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

heal-bot.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.

heal-bot.tsBlame529 lines · 1 contributor
ddb25a6Claude1/**
2 * Block K12 — Background test-suite heal-bot.
3 *
4 * Runs on a schedule (nightly by default). For each eligible repo we call
5 * Gatetest's `healSuite` primitive, which returns counts of flaky / dead /
6 * coverage-gap findings plus (optionally) a draft branch that Gatetest has
7 * already pushed into our repo with the repairs applied. If that branch is
8 * present we open a plain PR for a human to review — we NEVER auto-merge.
9 *
10 * Design rules (mirrors dep-updater.ts + ai-incident.ts):
11 * - Never throws. Every DB / network error returns `{ ok: false, summary }`
12 * and we log the detail to console so the `agent_runs` row carries
13 * enough detail without leaking a stack to the caller.
14 * - No new tables. We consume K8's optional `repo_agent_settings` table if
15 * present (for per-repo opt-out); otherwise fall back to "every
16 * non-archived repo".
17 * - Capped: at most 50 repos per scheduled fan-out so a very large fleet
18 * doesn't DOS Gatetest (and their retry budget).
19 * - Cost: $0.05 flat per run (5 cents) to cover Gatetest compute; we don't
20 * round-trip Anthropic here so token counts stay at zero.
21 *
22 * Typical usage in src/index.ts (scheduler not owned by K12):
23 *
24 * import { runHealBotForAll } from "./lib/agents";
25 * setInterval(() => { void runHealBotForAll(); }, 24 * 60 * 60 * 1000);
26 */
27
28import { and, eq, sql } from "drizzle-orm";
29import { db } from "../../db";
30import {
31 activityFeed,
32 pullRequests,
33 repositories,
34 users,
35} from "../../db/schema";
36import { healSuite } from "../gatetest-client";
37import {
38 executeAgentRun,
39 startAgentRun,
40 type AgentExecutorContext,
41} from "../agent-runtime";
42
43// ---------------------------------------------------------------------------
44// Types
45// ---------------------------------------------------------------------------
46
47export interface RunHealBotArgs {
48 repositoryId: string;
49 triggerBy?: string | null;
50}
51
52export interface RunHealBotResult {
53 ok: boolean;
54 summary: string;
55 runId: string | null;
56}
57
58export interface RunHealBotForAllResult {
59 started: number;
60 succeeded: number;
61 failed: number;
62 skipped: number;
63}
64
65// ---------------------------------------------------------------------------
66// Constants
67// ---------------------------------------------------------------------------
68
69/** Per-run Gatetest compute cost in cents (approximate). */
70const HEAL_BOT_COST_CENTS = 5;
71
72/** Cap on the number of repos a single scheduled fan-out will touch. */
73const MAX_REPOS_PER_RUN = 50;
74
75/** Bot app slug — matches the identity K2 will ensureAgentApp for. */
76export const HEAL_BOT_SLUG = "agent-heal-bot";
77
78/** Bot username used in PR bodies so humans know who opened the PR. */
79export const HEAL_BOT_BOT_USERNAME = "agent-heal-bot[bot]";
80
81// ---------------------------------------------------------------------------
82// Internal DB helpers — defensive, never throw.
83// ---------------------------------------------------------------------------
84
85interface RepoIdentity {
86 id: string;
87 name: string;
88 ownerUsername: string;
89 ownerId: string;
90 defaultBranch: string;
91}
92
93async function resolveRepoIdentity(
94 repositoryId: string
95): Promise<RepoIdentity | null> {
96 try {
97 const rows = await db
98 .select({
99 id: repositories.id,
100 name: repositories.name,
101 ownerId: repositories.ownerId,
102 defaultBranch: repositories.defaultBranch,
103 ownerUsername: users.username,
104 })
105 .from(repositories)
106 .innerJoin(users, eq(users.id, repositories.ownerId))
107 .where(eq(repositories.id, repositoryId))
108 .limit(1);
109 const row = rows[0];
110 if (!row) return null;
111 return {
112 id: row.id,
113 name: row.name,
114 ownerId: row.ownerId,
115 defaultBranch: row.defaultBranch || "main",
116 ownerUsername: row.ownerUsername,
117 };
118 } catch (err) {
119 console.error("[heal-bot] resolveRepoIdentity:", err);
120 return null;
121 }
122}
123
124/**
125 * Pick the author_id for the PR row. `pull_requests.authorId` is NOT NULL so
126 * we must resolve to a real user. Preference order:
127 * 1. a "bot" user named HEAL_BOT_BOT_USERNAME (created by K2 when the app
128 * is installed),
129 * 2. the repo owner (guaranteed to exist).
130 */
131async function resolveHealBotAuthorId(
132 ownerId: string
133): Promise<string | null> {
134 try {
135 const [bot] = await db
136 .select({ id: users.id })
137 .from(users)
138 .where(eq(users.username, HEAL_BOT_BOT_USERNAME))
139 .limit(1);
140 if (bot?.id) return bot.id;
141 } catch (err) {
142 console.error("[heal-bot] resolveHealBotAuthorId bot lookup:", err);
143 }
144 return ownerId;
145}
146
147/**
148 * Fetch up to MAX_REPOS_PER_RUN candidate repo IDs, filtered for the heal-bot
149 * fan-out.
150 *
151 * Two code paths:
152 * 1. If the `repo_agent_settings` table from Block K8 exists, we LEFT JOIN
153 * it and drop rows where `paused = true` or where `enabled_kinds` is a
154 * JSON/text blob that explicitly excludes `heal_bot`. Rows with no
155 * settings row default to enabled. We detect the table's presence with
156 * `to_regclass` so a missing table doesn't blow up the query.
157 * 2. If the table is missing (to_regclass returns NULL) we fall back to a
158 * plain filter over non-archived `repositories`.
159 *
160 * Either way we return repository IDs as strings.
161 */
162async function listEligibleRepositoryIds(): Promise<string[]> {
163 // 1. Does repo_agent_settings exist?
164 let tableExists = false;
165 try {
166 const probe = (await db.execute(sql`
167 SELECT to_regclass('public.repo_agent_settings') AS reg
168 `)) as unknown as Array<Record<string, unknown>>;
169 const first = Array.isArray(probe) ? probe[0] : undefined;
170 tableExists = !!(first && first.reg);
171 } catch (err) {
172 console.error("[heal-bot] to_regclass probe failed:", err);
173 tableExists = false;
174 }
175
176 try {
177 if (tableExists) {
178 // We LEFT JOIN so repos without a settings row still pass. The
179 // `enabled_kinds` column is expected to be a JSON array of agent kind
180 // strings; if present, it must contain 'heal_bot'. If it's NULL we
181 // treat that as "all enabled".
182 const rows = (await db.execute(sql`
183 SELECT r.id::text AS id
184 FROM repositories r
185 LEFT JOIN repo_agent_settings s
186 ON s.repository_id = r.id
187 WHERE r.is_archived = false
188 AND COALESCE(s.paused, false) = false
189 AND (
190 s.enabled_kinds IS NULL
191 OR s.enabled_kinds::text LIKE '%heal_bot%'
192 )
193 ORDER BY r.pushed_at DESC NULLS LAST, r.created_at DESC
194 LIMIT ${MAX_REPOS_PER_RUN}
195 `)) as unknown as Array<Record<string, unknown>>;
196 if (Array.isArray(rows)) {
197 return rows.map((r) => String(r.id)).filter(Boolean);
198 }
199 return [];
200 }
201 } catch (err) {
202 // Any failure on the "advanced" path falls through to the simple one.
203 console.error(
204 "[heal-bot] listEligibleRepositoryIds (with settings) failed:",
205 err
206 );
207 }
208
209 try {
210 const rows = await db
211 .select({ id: repositories.id })
212 .from(repositories)
213 .where(eq(repositories.isArchived, false))
214 .limit(MAX_REPOS_PER_RUN);
215 return rows.map((r) => r.id).filter(Boolean);
216 } catch (err) {
217 console.error("[heal-bot] listEligibleRepositoryIds (fallback) failed:", err);
218 return [];
219 }
220}
221
222// ---------------------------------------------------------------------------
223// Pure helpers (unit-testable)
224// ---------------------------------------------------------------------------
225
226/**
227 * Render the PR body for a heal-bot PR. Deterministic + no I/O so we can
228 * exercise it in isolation.
229 */
230export function renderHealBotPrBody(findings: {
231 flakyFound: number;
232 deadFound: number;
233 coverageGapsFound: number;
234 headBranch: string;
235 baseBranch: string;
236}): string {
237 const {
238 flakyFound,
239 deadFound,
240 coverageGapsFound,
241 headBranch,
242 baseBranch,
243 } = findings;
244 const total = flakyFound + deadFound + coverageGapsFound;
245 const lines: string[] = [];
246 lines.push(`Automated test-suite heal by GlueCron's heal-bot.`);
247 lines.push("");
248 lines.push(`**${total} repair${total === 1 ? "" : "s"}** queued on \`${headBranch}\` → \`${baseBranch}\`.`);
249 lines.push("");
250 lines.push("| Finding | Count |");
251 lines.push("| --- | ---: |");
252 lines.push(`| Flaky tests stabilised | ${flakyFound} |`);
253 lines.push(`| Dead / obsolete tests pruned | ${deadFound} |`);
254 lines.push(`| Coverage gaps newly covered | ${coverageGapsFound} |`);
255 lines.push("");
256 lines.push(
257 "Review carefully — the heal-bot never auto-merges. If a repair is wrong, close this PR and the bot will not retry the same branch."
258 );
259 lines.push("");
260 lines.push(`_Generated by ${HEAL_BOT_BOT_USERNAME}._`);
261 return lines.join("\n");
262}
263
264/** Short PR title matching the task spec. */
265export function renderHealBotPrTitle(repairs: number): string {
266 return `chore(tests): heal-bot — ${repairs} repair${repairs === 1 ? "" : "s"}`;
267}
268
269/** Short summary line stored in agent_runs.summary. */
270export function buildHealBotSummary(params: {
271 flakyFound: number;
272 deadFound: number;
273 coverageGapsFound: number;
274 prNumber: number | null;
275 branchProduced: boolean;
276}): string {
277 const { flakyFound, deadFound, coverageGapsFound, prNumber, branchProduced } =
278 params;
279 const total = flakyFound + deadFound + coverageGapsFound;
280 if (total === 0) return "suite healthy";
281 if (!branchProduced) {
282 return `${total} findings, no branch produced — Gatetest may need reconfiguration`;
283 }
284 const prLabel = prNumber !== null ? `#${prNumber}` : "(unknown PR)";
285 return `opened ${prLabel} (${flakyFound} flaky, ${deadFound} dead, ${coverageGapsFound} coverage)`;
286}
287
288// ---------------------------------------------------------------------------
289// Entry points
290// ---------------------------------------------------------------------------
291
292/**
293 * Run the heal-bot for a single repository. Never throws.
294 *
295 * Lifecycle:
296 * 1. Open an `agent_runs` row (kind = "heal_bot").
297 * 2. Inside the runtime wrapper, resolve the repo, call Gatetest, and:
298 * - if offline → record "gatetest offline; skipped"
299 * - if zero findings → record "suite healthy"
300 * - if findings but no draft branch → record the degraded summary
301 * - if draft branch exists → create a PR row + activity-feed entry
302 * 3. Cost: 5 cents flat for the Gatetest call.
303 */
304export async function runHealBot(
305 args: RunHealBotArgs
306): Promise<RunHealBotResult> {
307 if (!args || typeof args.repositoryId !== "string" || !args.repositoryId) {
308 return {
309 ok: false,
310 summary: "invalid args: missing repositoryId",
311 runId: null,
312 };
313 }
314
315 const trigger: "manual" | "scheduled" = args.triggerBy ? "manual" : "scheduled";
316
317 const run = await startAgentRun({
318 repositoryId: args.repositoryId,
319 kind: "heal_bot",
320 trigger,
321 triggerRef: "nightly",
322 });
323 if (!run) {
324 return {
325 ok: false,
326 summary: "could not open agent_runs row",
327 runId: null,
328 };
329 }
330
331 let finalSummary = "suite healthy";
332
333 await executeAgentRun(run.id, async (ctx: AgentExecutorContext) => {
334 await ctx.appendLog(
335 `[heal-bot] starting run for repo ${args.repositoryId} (trigger=${trigger})`
336 );
337
338 const identity = await resolveRepoIdentity(args.repositoryId);
339 if (!identity) {
340 await ctx.appendLog("[heal-bot] repository lookup failed; aborting");
341 finalSummary = "repo not found";
342 return { ok: false, summary: finalSummary };
343 }
344
345 const repoSlug = `${identity.ownerUsername}/${identity.name}`;
346 await ctx.appendLog(`[heal-bot] calling gatetest.healSuite for ${repoSlug}`);
347
348 const result = await healSuite({ repo: repoSlug });
349 await ctx.recordCost(0, 0, HEAL_BOT_COST_CENTS);
350
351 if (result.offline) {
352 await ctx.appendLog("[heal-bot] Gatetest offline; skipping.");
353 finalSummary = "gatetest offline; skipped";
354 return { ok: true, summary: finalSummary };
355 }
356
357 const total =
358 (result.flakyFound || 0) +
359 (result.deadFound || 0) +
360 (result.coverageGapsFound || 0);
361
362 await ctx.appendLog(
363 `[heal-bot] gatetest returned: flaky=${result.flakyFound}, dead=${result.deadFound}, coverage=${result.coverageGapsFound}, branch=${result.prDraftBranch ?? "(none)"}`
364 );
365
366 if (total === 0) {
367 finalSummary = buildHealBotSummary({
368 flakyFound: 0,
369 deadFound: 0,
370 coverageGapsFound: 0,
371 prNumber: null,
372 branchProduced: false,
373 });
374 return { ok: true, summary: finalSummary };
375 }
376
377 // Findings present but Gatetest didn't push a branch — record and exit.
378 if (!result.prDraftBranch) {
379 finalSummary = buildHealBotSummary({
380 flakyFound: result.flakyFound,
381 deadFound: result.deadFound,
382 coverageGapsFound: result.coverageGapsFound,
383 prNumber: null,
384 branchProduced: false,
385 });
386 await ctx.appendLog(`[heal-bot] ${finalSummary}`);
387 return { ok: true, summary: finalSummary };
388 }
389
390 // Open a PR. The branch was pushed by Gatetest — we only write the DB row.
391 const authorId = await resolveHealBotAuthorId(identity.ownerId);
392 if (!authorId) {
393 await ctx.appendLog(
394 "[heal-bot] no viable author_id for PR; aborting PR insert"
395 );
396 finalSummary = "no author_id available; PR not opened";
397 return { ok: false, summary: finalSummary };
398 }
399
400 const title = renderHealBotPrTitle(total);
401 const body = renderHealBotPrBody({
402 flakyFound: result.flakyFound,
403 deadFound: result.deadFound,
404 coverageGapsFound: result.coverageGapsFound,
405 headBranch: result.prDraftBranch,
406 baseBranch: identity.defaultBranch,
407 });
408
409 let prNumber: number | null = null;
410 let prId: string | null = null;
411 try {
412 const [pr] = await db
413 .insert(pullRequests)
414 .values({
415 repositoryId: identity.id,
416 authorId,
417 title,
418 body,
419 baseBranch: identity.defaultBranch,
420 headBranch: result.prDraftBranch,
421 isDraft: false,
422 })
423 .returning();
424 prNumber = pr?.number ?? null;
425 prId = pr?.id ?? null;
426 } catch (err) {
427 await ctx.appendLog(
428 `[heal-bot] PR insert failed: ${(err as Error).message}`
429 );
430 finalSummary = `PR insert failed: ${(err as Error).message}`;
431 return { ok: false, summary: finalSummary };
432 }
433
434 // Activity feed — best-effort; failure here doesn't block success.
435 if (prId) {
436 try {
437 await db.insert(activityFeed).values({
438 repositoryId: identity.id,
439 userId: authorId,
440 action: "pr_open",
441 targetType: "pr",
442 targetId: prId,
443 metadata: JSON.stringify({
444 agent: "heal_bot",
445 flakyFound: result.flakyFound,
446 deadFound: result.deadFound,
447 coverageGapsFound: result.coverageGapsFound,
448 }),
449 });
450 } catch (err) {
451 console.error("[heal-bot] activity_feed insert failed:", err);
452 }
453 }
454
455 finalSummary = buildHealBotSummary({
456 flakyFound: result.flakyFound,
457 deadFound: result.deadFound,
458 coverageGapsFound: result.coverageGapsFound,
459 prNumber,
460 branchProduced: true,
461 });
462 await ctx.appendLog(`[heal-bot] ${finalSummary}`);
463 return { ok: true, summary: finalSummary };
464 });
465
466 return { ok: true, summary: finalSummary, runId: run.id };
467}
468
469/**
470 * Scheduled fan-out: iterate over every eligible repository and invoke
471 * `runHealBot` for each. Bounded to MAX_REPOS_PER_RUN per invocation so
472 * a large fleet doesn't wedge Gatetest.
473 *
474 * Runs sequentially (not in parallel) — Gatetest's per-repo compute can be
475 * expensive and we don't want to flood their API. Never throws. Returns
476 * aggregate counts so a caller can log or alert.
477 */
478export async function runHealBotForAll(): Promise<RunHealBotForAllResult> {
479 const agg: RunHealBotForAllResult = {
480 started: 0,
481 succeeded: 0,
482 failed: 0,
483 skipped: 0,
484 };
485
486 let repoIds: string[] = [];
487 try {
488 repoIds = await listEligibleRepositoryIds();
489 } catch (err) {
490 console.error("[heal-bot] runHealBotForAll: listing failed:", err);
491 return agg;
492 }
493
494 if (repoIds.length === 0) {
495 console.log("[heal-bot] runHealBotForAll: no eligible repositories");
496 return agg;
497 }
498
499 console.log(
500 `[heal-bot] runHealBotForAll: scheduling ${repoIds.length} repo(s)`
501 );
502
503 for (const repositoryId of repoIds) {
504 agg.started++;
505 try {
506 const result = await runHealBot({ repositoryId });
507 if (result.ok) agg.succeeded++;
508 else agg.failed++;
509 console.log(
510 `[heal-bot] ${repositoryId}: ${result.ok ? "ok" : "fail"} — ${result.summary}`
511 );
512 } catch (err) {
513 // runHealBot never throws, but belt + braces: if it ever does, we keep
514 // iterating over the remaining repos.
515 agg.failed++;
516 console.error(`[heal-bot] ${repositoryId}: unexpected throw:`, err);
517 }
518 }
519
520 return agg;
521}
522
523export const __internal = {
524 HEAL_BOT_COST_CENTS,
525 MAX_REPOS_PER_RUN,
526 listEligibleRepositoryIds,
527 resolveRepoIdentity,
528 resolveHealBotAuthorId,
529};