Manual-first when AI is down + the honest GitHub scorecard (/vs-github stops overclaiming) #5504
9 changed files+729−20
Addeddocs/ops/MANUAL-FIRST.md+35−0View fileUnifiedSplit
@@ -0,0 +1,35 @@
1# Manual-first operations — what to do when AI is down
2
3Owner directive (2026-08-19): *"We shouldn't be solely relying on AI — if
4AI goes down, operators should be able to check and fix code manually."*
5
6This is the operator's page for that. Every surface below has a working
7by-hand path; the AI action on each is a convenience layered on top. The
8ship path itself (push → CI → gates → merge → deploy) has no AI dependency
9at all — see `docs/ops/OPERATIONS.md` §"Ship-path AI independence" and
10`src/__tests__/ship-path-ai-independence.test.ts`.
11
12How to tell AI is down on a host: `isAiAvailable()` (`src/lib/ai-client.ts`)
13is false when `ANTHROPIC_API_KEY` is unset. AI buttons render disabled with
14the title "AI unavailable on this host — see manual steps"; AI gates report
15"skipped / unavailable" and never block.
16
17| Situation | Where to look | What you do by hand |
18|---|---|---|
19| **Failed CI** | `/:owner/:repo/actions/runs/:id` (full log per step; `/actions` lists runs). API: `GET /api/v2/repos/:owner/:repo/actions/runs/:id/logs`. | Read the failing step's log, fix the code on the branch, push. A push re-queues the workflow. To re-run without a code change: `/:owner/:repo/actions` → the **Run** button next to the workflow (`POST /:owner/:repo/actions/:workflowId/run`, repo owner). |
20| **Red gate** | `/:owner/:repo/gates` (recent gate runs, per-check result + details) and the "Gate checks" card on the PR page. | Each row says which check failed and why (`details`). Fix the cause (tests, secrets, conflicts), push; gates re-evaluate on the next PR page load and on merge. `POST /:owner/:repo/gates/run` re-runs on the default branch. AI review / AI security scan rows showing "skipped — unavailable" are fail-open and never block. |
21| **Merge conflict** | PR page → "Gate checks" → **Resolve conflicts manually** panel (lists the conflicting files and the exact commands). | `git fetch origin` · `git checkout <head>` · `git merge origin/<base>` · fix the listed files · `git add -A && git commit` · `git push origin <head>`. The merge check re-runs on the next page load; the Merge button unblocks once it passes. Helper: `src/lib/merge-conflicts.ts` (AI-free). |
22| **Security finding / health item** | `/:owner/:repo/health` → each "How to improve" row → **Fix it yourself** block (file:line per rule, the rule id, and the change). The "Security findings" card below lists every occurrence. | Make the change on a branch, push, open a PR. The score recomputes on the next visit. "Fix this for me" is disabled when AI is down; when AI is up it is the shortcut, never the only path. |
23| **Platform error** | `/admin/errors` (server + client beacons; each row has the route, message and an expandable stack with `file:line`). | Open the file at that line, fix, push to canonical → the 60 s `gluecron-update.timer` redeploys. Then "Mark resolved" on the row (`POST /admin/errors/resolve`). |
24| **Spine red** | `/admin/spine` — one board: synthetic checks (URL suite, `surface:`, `dep:`), open incidents, and the **remediation ledger** (`audit_log` rows `remediation.*` — what autopilot already tried and the outcome). | Read the ledger first so you do not repeat an attempted fix. The page is read-only by design; fix at the source (code → push, or env → `/opt/gluecron/.env` + `docker compose up -d`), then watch the check go green. Box access: `ssh root@100.109.131.122`, app at `/opt/gluecron`. |
25| **Repair-queue issue** | Repo issues labelled `ai:repair` (from "Fix this for me" in agent mode, specs, and autopilot). Each body is a spec that names the goal and, for health/security items, the `file:line` findings. | Implement it by hand on a branch, open a PR, and close the issue with a comment linking the PR. Nothing else consumes the queue when no agent is draining it — an open `ai:repair` issue is just an issue. |
26
27Rules of thumb:
28
29- A page that says "AI unavailable" is telling the truth, not failing. Look
30 for the manual block on the same page before escalating.
31- Never wait on an AI action to land a fix. Code → push → PR is always open.
32- If a surface offers *only* an AI action with no by-hand instructions,
33 that is a bug in the platform: file it, and add the manual path
34 (`src/__tests__/ship-path-ai-independence.test.ts` pins the ones that
35 exist today).
Modifieddocs/ops/OPERATIONS.md+4−0View fileUnifiedSplit
@@ -120,6 +120,10 @@ features on the ship path either stay AI-free or add a degradation seam
120120and pin it there. Audited 2026-08-10 with a zero balance: ~10 PRs shipped
121121over two days in that state.
122122
123**Operator runbook for AI-down:** `docs/ops/MANUAL-FIRST.md` — the by-hand
124path for failed CI, red gates, merge conflicts, health/security findings,
125platform errors, spine red, and repair-queue issues (2026-08-19).
126
123127## GitHub reality (2026-07-12)
124128
125129The old GitHub repo (`ccantynz-alt/Gluecron.com`) is NOT frozen and is
Addedsrc/__tests__/merge-conflicts.test.ts+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1/**
2 * merge-conflicts — the manual-first conflict listing the PR page shows
3 * when a merge cannot proceed (and AI may or may not be there to help).
4 *
5 * Builds a real bare repo with a genuine content conflict and asserts the
6 * helper names the file, never throws on garbage input, and keeps the AI
7 * client out of its dependency graph entirely.
8 */
9
10import { describe, it, expect, beforeAll, afterAll } from "bun:test";
11import { mkdtemp, rm, writeFile } from "fs/promises";
12import { tmpdir } from "os";
13import { join } from "path";
14import {
15 listConflictingFiles,
16 manualResolutionCommands,
17 parseClassicMergeTreeConflicts,
18 parseWriteTreeNameOnly,
19} from "../lib/merge-conflicts";
20
21const ENV = {
22 ...process.env,
23 GIT_AUTHOR_NAME: "t",
24 GIT_AUTHOR_EMAIL: "t@example.com",
25 GIT_COMMITTER_NAME: "t",
26 GIT_COMMITTER_EMAIL: "t@example.com",
27};
28
29async function git(cwd: string, ...args: string[]): Promise<string> {
30 const proc = Bun.spawn(["git", ...args], { cwd, env: ENV, stdout: "pipe", stderr: "pipe" });
31 const [out, err] = await Promise.all([
32 new Response(proc.stdout).text(),
33 new Response(proc.stderr).text(),
34 ]);
35 const code = await proc.exited;
36 if (code !== 0) throw new Error(`git ${args.join(" ")} failed (${code}): ${err}`);
37 return out.trim();
38}
39
40let scratch: string;
41let bareDir: string;
42
43beforeAll(async () => {
44 scratch = await mkdtemp(join(tmpdir(), "gluecron-merge-conflicts-"));
45 bareDir = join(scratch, "repo.git");
46 const work = join(scratch, "work");
47 await git(scratch, "init", "--bare", "-b", "main", bareDir);
48 await git(scratch, "clone", "-q", bareDir, work);
49 await writeFile(join(work, "shared.txt"), "line 1\nline 2\n");
50 await writeFile(join(work, "untouched.txt"), "stable\n");
51 await git(work, "add", "-A");
52 await git(work, "commit", "-q", "-m", "base");
53 await git(work, "push", "-q", "origin", "main");
54
55 await git(work, "checkout", "-q", "-b", "feature");
56 await writeFile(join(work, "shared.txt"), "line 1 — feature\nline 2\n");
57 await writeFile(join(work, "feature-only.txt"), "new\n");
58 await git(work, "add", "-A");
59 await git(work, "commit", "-q", "-m", "feature edit");
60 await git(work, "push", "-q", "origin", "feature");
61
62 await git(work, "checkout", "-q", "main");
63 await writeFile(join(work, "shared.txt"), "line 1 — main\nline 2\n");
64 await git(work, "add", "-A");
65 await git(work, "commit", "-q", "-m", "main edit");
66 await git(work, "push", "-q", "origin", "main");
67});
68
69afterAll(async () => {
70 await rm(scratch, { recursive: true, force: true });
71});
72
73describe("listConflictingFiles", () => {
74 it("names the conflicting file and only that file", async () => {
75 const res = await listConflictingFiles(bareDir, "main", "feature");
76 expect(res.files).toEqual(["shared.txt"]);
77 expect(res.source).not.toBe("none");
78 });
79
80 it("returns an empty list for a clean merge", async () => {
81 // feature vs itself is trivially clean; so is main vs main.
82 const res = await listConflictingFiles(bareDir, "main", "main");
83 expect(res.files).toEqual([]);
84 });
85
86 it("never throws on an unsafe ref or a missing repo", async () => {
87 const unsafe = await listConflictingFiles(bareDir, "--output=/tmp/x", "feature");
88 expect(unsafe.files).toEqual([]);
89 expect(unsafe.source).toBe("none");
90 expect(unsafe.note).toBeTruthy();
91
92 const missing = await listConflictingFiles(join(scratch, "nope.git"), "main", "feature");
93 expect(missing.files).toEqual([]);
94 expect(missing.source).toBe("none");
95 });
96});
97
98describe("parsers", () => {
99 it("reads --write-tree --name-only output", () => {
100 const out = "0123456789abcdef0123456789abcdef01234567\nsrc/a.ts\nsrc/b.ts\n\n";
101 expect(parseWriteTreeNameOnly(out)).toEqual(["src/a.ts", "src/b.ts"]);
102 });
103
104 it("reads classic merge-tree output", () => {
105 const sha = "a".repeat(40);
106 const out = [
107 "changed in both",
108 ` base 100644 ${sha} src/conflict.ts`,
109 ` our 100644 ${sha} src/conflict.ts`,
110 ` their 100644 ${sha} src/conflict.ts`,
111 "@@ -1 +1,5 @@",
112 "+<<<<<<< .our",
113 " x",
114 "+=======",
115 " y",
116 "+>>>>>>> .their",
117 "merged",
118 ` result 100644 ${sha} src/clean.ts`,
119 ` our 100644 ${sha} src/clean.ts`,
120 "",
121 ].join("\n");
122 expect(parseClassicMergeTreeConflicts(out)).toEqual(["src/conflict.ts"]);
123 });
124});
125
126describe("manualResolutionCommands", () => {
127 it("spells out fetch → checkout → merge → commit → push", () => {
128 const cmds = manualResolutionCommands("main", "feature");
129 expect(cmds[0]).toBe("git fetch origin");
130 expect(cmds[1]).toBe("git checkout feature");
131 expect(cmds[2]).toStartWith("git merge origin/main");
132 expect(cmds[3]).toBe("git add -A && git commit");
133 expect(cmds[4]).toBe("git push origin feature");
134 });
135});
136
137describe("AI independence", () => {
138 it("the helper never imports ai-client", async () => {
139 const src = await Bun.file("src/lib/merge-conflicts.ts").text();
140 expect(src).not.toContain("ai-client");
141 expect(src).not.toContain("getAnthropic");
142 });
143});
Modifiedsrc/__tests__/ship-path-ai-independence.test.ts+74−0View fileUnifiedSplit
@@ -97,3 +97,77 @@ describe("gates degrade, never block, on AI outage", () => {
9797 expect(fn).not.toContain("ai-client");
9898 });
9999});
100
101describe("operator surfaces offer a manual path", () => {
102 // Owner directive 2026-08-19: "we shouldn't be solely relying on AI — if
103 // AI goes down, operators should be able to check and fix code manually."
104 // Every surface whose remedy used to be an AI action must ALSO render a
105 // working by-hand path, and the AI control must be honest when
106 // isAiAvailable() is false. Pinned at source level so a redesign cannot
107 // quietly drop the manual block while keeping the AI button.
108
109 it("the PR page renders a 'Resolve conflicts manually' panel off the gate result", async () => {
110 const src = await read("src/routes/pulls.tsx");
111 expect(src).toContain("Resolve conflicts manually");
112 expect(src).toContain('from "../lib/merge-conflicts"');
113 expect(src).toContain("listConflictingFiles(");
114 expect(src).toContain("manualResolutionCommands(");
115 // The panel is keyed off the gate result, not off AI availability —
116 // present whether or not the host has a working AI.
117 const panel = src.slice(src.indexOf("const mergeCheckConflicted"));
118 expect(panel).toContain('c.name === "Merge check" && !c.passed && !c.skipped');
119 expect(src.indexOf("Resolve conflicts manually")).toBeGreaterThan(src.indexOf("mergeCheckConflicted &&"));
120 });
121
122 it("the conflict helper never imports ai-client and names the exact commands", async () => {
123 const src = await read("src/lib/merge-conflicts.ts");
124 expect(src).not.toContain("ai-client");
125 expect(src).not.toContain("getAnthropic");
126 expect(src).not.toContain("isAiAvailable");
127 expect(src).toContain("merge-tree");
128 for (const cmd of ["git fetch", "git checkout", "git merge", "git add -A && git commit", "git push"]) {
129 expect(src).toContain(cmd);
130 }
131 });
132
133 it("the health page renders a 'Fix it yourself' block and an honest disabled AI button", async () => {
134 const src = await read("src/routes/health.tsx");
135 expect(src).toContain("Fix it yourself");
136 expect(src).toContain("ManualFixBlock");
137 expect(src).toContain("disabled={!aiRepairPossible}");
138 expect(src).toContain("AI unavailable on this host — see manual steps");
139 // The manual block lists file:line per rule — reads the finding, no AI.
140 const block = src.slice(src.indexOf("const ManualFixBlock"), src.indexOf("const HealthNav"));
141 expect(block).toContain("issues.filter((i) => i.rule === imp.rule)");
142 expect(block).not.toContain("getAnthropic");
143 // The POST handler refuses honestly instead of 500-ing deep in createSpecPR.
144 expect(src).toContain("AI is unavailable on this host");
145 });
146
147 it("the issue re-triage button is gated and its handler is honest with AI down", async () => {
148 const src = await read("src/routes/issues.tsx");
149 const btn = src.slice(src.indexOf("Re-run AI triage") - 800, src.indexOf("Re-run AI triage"));
150 expect(btn).toContain("disabled={!isAiAvailable()}");
151 const handler = src.slice(src.indexOf('"/:owner/:repo/issues/:number/ai-retriage"'));
152 expect(handler.indexOf("if (!isAiAvailable())")).toBeGreaterThan(-1);
153 expect(handler.indexOf("if (!isAiAvailable())")).toBeLessThan(handler.indexOf("triggerIssueTriage("));
154 });
155
156 it("the operator runbook exists and covers every surface", async () => {
157 const doc = await read("docs/ops/MANUAL-FIRST.md");
158 for (const needle of [
159 "Failed CI",
160 "Red gate",
161 "Merge conflict",
162 "Security finding",
163 "Platform error",
164 "Spine red",
165 "Repair-queue issue",
166 "/admin/errors",
167 "/admin/spine",
168 "ai:repair",
169 ]) {
170 expect(doc).toContain(needle);
171 }
172 });
173});
Modifiedsrc/lib/intelligence.ts+6−0View fileUnifiedSplit
@@ -108,6 +108,11 @@ export interface HealthImprovement {
108108 detail: string;
109109 /** Estimated gain to the OVERALL 0-100 score if completed. */
110110 overallGain: number;
111 /**
112 * Security improvements only: the scanner rule id, so a UI can list the
113 * exact file:line findings behind the action (manual-first repair path).
114 */
115 rule?: string;
111116}
112117
113118/**
@@ -144,6 +149,7 @@ export function computeImprovements(
144149 const files = [...new Set(issues.map((i) => i.file))];
145150 out.push({
146151 category: "security",
152 rule,
147153 action: `Fix ${rule}: ${issues[0]?.message ?? rule}`,
148154 detail: `${issues.length} occurrence${issues.length !== 1 ? "s" : ""} in ${files.length} file${files.length !== 1 ? "s" : ""} (${files.slice(0, 3).join(", ")}${files.length > 3 ? ", …" : ""})`,
149155 overallGain: overall("security", penalty * scale),
Addedsrc/lib/merge-conflicts.ts+165−0View fileUnifiedSplit
@@ -0,0 +1,165 @@
1/**
2 * Manual-first merge-conflict listing.
3 *
4 * Owner directive (2026-08-19): "we shouldn't be solely relying on AI — if
5 * AI goes down, operators should be able to check and fix code manually."
6 * The PR page used to say "resolve manually" without naming a single file.
7 * This module names them, with no AI anywhere in the dependency graph
8 * (pinned by ship-path-ai-independence.test.ts).
9 *
10 * Pure helper: never throws, bounded by a timeout, returns [] plus a note
11 * when git cannot answer. Prefers `git merge-tree --write-tree --name-only`
12 * (git >= 2.38) and falls back to parsing the classic three-way output.
13 */
14
15import { gitExecTimeoutMs, isSafeRef } from "../git/repository";
16
17export interface ConflictListing {
18 /** Repo-relative paths that would conflict when merging head into base. */
19 files: string[];
20 /** Which git strategy produced the answer. */
21 source: "merge-tree-write-tree" | "merge-tree-classic" | "none";
22 /** Human-readable caveat when files could not be determined. */
23 note?: string;
24}
25
26/** Parse the classic `git merge-tree <base> <a> <b>` output for conflicted paths. */
27export function parseClassicMergeTreeConflicts(out: string): string[] {
28 const files = new Set<string>();
29 const lines = out.split("\n");
30 for (let i = 0; i < lines.length; i++) {
31 const line = lines[i];
32 // Classic output prefixes each hunk with e.g.
33 // changed in both
34 // base 100644 <sha> path
35 // our 100644 <sha> path
36 // their 100644 <sha> path
37 // followed by a diff containing <<<<<<< markers when the hunk conflicts.
38 if (/^(changed in both|added in both|removed in (local|remote)|both)/.test(line)) {
39 let path: string | null = null;
40 let conflicted = false;
41 for (let j = i + 1; j < lines.length; j++) {
42 const l = lines[j];
43 const m = l.match(/^\s+(base|our|their)\s+\d{6}\s+[0-9a-f]{40}\s+(.+)$/);
44 if (m) {
45 path = path ?? m[2].trim();
46 continue;
47 }
48 if (/^(changed in both|added in both|removed in (local|remote)|both|merged|added in (local|remote))/.test(l)) break;
49 if (l.startsWith("<<<<<<<") || l.startsWith("+<<<<<<<")) conflicted = true;
50 }
51 if (path && (conflicted || line.startsWith("added in both") || line.startsWith("removed in"))) {
52 files.add(path);
53 }
54 }
55 }
56 return [...files];
57}
58
59/** Parse `git merge-tree --write-tree --name-only` stdout (conflicted paths after the tree OID). */
60export function parseWriteTreeNameOnly(out: string): string[] {
61 const lines = out.split("\n").map((l) => l.trim());
62 // First line is the tree OID (or missing on error); the rest, up to a blank
63 // line, are conflicted file names. With --name-only there is no
64 // informational section, but a blank line still ends the list safely.
65 const names: string[] = [];
66 for (let i = 1; i < lines.length; i++) {
67 const l = lines[i];
68 if (!l) break;
69 names.push(l);
70 }
71 return [...new Set(names)];
72}
73
74async function runGit(
75 repoDir: string,
76 args: string[],
77 timeoutMs: number
78): Promise<{ stdout: string; stderr: string; exitCode: number } | null> {
79 let proc: ReturnType<typeof Bun.spawn>;
80 try {
81 proc = Bun.spawn(["git", ...args], {
82 cwd: repoDir,
83 stdout: "pipe",
84 stderr: "pipe",
85 timeout: timeoutMs,
86 killSignal: "SIGKILL",
87 });
88 } catch {
89 return null;
90 }
91 try {
92 const [stdout, stderr] = await Promise.all([
93 new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
94 new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
95 ]);
96 const exitCode = await proc.exited;
97 return { stdout, stderr, exitCode };
98 } catch {
99 return null;
100 }
101}
102
103/**
104 * List the files that conflict when `headBranch` is merged into `baseBranch`.
105 * Never throws. Returns `{ files: [], source: "none", note }` when it cannot tell.
106 */
107export async function listConflictingFiles(
108 repoDir: string,
109 baseBranch: string,
110 headBranch: string,
111 opts: { timeoutMs?: number } = {}
112): Promise<ConflictListing> {
113 if (!isSafeRef(baseBranch) || !isSafeRef(headBranch)) {
114 return { files: [], source: "none", note: "Branch name rejected as unsafe." };
115 }
116 const timeoutMs = Math.max(1_000, opts.timeoutMs ?? Math.min(gitExecTimeoutMs(), 15_000));
117
118 // Modern path (git >= 2.38): exit 1 means conflicts, stdout lists them.
119 const modern = await runGit(
120 repoDir,
121 ["merge-tree", "--write-tree", "--name-only", baseBranch, headBranch],
122 timeoutMs
123 );
124 if (modern && (modern.exitCode === 0 || modern.exitCode === 1)) {
125 const files = modern.exitCode === 1 ? parseWriteTreeNameOnly(modern.stdout) : [];
126 return { files, source: "merge-tree-write-tree" };
127 }
128 // exit 129 = unknown option on old git; anything else unexpected — fall back.
129
130 const mergeBase = await runGit(repoDir, ["merge-base", baseBranch, headBranch], timeoutMs);
131 if (!mergeBase || mergeBase.exitCode !== 0 || !mergeBase.stdout.trim()) {
132 return {
133 files: [],
134 source: "none",
135 note: "Could not compute a merge base — run the commands below locally to see the conflicts.",
136 };
137 }
138 const classic = await runGit(
139 repoDir,
140 ["merge-tree", mergeBase.stdout.trim(), baseBranch, headBranch],
141 timeoutMs
142 );
143 if (!classic || classic.exitCode !== 0) {
144 return {
145 files: [],
146 source: "none",
147 note: "git could not list the conflicting files — run the commands below locally to see them.",
148 };
149 }
150 return { files: parseClassicMergeTreeConflicts(classic.stdout), source: "merge-tree-classic" };
151}
152
153/**
154 * The exact commands an operator runs to resolve the conflicts by hand.
155 * Pure, so the PR page, the API, and the docs all render the same thing.
156 */
157export function manualResolutionCommands(baseBranch: string, headBranch: string, remote = "origin"): string[] {
158 return [
159 `git fetch ${remote}`,
160 `git checkout ${headBranch}`,
161 `git merge ${remote}/${baseBranch} # fix the files listed above, then:`,
162 `git add -A && git commit`,
163 `git push ${remote} ${headBranch}`,
164 ];
165}
Modifiedsrc/routes/health.tsx+187−19View fileUnifiedSplit
@@ -16,6 +16,7 @@ import {
1616 computeImprovements,
1717 detectCIConfig,
1818 healthWeights,
19 type HealthImprovement,
1920 type RepoHealthReport,
2021 type SecurityIssue,
2122} from "../lib/intelligence";
@@ -25,7 +26,7 @@ import type { AuthEnv } from "../middleware/auth";
2526import { loadRepoByPath } from "../lib/namespace";
2627import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
2728import { createSpecPR } from "../lib/spec-to-pr";
28import { internalAiMode } from "../lib/ai-client";
29import { internalAiMode, isAiAvailable } from "../lib/ai-client";
2930import { queueRepairIssue } from "../lib/repair-queue";
3031import { isSiteAdmin } from "../lib/admin";
3132
@@ -133,6 +134,37 @@ const hlthStyles = `
133134 cursor: pointer; transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
134135 }
135136 .hlth-fix-btn:hover { background: var(--accent); color: var(--bg-elevated); }
137 .hlth-fix-btn[disabled] {
138 cursor: not-allowed; opacity: .55;
139 border-color: var(--border-strong); color: var(--text-muted);
140 }
141 .hlth-fix-btn[disabled]:hover { background: transparent; color: var(--text-muted); }
142 .hlth-row-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex-shrink: 0; }
143 .hlth-ai-down { font-size: var(--t-xs); color: var(--text-faint); text-align: right; }
144
145 /* ─── Manual-first block: what to change, by hand, with or without AI ─── */
146 .hlth-manual { margin-top: 6px; }
147 .hlth-manual summary {
148 cursor: pointer; list-style: none; font-size: var(--t-sm);
149 color: var(--accent); display: inline-flex; align-items: center; gap: 6px;
150 }
151 .hlth-manual summary::-webkit-details-marker { display: none; }
152 .hlth-manual summary::before { content: '▸'; font-size: 10px; transition: transform var(--t-fast) var(--ease); }
153 .hlth-manual[open] summary::before { transform: rotate(90deg); }
154 .hlth-manual-body {
155 margin-top: 6px; padding: 10px 12px;
156 border: 1px solid var(--border-subtle); border-left: 3px solid var(--accent);
157 border-radius: var(--r); background: var(--bg-secondary);
158 font-size: var(--t-sm); color: var(--text-muted);
159 }
160 .hlth-manual-body p { margin: 0 0 6px; }
161 .hlth-manual-body p:last-child { margin-bottom: 0; }
162 .hlth-manual-loc {
163 font-family: var(--font-mono); font-size: 12px; color: var(--text);
164 margin: 0; padding: 0; list-style: none;
165 }
166 .hlth-manual-loc li { padding: 2px 0; }
167 .hlth-manual-loc li span { color: var(--text-muted); }
136168 .hlth-improve-foot {
137169 padding: 10px var(--space-5); font-size: var(--t-xs); color: var(--text-faint);
138170 border-top: 1px solid var(--border-subtle); background: var(--bg-secondary);
@@ -210,6 +242,16 @@ health.get("/:owner/:repo/health", async (c) => {
210242 const repairError = c.req.query("repair_error");
211243 const ref = (await getDefaultBranch(owner, repo)) || "main";
212244
245 // Manual-first (owner directive 2026-08-19): the AI repair is a
246 // convenience, never the only path. The button is honest when the host has
247 // no working AI — disabled, with the by-hand block always rendered below.
248 // The site-admin agent-queue path needs no API key (it queues an issue a
249 // subscription-backed agent drains), so it stays live in that mode.
250 let aiRepairPossible = isAiAvailable();
251 if (!aiRepairPossible && user && internalAiMode() === "agent") {
252 aiRepairPossible = await isSiteAdmin(user.id).catch(() => false);
253 }
254
213255 // Run analysis in parallel
214256 const [report, ciConfig] = await Promise.all([
215257 computeHealthScore(owner, repo),
@@ -286,32 +328,64 @@ health.get("/:owner/:repo/health", async (c) => {
286328 <span class="hlth-row-cat">{imp.category}</span>
287329 </div>
288330 <div class="hlth-row-detail">{imp.detail}</div>
331 <ManualFixBlock
332 imp={imp}
333 issues={report.breakdown.security.issues}
334 open={!aiRepairPossible}
335 />
289336 </div>
290337 {canRepair && (
291 <form
292 method="post"
293 action={`/${owner}/${repo}/health/repair`}
294 >
295 <input type="hidden" name="action" value={imp.action} />
296 <input type="hidden" name="detail" value={imp.detail} />
297 <input type="hidden" name="category" value={imp.category} />
298 <button
299 type="submit"
300 class="hlth-fix-btn"
301 title="Have the AI implement this fix and open a pull request for your review"
338 <div class="hlth-row-actions">
339 <form
340 method="post"
341 action={`/${owner}/${repo}/health/repair`}
302342 >
303 Fix this for me
304 </button>
305 </form>
343 <input type="hidden" name="action" value={imp.action} />
344 <input type="hidden" name="detail" value={imp.detail} />
345 <input type="hidden" name="category" value={imp.category} />
346 <input
347 type="hidden"
348 name="locations"
349 value={findingLocations(imp, report.breakdown.security.issues)}
350 />
351 <button
352 type="submit"
353 class="hlth-fix-btn"
354 disabled={!aiRepairPossible}
355 title={
356 aiRepairPossible
357 ? "Have the AI implement this fix and open a pull request for your review"
358 : "AI unavailable on this host — see manual steps"
359 }
360 >
361 Fix this for me
362 </button>
363 </form>
364 {!aiRepairPossible && (
365 <span class="hlth-ai-down">AI unavailable — fix it yourself below</span>
366 )}
367 </div>
306368 )}
307369 </div>
308370 ))}
309371 </div>
310372 {canRepair && (
311373 <div class="hlth-improve-foot">
312 "Fix this for me" queues the change for the repair agent — it
313 lands as a pull request for your review; nothing reaches{" "}
314 <code>{ref}</code> without you merging it.
374 {aiRepairPossible ? (
375 <>
376 "Fix this for me" queues the change for the repair agent — it
377 lands as a pull request for your review; nothing reaches{" "}
378 <code>{ref}</code> without you merging it. Every row also
379 carries the by-hand steps, so nothing here depends on AI.
380 </>
381 ) : (
382 <>
383 AI is unavailable on this host right now, so "Fix this for me"
384 is disabled. Each row's "Fix it yourself" block names the
385 file:line and the change — make it on a branch, push, and open
386 a pull request as usual.
387 </>
388 )}
315389 </div>
316390 )}
317391 </div>
@@ -501,14 +575,28 @@ health.post("/:owner/:repo/health/repair", async (c) => {
501575 const action = String(body.action || "").trim().slice(0, 300);
502576 const detail = String(body.detail || "").trim().slice(0, 600);
503577 const category = String(body.category || "").trim().slice(0, 40);
578 // file:line list for security rules — rendered into the spec so a human
579 // draining the queue by hand (AI down) has the exact locations.
580 const locations = String(body.locations || "").trim().slice(0, 800);
504581 if (!action) return fail("No repair action provided.");
505582
583 // Honest dead-end guard: with no working AI and no agent queue to hand the
584 // job to, say so in plain words rather than failing deep inside createSpecPR.
585 const agentQueueAvailable =
586 internalAiMode() === "agent" && (await isSiteAdmin(user.id));
587 if (!isAiAvailable() && !agentQueueAvailable) {
588 return fail(
589 "AI is unavailable on this host — use the \"Fix it yourself\" steps on each row instead."
590 );
591 }
592
506593 const spec = [
507594 `# Health repair: ${action}`,
508595 ``,
509596 `This change comes from the repository health page (category: ${category || "general"}).`,
510597 `Goal: ${action}.`,
511598 detail ? `Context from the health analysis: ${detail}` : ``,
599 locations ? `Locations (file:line): ${locations}` : ``,
512600 ``,
513601 `Make the smallest correct change that accomplishes the goal. Follow the`,
514602 `repository's existing conventions (license style, file layout, code idiom).`,
@@ -523,7 +611,7 @@ health.post("/:owner/:repo/health/repair", async (c) => {
523611 // ONLY: a customer's click must never become free labor billed to the
524612 // owner's subscription. Customers always take the API path below, where
525613 // createSpecPR meters the spend against their own AI quota.
526 if (internalAiMode() === "agent" && (await isSiteAdmin(user.id))) {
614 if (agentQueueAvailable) {
527615 const issueNumber = await queueRepairIssue({
528616 repositoryId: repoRow.id,
529617 authorId: user.id,
@@ -544,6 +632,86 @@ health.post("/:owner/:repo/health/repair", async (c) => {
544632 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
545633});
546634
635/**
636 * Manual-first repair block. Always rendered (collapsed when AI can take the
637 * job, open when it cannot) so an operator has the exact file:line, rule and
638 * change to make by hand. Reads only what the finding already carries — no
639 * AI call, no network.
640 */
641/** "file:1, 2; other.ts:9" — the finding locations behind a security improvement. */
642function findingLocations(imp: HealthImprovement, issues: SecurityIssue[]): string {
643 if (imp.category !== "security" || !imp.rule) return "";
644 const byFile = new Map<string, (number | string)[]>();
645 for (const i of issues) {
646 if (i.rule !== imp.rule) continue;
647 const lines = byFile.get(i.file) ?? [];
648 lines.push(i.line ?? "?");
649 byFile.set(i.file, lines);
650 }
651 return [...byFile.entries()]
652 .map(([file, lines]) => `${file}:${lines.join(",")}`)
653 .join("; ")
654 .slice(0, 800);
655}
656
657const ManualFixBlock = ({
658 imp,
659 issues,
660 open,
661}: {
662 imp: HealthImprovement;
663 issues: SecurityIssue[];
664 open: boolean;
665}) => {
666 const findings =
667 imp.category === "security" && imp.rule
668 ? issues.filter((i) => i.rule === imp.rule)
669 : [];
670 const byFile = new Map<string, (number | string)[]>();
671 for (const f of findings) {
672 const lines = byFile.get(f.file) ?? [];
673 lines.push(f.line ?? "?");
674 byFile.set(f.file, lines);
675 }
676 return (
677 <details class="hlth-manual" open={open} data-testid="fix-it-yourself">
678 <summary>Fix it yourself</summary>
679 <div class="hlth-manual-body">
680 <p>
681 <strong style="color: var(--text)">Change:</strong> {imp.action}
682 {imp.rule ? (
683 <>
684 {" "}
685 <span style="font-family: var(--font-mono)">({imp.rule})</span>
686 </>
687 ) : null}
688 </p>
689 <p>{imp.detail}</p>
690 {findings.length > 0 ? (
691 <>
692 <p>
693 <strong style="color: var(--text)">Where</strong> —{" "}
694 {findings[0]?.message}:
695 </p>
696 <ul class="hlth-manual-loc">
697 {[...byFile.entries()].map(([file, lines]) => (
698 <li>
699 {file}
700 <span>:{lines.join(", ")}</span>
701 </li>
702 ))}
703 </ul>
704 </>
705 ) : null}
706 <p style="margin-top: 6px">
707 Make the change on a branch, push it, and open a pull request — the
708 health score recomputes on the next visit.
709 </p>
710 </div>
711 </details>
712 );
713};
714
547715const HealthNav = ({
548716 owner,
549717 repo,
Modifiedsrc/routes/issues.tsx+16−1View fileUnifiedSplit
@@ -1864,7 +1864,12 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
18641864 formaction={`/${ownerName}/${repoName}/issues/${issue.number}/ai-retriage`}
18651865 formnovalidate
18661866 class="btn"
1867 title="Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1867 disabled={!isAiAvailable()}
1868 title={
1869 isAiAvailable()
1870 ? "Re-run AI triage. Posts a fresh suggestions comment (use after editing the issue body)."
1871 : "AI unavailable on this host — triage by hand: set labels, assignee and milestone in the sidebar"
1872 }
18681873 >
18691874 Re-run AI triage
18701875 </button>
@@ -2283,6 +2288,16 @@ issueRoutes.post(
22832288 return c.redirect(`/${ownerName}/${repoName}/issues`);
22842289 }
22852290
2291 // Manual-first: don't promise a comment the triage lib will silently
2292 // skip. With no AI on the host, say so and point at the by-hand path.
2293 if (!isAiAvailable()) {
2294 return c.redirect(
2295 `/${ownerName}/${repoName}/issues/${issueNum}?info=${encodeURIComponent(
2296 "AI triage is unavailable on this host — triage by hand: set labels, assignee and milestone in the sidebar."
2297 )}`
2298 );
2299 }
2300
22862301 triggerIssueTriage(
22872302 {
22882303 ownerName,
Modifiedsrc/routes/pulls.tsx+99−0View fileUnifiedSplit
@@ -79,6 +79,8 @@ import {
7979 type PrRiskScore,
8080} from "../lib/pr-risk";
8181import { runAllGateChecks } from "../lib/gate";
82import { listConflictingFiles, manualResolutionCommands } from "../lib/merge-conflicts";
83import type { ConflictListing } from "../lib/merge-conflicts";
8284import type { GateCheckResult } from "../lib/gate";
8385import {
8486 matchProtection,
@@ -601,6 +603,50 @@ const PRS_DETAIL_STYLES = `
601603 color: var(--text-muted);
602604 }
603605
606 /* Manual-first conflict panel — always present when a merge would
607 conflict; the AI offer (when the host has one) is the convenience. */
608 .prs-conflict-panel {
609 margin: 0 18px 14px;
610 padding: 12px 14px;
611 border: 1px solid var(--border);
612 border-left: 3px solid var(--accent);
613 border-radius: var(--r, 8px);
614 background: var(--bg-elevated);
615 font-size: 12.5px;
616 color: var(--text-muted);
617 }
618 .prs-conflict-panel h4 {
619 margin: 0 0 6px;
620 font-size: 12px;
621 font-weight: 650;
622 letter-spacing: .04em;
623 text-transform: uppercase;
624 color: var(--text);
625 }
626 .prs-conflict-files {
627 margin: 6px 0 10px;
628 padding: 0;
629 list-style: none;
630 font-family: var(--font-mono);
631 font-size: 12px;
632 color: var(--text);
633 }
634 .prs-conflict-files li { padding: 2px 0; }
635 .prs-conflict-cmds {
636 margin: 0 0 8px;
637 padding: 10px 12px;
638 background: var(--bg-secondary);
639 border: 1px solid var(--border-subtle, var(--border));
640 border-radius: 6px;
641 font-family: var(--font-mono);
642 font-size: 12px;
643 line-height: 1.6;
644 color: var(--text);
645 overflow-x: auto;
646 white-space: pre;
647 }
648 .prs-conflict-note { font-size: 12px; color: var(--text-muted); }
649
604650 /* Readable measure — the PR description and conversation prose cap at
605651 ~900px instead of spanning the full content shell (~1500px on wide
606652 screens, unreadable line lengths). Diffs, tabs and panels keep the
@@ -4145,6 +4191,21 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
41454191 }
41464192 }
41474193
4194 // Manual-first: when the merge check failed on conflicts, name the files
4195 // and the commands so an operator can resolve by hand — with or without
4196 // AI on the host. Never throws; [] + note when git cannot answer.
4197 const mergeCheckConflicted =
4198 pr.state === "open" &&
4199 gateChecks.some((c) => c.name === "Merge check" && !c.passed && !c.skipped);
4200 let conflictListing: ConflictListing | null = null;
4201 if (mergeCheckConflicted) {
4202 conflictListing = await listConflictingFiles(
4203 getRepoPath(ownerName, repoName),
4204 pr.baseBranch,
4205 pr.headBranch
4206 ).catch(() => ({ files: [], source: "none" as const, note: "Could not list conflicting files." }));
4207 }
4208
41484209 // Block M3 — pre-merge risk score. Cache-only on the request path so
41494210 // the page never waits on Haiku. On a cache miss for an open PR we
41504211 // kick off the computation fire-and-forget; the next refresh shows it.
@@ -5250,6 +5311,44 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
52505311 </div>
52515312 );
52525313 })}
5314 {mergeCheckConflicted && (
5315 <div class="prs-conflict-panel" data-testid="resolve-conflicts-manually">
5316 <h4>Resolve conflicts manually</h4>
5317 {isAiAvailable() && (
5318 <div class="prs-conflict-note" style="margin-bottom:8px">
5319 GlueCron AI will attempt auto-resolution when you press Merge.
5320 If it cannot, or you would rather not wait on it, the manual
5321 path below always works.
5322 </div>
5323 )}
5324 {conflictListing && conflictListing.files.length > 0 ? (
5325 <>
5326 <div>
5327 {conflictListing.files.length} conflicting file
5328 {conflictListing.files.length === 1 ? "" : "s"} between{" "}
5329 <code>{pr.headBranch}</code> and <code>{pr.baseBranch}</code>:
5330 </div>
5331 <ul class="prs-conflict-files">
5332 {conflictListing.files.map((f) => (
5333 <li>{f}</li>
5334 ))}
5335 </ul>
5336 </>
5337 ) : (
5338 <div style="margin-bottom:8px">
5339 {conflictListing?.note ??
5340 "git could not list the conflicting files — the commands below will show them locally."}
5341 </div>
5342 )}
5343 <pre class="prs-conflict-cmds">
5344 {manualResolutionCommands(pr.baseBranch, pr.headBranch).join("\n")}
5345 </pre>
5346 <div class="prs-conflict-note">
5347 After you push, the merge check re-runs automatically on the next
5348 page load and the Merge button unblocks once it passes.
5349 </div>
5350 </div>
5351 )}
52535352 <div class="prs-gate-footer">
52545353 {gatesAllPassed
52555354 ? "All checks passed — ready to merge."
52565355
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts