Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

fix-agent.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

fix-agent.tsBlame387 lines · 1 contributor
055ebc4Claude1/**
2 * Block K5 — Fix Agent.
3 *
4 * Pairs with Gatetest. When a PR has failing checks (or a human pokes the
5 * "try fix" button), the fix agent calls `runAndRepair` on Gatetest, which
6 * returns file-level before/after patches. For v1 we DO NOT auto-push the
7 * patches — instead we post a structured PR comment listing each repair with
8 * its reason. A human (or a follow-up agent) applies. This keeps the blast
9 * radius bounded while the platform gets its sea legs.
10 *
11 * Design rules (mirrors heal-bot.ts):
12 * - Never throws. Every DB / network error returns `{ ok: false, summary }`.
13 * - Runs inside the K1 `executeAgentRun` wrapper so the run row carries
14 * status + log + cost.
15 * - Cost: 3¢ flat (Gatetest compute proxy). No Anthropic round-trip.
16 * - No new tables.
17 */
18
19import { eq } from "drizzle-orm";
20import { db } from "../../db";
21import {
22 prComments,
23 pullRequests,
24 repositories,
25 users,
26} from "../../db/schema";
27import { runAndRepair, type GatetestRepair } from "../gatetest-client";
28import {
29 executeAgentRun,
30 startAgentRun,
31 type AgentExecutorContext,
32} from "../agent-runtime";
33
34// ---------------------------------------------------------------------------
35// Types
36// ---------------------------------------------------------------------------
37
38export interface RunFixAgentArgs {
39 repositoryId: string;
40 pullRequestId: string;
41 triggerBy?: string | null;
42 targetGlob?: string | null;
43}
44
45export interface RunFixAgentResult {
46 ok: boolean;
47 summary: string;
48 runId: string | null;
49}
50
51// ---------------------------------------------------------------------------
52// Constants
53// ---------------------------------------------------------------------------
54
55/** Per-run Gatetest compute cost proxy, in cents. */
56export const FIX_AGENT_COST_CENTS = 3;
57
58/** Max individual repairs to enumerate in the PR comment body. */
59export const FIX_AGENT_MAX_REPAIRS_IN_COMMENT = 20;
60
61/** Bot slug — matches the identity K2 will ensureAgentApp for. */
62export const FIX_AGENT_SLUG = "agent-fix";
63
64/** Bot username used in comment bodies so humans know who commented. */
65export const FIX_AGENT_BOT_USERNAME = "agent-fix[bot]";
66
67// ---------------------------------------------------------------------------
68// Pure helpers (unit-testable)
69// ---------------------------------------------------------------------------
70
71/**
72 * Render the PR comment body for a successful Gatetest run. Deterministic +
73 * no I/O so we can exercise it in isolation.
74 */
75export function renderFixAgentComment(params: {
76 passed: boolean;
77 totalTests: number;
78 failedBefore: number;
79 failedAfter: number;
80 repairs: GatetestRepair[];
81 unfixable: { file: string; reason: string }[];
82}): string {
83 const { passed, totalTests, failedBefore, failedAfter, repairs, unfixable } =
84 params;
85 const lines: string[] = [];
86 lines.push(`## Fix Agent`);
87 lines.push("");
88 if (passed) {
89 lines.push(
90 `Gatetest ran **${totalTests}** test${totalTests === 1 ? "" : "s"} — all passing after ${repairs.length} repair${repairs.length === 1 ? "" : "s"}.`
91 );
92 } else {
93 lines.push(
94 `Gatetest proposed **${repairs.length}** repair${repairs.length === 1 ? "" : "s"} (${failedBefore} → ${failedAfter} failing).`
95 );
96 }
97 lines.push("");
98
99 if (repairs.length > 0) {
100 lines.push(`### Repairs`);
101 const cap = Math.min(repairs.length, FIX_AGENT_MAX_REPAIRS_IN_COMMENT);
102 for (let i = 0; i < cap; i++) {
103 const r = repairs[i]!;
104 lines.push(`- \`${r.file}\` — ${r.reason}`);
105 }
106 if (repairs.length > cap) {
107 lines.push(`- _…and ${repairs.length - cap} more._`);
108 }
109 lines.push("");
110 }
111
112 if (unfixable.length > 0) {
113 lines.push(`### Unfixable`);
114 for (const u of unfixable.slice(0, FIX_AGENT_MAX_REPAIRS_IN_COMMENT)) {
115 lines.push(`- \`${u.file}\` — ${u.reason}`);
116 }
117 lines.push("");
118 }
119
120 lines.push(`_Generated by ${FIX_AGENT_BOT_USERNAME}._`);
121 return lines.join("\n");
122}
123
124/** Short summary stored in agent_runs.summary. */
125export function buildFixAgentSummary(params: {
126 offline: boolean;
127 passed: boolean;
128 failedBefore: number;
129 failedAfter: number;
130 repairs: number;
131}): string {
132 if (params.offline) return "gatetest offline; skipped";
133 if (params.repairs === 0 && params.failedBefore === 0) {
134 return "suite healthy; nothing to fix";
135 }
136 if (params.repairs === 0) {
137 return `${params.failedBefore} failing, 0 repairs proposed`;
138 }
139 if (params.passed) {
140 return `repaired ${params.repairs} (${params.failedBefore} → 0)`;
141 }
142 return `proposed ${params.repairs} repair${params.repairs === 1 ? "" : "s"} (${params.failedBefore} → ${params.failedAfter})`;
143}
144
145// ---------------------------------------------------------------------------
146// Internal DB helpers — defensive, never throw.
147// ---------------------------------------------------------------------------
148
149interface PrContext {
150 prId: string;
151 prNumber: number;
152 headBranch: string;
153 isClosed: boolean;
154 isMerged: boolean;
155 repoId: string;
156 repoName: string;
157 ownerUsername: string;
158 ownerId: string;
159}
160
161async function resolvePrContext(
162 pullRequestId: string
163): Promise<PrContext | null> {
164 try {
165 const rows = await db
166 .select({
167 prId: pullRequests.id,
168 prNumber: pullRequests.number,
169 headBranch: pullRequests.headBranch,
170 state: pullRequests.state,
171 mergedAt: pullRequests.mergedAt,
172 repoId: repositories.id,
173 repoName: repositories.name,
174 ownerUsername: users.username,
175 ownerId: repositories.ownerId,
176 })
177 .from(pullRequests)
178 .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId))
179 .innerJoin(users, eq(users.id, repositories.ownerId))
180 .where(eq(pullRequests.id, pullRequestId))
181 .limit(1);
182 const row = rows[0];
183 if (!row) return null;
184 return {
185 prId: row.prId,
186 prNumber: row.prNumber,
187 headBranch: row.headBranch,
188 isClosed: row.state !== "open",
189 isMerged: row.mergedAt !== null,
190 repoId: row.repoId,
191 repoName: row.repoName,
192 ownerUsername: row.ownerUsername,
193 ownerId: row.ownerId,
194 };
195 } catch (err) {
196 console.error("[fix-agent] resolvePrContext:", err);
197 return null;
198 }
199}
200
201/**
202 * Pick the author_id for the PR comment. Preference:
203 * 1. a "bot" user named FIX_AGENT_BOT_USERNAME,
204 * 2. the repo owner.
205 */
206async function resolveFixAgentAuthorId(
207 ownerId: string
208): Promise<string | null> {
209 try {
210 const [bot] = await db
211 .select({ id: users.id })
212 .from(users)
213 .where(eq(users.username, FIX_AGENT_BOT_USERNAME))
214 .limit(1);
215 if (bot?.id) return bot.id;
216 } catch (err) {
217 console.error("[fix-agent] bot author lookup:", err);
218 }
219 return ownerId;
220}
221
222// ---------------------------------------------------------------------------
223// Entry point
224// ---------------------------------------------------------------------------
225
226/**
227 * Run the fix agent for a single PR. Never throws.
228 *
229 * Lifecycle:
230 * 1. Open an `agent_runs` row (kind = "fix").
231 * 2. Resolve the PR; short-circuit if closed/merged.
232 * 3. Call Gatetest `runAndRepair` against the PR head branch.
233 * 4. On any result post a PR comment summarising the repairs.
234 * 5. Cost: 3¢ flat.
235 */
236export async function runFixAgent(
237 args: RunFixAgentArgs
238): Promise<RunFixAgentResult> {
239 if (
240 !args ||
241 typeof args.repositoryId !== "string" ||
242 !args.repositoryId ||
243 typeof args.pullRequestId !== "string" ||
244 !args.pullRequestId
245 ) {
246 return {
247 ok: false,
248 summary: "invalid args: missing repositoryId or pullRequestId",
249 runId: null,
250 };
251 }
252
253 const trigger = args.triggerBy ? "manual" : "pr.review_comment";
254
255 const run = await startAgentRun({
256 repositoryId: args.repositoryId,
257 kind: "fix",
258 trigger,
259 triggerRef: args.pullRequestId,
260 });
261 if (!run) {
262 return {
263 ok: false,
264 summary: "could not open agent_runs row",
265 runId: null,
266 };
267 }
268
269 let finalSummary = "not started";
270
271 await executeAgentRun(run.id, async (ctx: AgentExecutorContext) => {
272 await ctx.appendLog(
273 `[fix-agent] starting run for PR ${args.pullRequestId} (trigger=${trigger})`
274 );
275
276 const pr = await resolvePrContext(args.pullRequestId);
277 if (!pr) {
278 finalSummary = "PR lookup failed";
279 await ctx.appendLog("[fix-agent] PR lookup failed; aborting");
280 return { ok: false, summary: finalSummary };
281 }
282 if (pr.repoId !== args.repositoryId) {
283 finalSummary = "PR does not belong to repository";
284 await ctx.appendLog(
285 `[fix-agent] PR ${pr.prId} is in repo ${pr.repoId}, expected ${args.repositoryId}`
286 );
287 return { ok: false, summary: finalSummary };
288 }
289 if (pr.isClosed || pr.isMerged) {
290 finalSummary = "PR is closed/merged; skipped";
291 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
292 return { ok: true, summary: finalSummary };
293 }
294
295 const repoSlug = `${pr.ownerUsername}/${pr.repoName}`;
296 await ctx.appendLog(
297 `[fix-agent] calling gatetest.runAndRepair for ${repoSlug}@${pr.headBranch}`
298 );
299
300 const result = await runAndRepair({
301 repo: repoSlug,
302 ref: pr.headBranch,
303 targetGlob: args.targetGlob || undefined,
304 });
305 await ctx.recordCost(0, 0, FIX_AGENT_COST_CENTS);
306
307 if (result.offline) {
308 finalSummary = buildFixAgentSummary({
309 offline: true,
310 passed: false,
311 failedBefore: 0,
312 failedAfter: 0,
313 repairs: 0,
314 });
315 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
316 return { ok: true, summary: finalSummary };
317 }
318
319 await ctx.appendLog(
320 `[fix-agent] gatetest returned: passed=${result.passed}, failedBefore=${result.failedBefore}, failedAfter=${result.failedAfter}, repairs=${result.repairs.length}`
321 );
322
323 // If nothing to say, short-circuit before spamming the PR.
324 if (
325 result.repairs.length === 0 &&
326 result.unfixable.length === 0 &&
327 result.failedBefore === 0
328 ) {
329 finalSummary = buildFixAgentSummary({
330 offline: false,
331 passed: true,
332 failedBefore: 0,
333 failedAfter: 0,
334 repairs: 0,
335 });
336 return { ok: true, summary: finalSummary };
337 }
338
339 const authorId = await resolveFixAgentAuthorId(pr.ownerId);
340 if (!authorId) {
341 finalSummary = "no viable author_id for comment";
342 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
343 return { ok: false, summary: finalSummary };
344 }
345
346 const body = renderFixAgentComment({
347 passed: result.passed,
348 totalTests: result.totalTests,
349 failedBefore: result.failedBefore,
350 failedAfter: result.failedAfter,
351 repairs: result.repairs,
352 unfixable: result.unfixable,
353 });
354
355 try {
356 await db.insert(prComments).values({
357 pullRequestId: pr.prId,
358 authorId,
359 body,
360 isAiReview: true,
361 });
362 } catch (err) {
363 finalSummary = `comment insert failed: ${(err as Error).message}`;
364 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
365 return { ok: false, summary: finalSummary };
366 }
367
368 finalSummary = buildFixAgentSummary({
369 offline: false,
370 passed: result.passed,
371 failedBefore: result.failedBefore,
372 failedAfter: result.failedAfter,
373 repairs: result.repairs.length,
374 });
375 await ctx.appendLog(`[fix-agent] ${finalSummary}`);
376 return { ok: true, summary: finalSummary };
377 });
378
379 return { ok: true, summary: finalSummary, runId: run.id };
380}
381
382export const __internal = {
383 FIX_AGENT_COST_CENTS,
384 FIX_AGENT_MAX_REPAIRS_IN_COMMENT,
385 resolvePrContext,
386 resolveFixAgentAuthorId,
387};