feat(health): "Fix this for me" — one-click AI repair from the health page #5441
1 changed file+108−1
Modifiedsrc/routes/health.tsx+108−1View fileUnifiedSplit
@@ -22,6 +22,9 @@ import {
2222import { repoExists, getDefaultBranch } from "../git/repository";
2323import { softAuth } from "../middleware/auth";
2424import type { AuthEnv } from "../middleware/auth";
25import { loadRepoByPath } from "../lib/namespace";
26import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
27import { createSpecPR } from "../lib/spec-to-pr";
2528
2629const health = new Hono<AuthEnv>();
2730
@@ -33,6 +36,23 @@ health.get("/:owner/:repo/health", async (c) => {
3336
3437 if (!(await repoExists(owner, repo))) return c.notFound();
3538
39 // Can the viewer trigger an AI repair? Requires write access to the repo.
40 let canRepair = false;
41 try {
42 const repoRow = await loadRepoByPath(owner, repo);
43 if (repoRow && user) {
44 const level = await resolveRepoAccess({
45 repoId: repoRow.id,
46 userId: user.id,
47 isPublic: !repoRow.isPrivate,
48 });
49 canRepair = satisfiesAccess(level, "write");
50 }
51 } catch {
52 // Repair button is an enhancement — the page renders without it.
53 }
54
55 const repairError = c.req.query("repair_error");
3656 const ref = (await getDefaultBranch(owner, repo)) || "main";
3757
3858 // Run analysis in parallel
@@ -99,13 +119,18 @@ health.get("/:owner/:repo/health", async (c) => {
99119 <strong>{potential}</strong>/100. Gains are computed from the
100120 same formula that produced the score.
101121 </div>
122 {repairError && (
123 <div style="margin-bottom: 12px; padding: 10px 14px; border: 1px solid var(--red); border-radius: var(--radius); color: var(--red); font-size: 13px">
124 Repair failed: {repairError}
125 </div>
126 )}
102127 <div class="issue-list">
103128 {improvements.map((imp, idx) => (
104129 <div class="issue-item" style="display: flex; gap: 12px; align-items: baseline">
105130 <span style="font-size: 14px; font-weight: 700; color: var(--green); flex-shrink: 0; min-width: 52px; text-align: right; font-family: var(--font-mono)">
106131 +{imp.overallGain}
107132 </span>
108 <div style="min-width: 0">
133 <div style="min-width: 0; flex: 1">
109134 <div style="font-size: 14px; font-weight: 500">
110135 {idx + 1}. {imp.action}
111136 <span class="badge" style="margin-left: 8px; font-size: 10px; text-transform: capitalize">
@@ -116,9 +141,35 @@ health.get("/:owner/:repo/health", async (c) => {
116141 {imp.detail}
117142 </div>
118143 </div>
144 {canRepair && (
145 <form
146 method="post"
147 action={`/${owner}/${repo}/health/repair`}
148 style="flex-shrink: 0"
149 >
150 <input type="hidden" name="action" value={imp.action} />
151 <input type="hidden" name="detail" value={imp.detail} />
152 <input type="hidden" name="category" value={imp.category} />
153 <button
154 type="submit"
155 class="btn"
156 style="font-size: 12px; padding: 4px 10px"
157 title="Have the AI implement this fix and open a draft PR for your review"
158 >
159 Fix this for me
160 </button>
161 </form>
162 )}
119163 </div>
120164 ))}
121165 </div>
166 {canRepair && (
167 <div style="margin-top: 8px; font-size: 12px; color: var(--text-muted)">
168 "Fix this for me" has the AI implement the change on a branch and
169 open a pull request for your review — nothing lands on{" "}
170 <code>{ref}</code> without you merging it.
171 </div>
172 )}
122173 </div>
123174 )}
124175
@@ -281,6 +332,62 @@ health.get("/:owner/:repo/health", async (c) => {
281332 );
282333});
283334
335/**
336 * "Fix this for me" — turn one health improvement into an AI-implemented
337 * draft PR via the existing spec-to-PR pipeline. Write access required;
338 * AI quota is enforced inside createSpecPR (billing.assertAiQuota), so a
339 * customer with an exhausted budget gets the upgrade message, not a bill.
340 */
341health.post("/:owner/:repo/health/repair", async (c) => {
342 const { owner, repo } = c.req.param();
343 const user = c.get("user");
344 const healthUrl = `/${owner}/${repo}/health`;
345 const fail = (msg: string) =>
346 c.redirect(`${healthUrl}?repair_error=${encodeURIComponent(msg.slice(0, 200))}`);
347
348 if (!user) return c.redirect(`/login?redirect=${encodeURIComponent(healthUrl)}`);
349
350 const repoRow = await loadRepoByPath(owner, repo);
351 if (!repoRow) return c.notFound();
352 const level = await resolveRepoAccess({
353 repoId: repoRow.id,
354 userId: user.id,
355 isPublic: !repoRow.isPrivate,
356 });
357 if (!satisfiesAccess(level, "write")) {
358 return fail("You need write access to this repository to run repairs.");
359 }
360
361 const body = await c.req.parseBody();
362 const action = String(body.action || "").trim().slice(0, 300);
363 const detail = String(body.detail || "").trim().slice(0, 600);
364 const category = String(body.category || "").trim().slice(0, 40);
365 if (!action) return fail("No repair action provided.");
366
367 const spec = [
368 `# Health repair: ${action}`,
369 ``,
370 `This change comes from the repository health page (category: ${category || "general"}).`,
371 `Goal: ${action}.`,
372 detail ? `Context from the health analysis: ${detail}` : ``,
373 ``,
374 `Make the smallest correct change that accomplishes the goal. Follow the`,
375 `repository's existing conventions (license style, file layout, code idiom).`,
376 `Do not refactor unrelated code.`,
377 ]
378 .filter(Boolean)
379 .join("\n");
380
381 const result = await createSpecPR({
382 repoId: repoRow.id,
383 spec,
384 userId: user.id,
385 });
386
387 if (!result.ok) return fail(result.error);
388 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
389});
390
284391const HealthNav = ({
285392 owner,
286393 repo,
287394
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts