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.
| ddb25a6 | 1 | /** |
| 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 | ||
| 28 | import { and, eq, sql } from "drizzle-orm"; | |
| 29 | import { db } from "../../db"; | |
| 30 | import { | |
| 31 | activityFeed, | |
| 32 | pullRequests, | |
| 33 | repositories, | |
| 34 | users, | |
| 35 | } from "../../db/schema"; | |
| 36 | import { healSuite } from "../gatetest-client"; | |
| 37 | import { | |
| 38 | executeAgentRun, | |
| 39 | startAgentRun, | |
| 40 | type AgentExecutorContext, | |
| 41 | } from "../agent-runtime"; | |
| 42 | ||
| 43 | // --------------------------------------------------------------------------- | |
| 44 | // Types | |
| 45 | // --------------------------------------------------------------------------- | |
| 46 | ||
| 47 | export interface RunHealBotArgs { | |
| 48 | repositoryId: string; | |
| 49 | triggerBy?: string | null; | |
| 50 | } | |
| 51 | ||
| 52 | export interface RunHealBotResult { | |
| 53 | ok: boolean; | |
| 54 | summary: string; | |
| 55 | runId: string | null; | |
| 56 | } | |
| 57 | ||
| 58 | export 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). */ | |
| 70 | const HEAL_BOT_COST_CENTS = 5; | |
| 71 | ||
| 72 | /** Cap on the number of repos a single scheduled fan-out will touch. */ | |
| 73 | const MAX_REPOS_PER_RUN = 50; | |
| 74 | ||
| 75 | /** Bot app slug — matches the identity K2 will ensureAgentApp for. */ | |
| 76 | export const HEAL_BOT_SLUG = "agent-heal-bot"; | |
| 77 | ||
| 78 | /** Bot username used in PR bodies so humans know who opened the PR. */ | |
| 79 | export const HEAL_BOT_BOT_USERNAME = "agent-heal-bot[bot]"; | |
| 80 | ||
| 81 | // --------------------------------------------------------------------------- | |
| 82 | // Internal DB helpers — defensive, never throw. | |
| 83 | // --------------------------------------------------------------------------- | |
| 84 | ||
| 85 | interface RepoIdentity { | |
| 86 | id: string; | |
| 87 | name: string; | |
| 88 | ownerUsername: string; | |
| 89 | ownerId: string; | |
| 90 | defaultBranch: string; | |
| 91 | } | |
| 92 | ||
| 93 | async 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 | */ | |
| 131 | async 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 | */ | |
| 162 | async 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 | */ | |
| 230 | export 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. */ | |
| 265 | export 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. */ | |
| 270 | export 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 | */ | |
| 304 | export 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 | */ | |
| 478 | export 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 | ||
| 523 | export const __internal = { | |
| 524 | HEAL_BOT_COST_CENTS, | |
| 525 | MAX_REPOS_PER_RUN, | |
| 526 | listEligibleRepositoryIds, | |
| 527 | resolveRepoIdentity, | |
| 528 | resolveHealBotAuthorId, | |
| 529 | }; |