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

autopilot.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.

autopilot.tsBlame1303 lines · 3 contributors
2b821b7Claude1/**
2 * Autopilot — self-sufficiency loop.
3 *
4 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
5 * weekly digests, advisory rescans) on an interval so the host runs itself
6 * without an external cron. All sub-tasks are injected so tests can stub them
7 * without touching the DB; the default task set wires real helpers from the
8 * locked libs. Nothing here throws — every sub-task and the outer tick are
9 * try/caught so a single failure never blocks the others.
10 */
11
2b9055eClaude12import { and, eq, gte, sql } from "drizzle-orm";
2b821b7Claude13import { db } from "../db";
2b9055eClaude14import {
15 mergeQueueEntries,
16 prComments,
17 pullRequests,
18 repoDependencies,
19 repositories,
20 users,
21} from "../db/schema";
2b821b7Claude22import { syncAllDue } from "./mirrors";
23import { peekHead } from "./merge-queue";
24import { sendDigestsToAll } from "./email-digest";
25import { scanRepositoryForAlerts } from "./advisories";
a76d984Claude26import { releaseExpiredWaitTimers } from "./environments";
665c8bfClaude27import { runScheduledWorkflowsTick } from "./scheduled-workflows";
2b9055eClaude28import {
29 evaluateAutoMerge,
30 recordAutoMergeAttempt,
31 type AutoMergeContext,
32 type AutoMergeDecision,
33} from "./auto-merge";
34import { matchProtection } from "./branch-protection";
35import { performMerge, type PerformMergeResult } from "./pr-merge";
36import { audit } from "./notify";
37import { runAiBuildTaskOnce } from "./ai-build-tasks";
46d6165Claude38import {
39 sendSleepModeDigestForUser,
40 SLEEP_MODE_USER_CAP_PER_TICK,
41 SLEEP_MODE_COOLDOWN_HOURS,
42} from "./sleep-mode";
534f04aClaude43import {
44 runStalePrSweepOnce,
45 runStaleIssueSweepOnce,
46} from "./stale-sweep";
47import { computePrRiskForPullRequest } from "./pr-risk";
48import { prRiskScores } from "../db/schema";
c63b860Claude49import { purgeScheduledAccounts } from "./account-deletion";
cd4f63bTest User50import { purgeExpiredPlaygroundAccounts } from "./playground";
b1be050CC LABS App51import {
52 runSyntheticChecks,
53 persistChecks,
54 latestStatusByCheck,
55 type SyntheticCheckResult,
56} from "./synthetic-monitor";
e9aa4d8Claude57import { aiProactiveMonitorTick } from "./ai-proactive-monitor";
9336f45Claude58import { runCiHealerTick } from "./ai-ci-healer";
a686079Claude59import {
60 runDailyStandupTaskOnce,
61 runWeeklyStandupTaskOnce,
62} from "./ai-standup";
950ef90Claude63import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
45f3b73Claude64import { runMigrationWatcherTaskOnce } from "./migration-assistant";
2b821b7Claude65
66export interface AutopilotTaskResult {
67 name: string;
68 ok: boolean;
69 durationMs: number;
70 error?: string;
71}
72
73export interface AutopilotTickResult {
74 startedAt: string;
75 finishedAt: string;
76 tasks: AutopilotTaskResult[];
77}
78
79export interface AutopilotTask {
80 name: string;
81 run: () => Promise<void>;
82}
83
84export interface StartAutopilotOpts {
85 intervalMs?: number;
86 now?: () => number;
87 tasks?: AutopilotTask[];
88}
89
90export interface RunTickOpts {
91 tasks?: AutopilotTask[];
92 now?: () => number;
93}
94
95const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
96const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude97/** K3 — recency window for auto-merge candidate selection. */
98const AUTO_MERGE_LOOKBACK_HOURS = 24;
99/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
100const AUTO_MERGE_MAX_PER_TICK = 50;
101/** K3 — stable marker for the auto-merge audit comment. */
102const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
534f04aClaude103/** M3 — hard cap on PRs scored per tick (runaway protection). */
104const PR_RISK_RESCORE_MAX_PER_TICK = 20;
105/** M3 — recency window for the pr-risk-rescore sweep. */
106const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
e9aa4d8Claude107/** Proactive monitor cadence — Claude scans platform telemetry hourly. */
108const PROACTIVE_MONITOR_INTERVAL_MS = 60 * 60 * 1000;
109let _lastProactiveMonitorAt = 0;
950ef90Claude110/** Spec-to-PR cadence — autopilot scans `.gluecron/specs/*.md` every 2 minutes. */
111const SPEC_TO_PR_INTERVAL_MS = 2 * 60 * 1000;
112let _lastSpecToPrAt = 0;
45f3b73Claude113/**
114 * Migration watcher cadence. The lookup is cheap (registry calls per
115 * declared dep) but we still throttle to every 6 hours so we don't hammer
116 * npm and don't propose more than ~one PR per repo per day.
117 */
118const MIGRATION_WATCHER_INTERVAL_MS = 6 * 60 * 60 * 1000;
119let _lastMigrationWatcherAt = 0;
2b821b7Claude120
121/**
122 * Default task set. Each task is a thin wrapper around an existing locked
123 * helper — no gate/merge logic is duplicated here.
124 */
125export function defaultTasks(): AutopilotTask[] {
126 return [
127 {
128 name: "mirror-sync",
129 run: async () => {
130 await syncAllDue();
131 },
132 },
133 {
134 name: "merge-queue",
135 run: async () => {
136 await processMergeQueues();
137 },
138 },
139 {
140 name: "weekly-digest",
141 run: async () => {
142 await sendDigestsToAll();
143 },
144 },
145 {
146 name: "advisory-rescan",
147 run: async () => {
148 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
149 },
150 },
a76d984Claude151 {
152 name: "wait-timer-release",
153 run: async () => {
154 await releaseExpiredWaitTimers();
155 },
156 },
665c8bfClaude157 {
158 name: "scheduled-workflows",
159 run: async () => {
160 await runScheduledWorkflowsTick();
161 },
162 },
2b9055eClaude163 {
164 name: "auto-merge-sweep",
165 run: async () => {
166 await runAutoMergeSweep();
167 },
168 },
169 {
170 name: "ai-build-from-issues",
171 run: async () => {
172 const summary = await runAiBuildTaskOnce();
173 console.log(
174 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
175 );
176 },
177 },
46d6165Claude178 {
179 name: "sleep-mode-digest",
180 run: async () => {
181 const summary = await runSleepModeDigestTaskOnce();
182 console.log(
183 `[autopilot] sleep-mode-digest: sent=${summary.sent} skipped=${summary.skipped}`
184 );
185 },
186 },
534f04aClaude187 {
188 name: "stale-pr-sweep",
189 run: async () => {
190 // Two-stage gate: poke at 7d stale, close at 14d after poke
191 // (when the repo opts in via `auto_close_stale_prs`).
192 // Wrapped in try/catch so a finder crash never wedges the tick.
193 try {
194 const summary = await runStalePrSweepOnce();
195 console.log(
196 `[autopilot] stale-pr-sweep: poked=${summary.poked} closed=${summary.closed}`
197 );
198 } catch (err) {
199 console.error("[autopilot] stale-pr-sweep: threw:", err);
200 }
201 },
202 },
203 {
204 name: "stale-issue-sweep",
205 run: async () => {
206 // Mirror of stale-pr-sweep with the issue thresholds (30d/60d).
207 try {
208 const summary = await runStaleIssueSweepOnce();
209 console.log(
210 `[autopilot] stale-issue-sweep: poked=${summary.poked} closed=${summary.closed}`
211 );
212 } catch (err) {
213 console.error("[autopilot] stale-issue-sweep: threw:", err);
214 }
215 },
216 },
217 {
218 name: "pr-risk-rescore",
219 run: async () => {
220 const summary = await runPrRiskRescoreTaskOnce();
221 console.log(
222 `[autopilot] pr-risk-rescore: scored=${summary.scored} skipped=${summary.skipped}`
223 );
224 },
225 },
c63b860Claude226
227 {
228 // Block P5 — Hard-delete users whose 30-day grace period expired.
229 name: "account-purge",
230 run: async () => {
231 try {
232 const summary = await purgeScheduledAccounts({ cap: 50 });
233 console.log(
234 `[autopilot] account-purge: purged=${summary.purged} errors=${summary.errors}`
235 );
236 } catch (err) {
237 console.error("[autopilot] account-purge: threw:", err);
238 }
239 },
240 },
cd4f63bTest User241 {
242 // Block Q3 — Hard-delete anonymous playground accounts past their
243 // 24h TTL. CASCADE handles repos, sessions, issues. Per-user
244 // try/catch in the lib so one FK violation can't stall the queue.
245 name: "playground-purge",
246 run: async () => {
247 try {
248 const summary = await purgeExpiredPlaygroundAccounts({ cap: 50 });
249 console.log(
250 `[autopilot] playground-purge: purged=${summary.purged} errors=${summary.errors}`
251 );
252 } catch (err) {
253 console.error("[autopilot] playground-purge: threw:", err);
254 }
255 },
256 },
e9aa4d8Claude257 {
258 // Proactive AI monitor — hourly. Claude reads 24h of audit_log +
259 // platformDeploys + workflowRuns and opens issues on anomalies
260 // (degraded deploy times, recurring failures, suspicious audit
261 // patterns). Skips when ANTHROPIC_API_KEY is unset (the lib
262 // itself short-circuits, but the cadence gate avoids redundant
263 // work on every 5-min tick too).
264 name: "ai-proactive-monitor",
265 run: async () => {
266 if (!process.env.ANTHROPIC_API_KEY) return;
267 const now = Date.now();
268 if (now - _lastProactiveMonitorAt < PROACTIVE_MONITOR_INTERVAL_MS) {
269 return;
270 }
271 _lastProactiveMonitorAt = now;
272 try {
273 const summary = await aiProactiveMonitorTick();
274 console.log(
275 `[autopilot] ai-proactive-monitor: opened=${summary.opened} considered=${summary.considered} dedup=${summary.skippedDedupe}`
276 );
277 } catch (err) {
278 console.error("[autopilot] ai-proactive-monitor: threw:", err);
279 }
280 },
281 },
9336f45Claude282 {
283 // AI CI Healer — autonomous CI failure → root-cause → patch PR loop.
284 // Polls every tick (5 min) for failed workflow_runs that finished
285 // at least HEAL_MIN_AGE_MS ago and haven't been processed yet.
950ef90Claude286 // Skips when ANTHROPIC_API_KEY is unset.
9336f45Claude287 name: "ci-healer",
288 run: async () => {
289 if (!process.env.ANTHROPIC_API_KEY) return;
290 try {
291 const summary = await runCiHealerTick();
292 console.log(
293 `[autopilot] ci-healer: considered=${summary.considered} healed=${summary.healed} gaveUp=${summary.gaveUp} skipped=${summary.skipped}`
294 );
295 } catch (err) {
296 console.error("[autopilot] ci-healer: threw:", err);
297 }
298 },
299 },
950ef90Claude300 {
301 // Spec-to-PR autopilot — picks up `.gluecron/specs/*.md` files whose
302 // front-matter status is `ready`, asks Claude to implement the spec,
303 // opens a draft PR tagged `ai:spec-implementation`. Cadence-gated
304 // to every 2 minutes.
305 name: "spec-to-pr",
306 run: async () => {
307 if (!process.env.ANTHROPIC_API_KEY) return;
308 const now = Date.now();
309 if (now - _lastSpecToPrAt < SPEC_TO_PR_INTERVAL_MS) return;
310 _lastSpecToPrAt = now;
311 try {
312 const summary = await runSpecToPrTaskOnce();
313 console.log(
314 `[autopilot] spec-to-pr: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
315 );
316 } catch (err) {
317 console.error("[autopilot] spec-to-pr: threw:", err);
318 }
319 },
320 },
b1be050CC LABS App321 {
322 // BLOCK S4 — Synthetic monitor.
323 //
324 // Runs the URL-only smoke suite (see src/lib/synthetic-monitor.ts),
325 // records the outcome into `synthetic_checks`, and on a
326 // green->red transition fires a webhook to MONITOR_ALERT_WEBHOOK_URL
327 // (when configured) so the owner finds out instantly that the live
328 // site is broken. Wrapped in try/catch — the monitor must never
329 // wedge the tick.
330 name: "synthetic-monitor",
331 run: async () => {
332 try {
333 const summary = await runSyntheticMonitorTaskOnce();
334 console.log(
335 `[autopilot] synthetic-monitor: green=${summary.green} red=${summary.red} transitions=${summary.transitions}`
336 );
337 } catch (err) {
338 console.error("[autopilot] synthetic-monitor: threw:", err);
339 }
340 },
341 },
45f3b73Claude342 {
343 // Migration watcher — scans each repo's package.json for deps that
344 // are at least one major version behind and asks Claude to draft an
345 // upgrade PR. Cadence-gated to every 6 hours; the lib itself
346 // enforces a per-repo + per-{dep,version} 7-day dedupe so we never
347 // re-propose the same migration twice in a single window. Skips
348 // entirely when ANTHROPIC_API_KEY is unset OR the
349 // MIGRATION_WATCHER_ENABLED env flag is off.
350 name: "migration-watcher",
351 run: async () => {
352 if (!process.env.ANTHROPIC_API_KEY) return;
353 const now = Date.now();
354 if (now - _lastMigrationWatcherAt < MIGRATION_WATCHER_INTERVAL_MS) {
355 return;
356 }
357 _lastMigrationWatcherAt = now;
358 try {
359 const summary = await runMigrationWatcherTaskOnce();
360 console.log(
361 `[autopilot] migration-watcher: considered=${summary.considered} proposed=${summary.proposed} throttled=${summary.skippedThrottle} disabled=${summary.skippedNotEnabled} errors=${summary.errors}`
362 );
363 } catch (err) {
364 console.error("[autopilot] migration-watcher: threw:", err);
365 }
366 },
367 },
a686079Claude368 {
369 // AI Standup — daily Claude-generated team brief.
370 // Fires at the user's configured UTC hour (default 09:00). Skips
371 // entirely when ANTHROPIC_API_KEY is unset (the lib still has a
372 // deterministic fallback, but we keep this task quiet unless the
373 // operator has wired AI). Per-user dedupe via `hasStandupForToday`.
374 name: "daily-standup",
375 run: async () => {
376 if (!process.env.ANTHROPIC_API_KEY) return;
377 try {
378 const summary = await runDailyStandupTaskOnce();
379 console.log(
380 `[autopilot] daily-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
381 );
382 } catch (err) {
383 console.error("[autopilot] daily-standup: threw:", err);
384 }
385 },
386 },
387 {
388 // AI Standup — weekly Claude-generated team brief. Mondays only.
389 name: "weekly-standup",
390 run: async () => {
391 if (!process.env.ANTHROPIC_API_KEY) return;
392 try {
393 const summary = await runWeeklyStandupTaskOnce();
394 console.log(
395 `[autopilot] weekly-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
396 );
397 } catch (err) {
398 console.error("[autopilot] weekly-standup: threw:", err);
399 }
400 },
401 },
2b821b7Claude402 ];
403}
404
b1be050CC LABS App405// ---------------------------------------------------------------------------
406// BLOCK S4 — synthetic-monitor task
407// ---------------------------------------------------------------------------
408
409export interface SyntheticMonitorTaskDeps {
410 /** Override the suite runner (DI for tests). */
411 runChecks?: () => Promise<SyntheticCheckResult[]>;
412 /** Override the persistence step (DI for tests). */
413 persist?: (results: SyntheticCheckResult[]) => Promise<void>;
414 /** Override the previous-state loader (DI for tests). */
415 loadPrevious?: () => Promise<Record<string, SyntheticCheckResult>>;
416 /** Override the webhook poster (DI for tests). */
417 postAlert?: (url: string, payload: unknown) => Promise<void>;
418 /** Override the alert-webhook URL lookup (defaults to env). */
419 alertUrl?: () => string;
420}
421
422export interface SyntheticMonitorTaskSummary {
423 green: number;
424 red: number;
425 yellow: number;
426 transitions: number;
427}
428
429async function defaultPostAlert(
430 url: string,
431 payload: unknown
432): Promise<void> {
433 try {
434 await fetch(url, {
435 method: "POST",
436 headers: { "Content-Type": "application/json" },
437 body: JSON.stringify(payload),
438 });
439 } catch (err) {
440 console.error("[autopilot] synthetic-monitor: alert webhook failed:", err);
441 }
442}
443
444/**
445 * One iteration of the synthetic-monitor task. Runs the checks, persists
446 * them, compares against the prior state, and fires a webhook on each
447 * green->red transition (red->red repeats stay quiet so we don't spam
448 * the channel). Never throws.
449 */
450export async function runSyntheticMonitorTaskOnce(
451 deps: SyntheticMonitorTaskDeps = {}
452): Promise<SyntheticMonitorTaskSummary> {
453 const runChecks = deps.runChecks ?? (() => runSyntheticChecks());
454 const persist = deps.persist ?? persistChecks;
455 const loadPrevious =
456 deps.loadPrevious ??
457 (async () => {
458 const latest = await latestStatusByCheck();
459 // Strip the `checkedAt` from the result shape so the diff loop
460 // compares the canonical SyntheticCheckResult fields only.
461 const out: Record<string, SyntheticCheckResult> = {};
462 for (const [k, v] of Object.entries(latest)) {
463 const { checkedAt: _unused, ...rest } = v;
464 void _unused;
465 out[k] = rest;
466 }
467 return out;
468 });
469 const postAlert = deps.postAlert ?? defaultPostAlert;
470 const alertUrl =
471 deps.alertUrl ?? (() => process.env.MONITOR_ALERT_WEBHOOK_URL || "");
472
473 let previous: Record<string, SyntheticCheckResult> = {};
474 try {
475 previous = await loadPrevious();
476 } catch (err) {
477 console.error(
478 "[autopilot] synthetic-monitor: loadPrevious threw:",
479 err
480 );
481 previous = {};
482 }
483
484 const results = await runChecks();
485 await persist(results);
486
487 let green = 0;
488 let red = 0;
489 let yellow = 0;
490 let transitions = 0;
491 const url = alertUrl();
492
493 for (const r of results) {
494 if (r.status === "green") green += 1;
495 else if (r.status === "red") red += 1;
496 else yellow += 1;
497
498 const prior = previous[r.name];
499 // green->red transition: prior was green (or absent and current is red
500 // after a green is also a transition — but absent-before is treated as
501 // green to avoid spamming on a fresh DB). We only alert on the
502 // green->red edge so red->red doesn't re-fire.
503 const priorWasGreen = !prior || prior.status === "green";
504 if (priorWasGreen && r.status === "red") {
505 transitions += 1;
506 if (url) {
507 await postAlert(url, {
508 check: r.name,
509 status: r.status,
510 statusCode: r.statusCode ?? null,
511 durationMs: r.durationMs,
512 error: r.error ?? null,
513 checkedAt: new Date().toISOString(),
514 });
515 }
516 }
517 }
518
519 return { green, red, yellow, transitions };
520}
521
46d6165Claude522// ---------------------------------------------------------------------------
523// L1 — sleep-mode-digest
524// ---------------------------------------------------------------------------
525
526export interface SleepModeDigestCandidate {
527 userId: string;
528 digestHourUtc: number;
529 lastDigestSentAt: Date | null;
530}
531
532export interface SleepModeDigestTaskDeps {
533 /** Override the candidate finder. */
534 findCandidates?: (cap: number) => Promise<SleepModeDigestCandidate[]>;
535 /** Override the send-one-user helper (DI for tests). */
536 sendOne?: (userId: string) => Promise<{ ok: boolean; reason?: string }>;
537 /** Override the wall clock (DI for tests). */
538 now?: () => Date;
539 /** Override the per-tick cap. */
540 cap?: number;
541 /** Override the cooldown hours. */
542 cooldownHours?: number;
543}
544
545export interface SleepModeDigestTaskSummary {
546 sent: number;
547 skipped: number;
548}
549
550/**
551 * Default candidate-finder. Returns enabled users whose
552 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
553 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
554 * timezone-independent of any SQL `extract(hour ...)` behaviour.
555 */
556async function defaultFindSleepModeCandidates(
557 cap: number
558): Promise<SleepModeDigestCandidate[]> {
559 try {
560 const rows = await db
561 .select({
562 userId: users.id,
563 digestHourUtc: users.sleepModeDigestHourUtc,
564 lastDigestSentAt: users.lastDigestSentAt,
565 })
566 .from(users)
567 .where(eq(users.sleepModeEnabled, true))
568 .limit(cap);
569 return rows.map((r) => ({
570 userId: r.userId,
571 digestHourUtc: r.digestHourUtc,
572 lastDigestSentAt: r.lastDigestSentAt,
573 }));
574 } catch (err) {
575 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
576 return [];
577 }
578}
579
580/**
581 * One iteration of the sleep-mode-digest task. Never throws.
582 *
583 * Per-user filters (applied in JS so we can DI a clock):
584 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
585 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
586 * configured local UTC hour.
587 *
588 * Caps at `SLEEP_MODE_USER_CAP_PER_TICK` (100) users per tick.
589 */
590export async function runSleepModeDigestTaskOnce(
591 deps: SleepModeDigestTaskDeps = {}
592): Promise<SleepModeDigestTaskSummary> {
593 const findCandidates =
594 deps.findCandidates ?? defaultFindSleepModeCandidates;
595 const sendOne = deps.sendOne ?? sendSleepModeDigestForUser;
596 const now = deps.now ?? (() => new Date());
597 const cap = deps.cap ?? SLEEP_MODE_USER_CAP_PER_TICK;
598 const cooldownHours = deps.cooldownHours ?? SLEEP_MODE_COOLDOWN_HOURS;
599
600 let candidates: SleepModeDigestCandidate[] = [];
601 try {
602 candidates = await findCandidates(cap);
603 } catch (err) {
604 console.error("[autopilot] sleep-mode-digest: findCandidates threw:", err);
605 return { sent: 0, skipped: 0 };
606 }
607
608 const nowDate = now();
609 const currentHour = nowDate.getUTCHours();
610 const cooldownMs = cooldownHours * 60 * 60 * 1000;
611
612 let sent = 0;
613 let skipped = 0;
614
615 for (const cand of candidates) {
616 try {
617 // Hour-match: must equal the user's configured UTC delivery hour.
618 if (cand.digestHourUtc !== currentHour) {
619 skipped += 1;
620 continue;
621 }
622 // Cooldown: skip if we sent within the last cooldown window.
623 if (
624 cand.lastDigestSentAt &&
625 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
626 cooldownMs
627 ) {
628 skipped += 1;
629 continue;
630 }
631 const result = await sendOne(cand.userId);
632 if (result.ok) sent += 1;
633 else skipped += 1;
634 } catch (err) {
635 skipped += 1;
636 console.error(
637 `[autopilot] sleep-mode-digest: per-user failure for user=${cand.userId}:`,
638 err
639 );
640 }
641 }
642
643 return { sent, skipped };
644}
645
534f04aClaude646// ---------------------------------------------------------------------------
647// M3 — pr-risk-rescore
648// ---------------------------------------------------------------------------
649
650export interface PrRiskRescoreCandidate {
651 pullRequestId: string;
652 headBranch: string;
653 updatedAt: Date;
654}
655
656export interface PrRiskRescoreTaskDeps {
657 /** Override candidate finder for tests. */
658 findCandidates?: (
659 lookbackHours: number,
660 cap: number
661 ) => Promise<PrRiskRescoreCandidate[]>;
662 /** Override score computation for tests. */
663 scoreOne?: (prId: string) => Promise<{ ok: boolean }>;
664 /** Override per-tick cap. */
665 cap?: number;
666 /** Override lookback. */
667 lookbackHours?: number;
668}
669
670export interface PrRiskRescoreTaskSummary {
671 scored: number;
672 skipped: number;
673}
674
675/**
676 * Default candidate-finder. Returns open, non-draft PRs from non-archived
677 * repos whose `updated_at` falls inside the lookback window. The "scored
678 * at all" filter is applied as a second pass via `defaultFilterNeedsScoring`.
679 */
680async function defaultFindPrRiskCandidates(
681 lookbackHours: number,
682 cap: number
683): Promise<PrRiskRescoreCandidate[]> {
684 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
685 try {
686 const rows = await db
687 .select({
688 pullRequestId: pullRequests.id,
689 headBranch: pullRequests.headBranch,
690 updatedAt: pullRequests.updatedAt,
691 })
692 .from(pullRequests)
693 .innerJoin(
694 repositories,
695 eq(repositories.id, pullRequests.repositoryId)
696 )
697 .where(
698 and(
699 eq(pullRequests.state, "open"),
700 eq(pullRequests.isDraft, false),
701 eq(repositories.isArchived, false),
702 gte(pullRequests.updatedAt, cutoff)
703 )
704 )
705 .orderBy(sql`${pullRequests.updatedAt} DESC`)
706 .limit(cap);
707 return rows.map((r) => ({
708 pullRequestId: r.pullRequestId,
709 headBranch: r.headBranch,
710 updatedAt: r.updatedAt,
711 }));
712 } catch (err) {
713 console.error("[autopilot] pr-risk-rescore: candidate query failed:", err);
714 return [];
715 }
716}
717
718/**
719 * Drop candidates that already have ANY cached score row. The unique
720 * constraint on (pull_request_id, commit_sha) handles the "score-the-
721 * same-SHA-twice" case at persist time; this filter just keeps the work
722 * list small enough to fit under the per-tick cap when many PRs are
723 * being pushed concurrently.
724 */
725async function defaultFilterNeedsScoring(
726 candidates: PrRiskRescoreCandidate[]
727): Promise<PrRiskRescoreCandidate[]> {
728 if (candidates.length === 0) return [];
729 try {
730 const rows = await db
731 .select({ pullRequestId: prRiskScores.pullRequestId })
732 .from(prRiskScores);
733 const scoredIds = new Set(rows.map((r) => r.pullRequestId));
734 return candidates.filter((c) => !scoredIds.has(c.pullRequestId));
735 } catch (err) {
736 console.error("[autopilot] pr-risk-rescore: filter query failed:", err);
737 // Fail-open: better to score everything than silently skip.
738 return candidates;
739 }
740}
741
742/**
743 * One iteration of the pr-risk-rescore task. Never throws. Compute risk
744 * for up to `cap` recently-touched open PRs that have no cached score
745 * yet, so reviewers usually see a populated card on first visit.
746 */
747export async function runPrRiskRescoreTaskOnce(
748 deps: PrRiskRescoreTaskDeps = {}
749): Promise<PrRiskRescoreTaskSummary> {
750 const findCandidates = deps.findCandidates ?? defaultFindPrRiskCandidates;
751 const scoreOne =
752 deps.scoreOne ??
753 (async (prId: string) => {
754 try {
755 const result = await computePrRiskForPullRequest(prId);
756 return { ok: result !== null };
757 } catch {
758 return { ok: false };
759 }
760 });
761 const cap = deps.cap ?? PR_RISK_RESCORE_MAX_PER_TICK;
762 const lookbackHours =
763 deps.lookbackHours ?? PR_RISK_RESCORE_LOOKBACK_HOURS;
764
765 let candidates: PrRiskRescoreCandidate[] = [];
766 try {
767 candidates = await findCandidates(lookbackHours, cap);
768 } catch (err) {
769 console.error("[autopilot] pr-risk-rescore: findCandidates threw:", err);
770 return { scored: 0, skipped: 0 };
771 }
772
773 // Only score PRs missing a cached row. Skip filter when the caller
774 // injected a custom finder (tests pass already-filtered lists).
775 const needsScoring =
776 deps.findCandidates === undefined
777 ? await defaultFilterNeedsScoring(candidates)
778 : candidates;
779
780 let scored = 0;
781 let skipped = 0;
782 for (const cand of needsScoring.slice(0, cap)) {
783 try {
784 const result = await scoreOne(cand.pullRequestId);
785 if (result.ok) scored += 1;
786 else skipped += 1;
787 } catch (err) {
788 skipped += 1;
789 console.error(
790 `[autopilot] pr-risk-rescore: per-PR failure for pr=${cand.pullRequestId}:`,
791 err
792 );
793 }
794 }
795 if (needsScoring.length > cap) {
796 skipped += needsScoring.length - cap;
797 }
798
799 return { scored, skipped };
800}
801
2b9055eClaude802// ---------------------------------------------------------------------------
803// K3 — auto-merge-sweep
804// ---------------------------------------------------------------------------
805
806interface SweepCandidate {
807 prId: string;
808 prNumber: number;
809 prTitle: string;
810 prBody: string | null;
811 baseBranch: string;
812 headBranch: string;
813 isDraft: boolean;
814 repositoryId: string;
815 authorUserId: string;
816 ownerUsername: string | null;
817 repoName: string;
818 state: string;
819}
820
821export interface AutoMergeSweepDeps {
822 /** Inject candidate-finder for tests. */
823 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
824 /** Inject evaluator for tests. */
825 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
826 /** Inject the merge executor for tests. */
827 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
828 /** Inject the audit-recording side-effect for tests. */
829 recordAttempt?: (
830 repoId: string,
831 prId: string,
832 decision: AutoMergeDecision
833 ) => Promise<void>;
834 /** Inject the audit/comment side-effects for the merged path (tests). */
835 onMerged?: (
836 cand: SweepCandidate,
837 result: PerformMergeResult
838 ) => Promise<void>;
839 /** Inject the audit side-effect for the merge-failed path (tests). */
840 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
841 /** Inject the AI-key short-circuit signal for tests. */
842 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
843}
844
845export interface AutoMergeSweepSummary {
846 evaluated: number;
847 merged: number;
848 blocked: number;
849}
850
851/**
852 * Default candidate-finder. Selects open, non-draft PRs from non-archived
853 * repos whose `updated_at` is within the lookback window. Joins repo +
854 * owner so the merge executor doesn't need extra round trips. Cap is
855 * enforced at the SQL layer.
856 */
857async function defaultFindAutoMergeCandidates(
858 lookbackHours: number,
859 limit: number
860): Promise<SweepCandidate[]> {
861 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
862 try {
863 const rows = await db
864 .select({
865 prId: pullRequests.id,
866 prNumber: pullRequests.number,
867 prTitle: pullRequests.title,
868 prBody: pullRequests.body,
869 baseBranch: pullRequests.baseBranch,
870 headBranch: pullRequests.headBranch,
871 isDraft: pullRequests.isDraft,
872 repositoryId: pullRequests.repositoryId,
873 authorUserId: pullRequests.authorId,
874 ownerUsername: users.username,
875 repoName: repositories.name,
876 state: pullRequests.state,
877 })
878 .from(pullRequests)
879 .innerJoin(
880 repositories,
881 eq(repositories.id, pullRequests.repositoryId)
882 )
883 .leftJoin(users, eq(users.id, repositories.ownerId))
884 .where(
885 and(
886 eq(pullRequests.state, "open"),
887 eq(pullRequests.isDraft, false),
888 eq(repositories.isArchived, false),
889 gte(pullRequests.updatedAt, cutoff)
890 )
891 )
892 .limit(limit);
893 return rows.map((r) => ({
894 prId: r.prId,
895 prNumber: r.prNumber,
896 prTitle: r.prTitle,
897 prBody: r.prBody,
898 baseBranch: r.baseBranch,
899 headBranch: r.headBranch,
900 isDraft: r.isDraft,
901 repositoryId: r.repositoryId,
902 authorUserId: r.authorUserId,
903 ownerUsername: r.ownerUsername ?? null,
904 repoName: r.repoName,
905 state: r.state,
906 }));
907 } catch (err) {
908 console.error("[autopilot] auto-merge: candidate query failed:", err);
909 return [];
910 }
911}
912
913/**
914 * Determine whether the matched branch_protection rule on this PR
915 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
916 * case the AI-approval check would inevitably fail downstream, so we
917 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
918 * — keeps the log readable and prevents misleading "AI review unavailable"
919 * lines in the audit trail.
920 */
921async function defaultShouldShortCircuitAi(
922 cand: SweepCandidate
923): Promise<boolean> {
924 if (process.env.ANTHROPIC_API_KEY) return false;
925 try {
926 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
927 return !!(rule && rule.requireAiApproval);
928 } catch {
929 return false;
930 }
931}
932
933/**
934 * Default success-path: post an `auto_merge.merged` audit row + a stable
935 * marker comment on the PR so a partial-merge retry doesn't double-post.
936 * Both are best-effort; failures are logged not thrown.
937 */
938async function defaultOnMerged(
939 cand: SweepCandidate,
940 result: PerformMergeResult
941): Promise<void> {
942 try {
943 await audit({
944 repositoryId: cand.repositoryId,
945 action: "auto_merge.merged",
946 targetType: "pull_request",
947 targetId: cand.prId,
948 metadata: {
949 prNumber: cand.prNumber,
950 baseBranch: cand.baseBranch,
951 headBranch: cand.headBranch,
952 closedIssueNumbers: result.closedIssueNumbers,
953 resolvedFiles: result.resolvedFiles,
954 },
955 });
956 } catch (err) {
957 console.error("[autopilot] auto-merge: merged audit failed:", err);
958 }
959 try {
960 await db.insert(prComments).values({
961 pullRequestId: cand.prId,
962 authorId: cand.authorUserId,
963 isAiReview: true,
964 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
965 });
966 } catch (err) {
967 console.error("[autopilot] auto-merge: comment insert failed:", err);
968 }
969}
970
971/** Default failure-path: only an audit row; no comment (we may retry). */
972async function defaultOnMergeFailed(
973 cand: SweepCandidate,
974 error: string
975): Promise<void> {
976 try {
977 await audit({
978 repositoryId: cand.repositoryId,
979 action: "auto_merge.merge_failed",
980 targetType: "pull_request",
981 targetId: cand.prId,
982 metadata: {
983 prNumber: cand.prNumber,
984 baseBranch: cand.baseBranch,
985 headBranch: cand.headBranch,
986 error,
987 },
988 });
989 } catch (err) {
990 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
991 }
992}
993
994/**
995 * Execute one sweep over recently-updated open PRs. For each, evaluate
996 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
997 * record the merged/merge-failed audit row + comment. Always record the
998 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
999 *
1000 * Returns a counts summary that the autopilot prints as the tick log line.
1001 * Never throws.
1002 */
1003export async function runAutoMergeSweep(
1004 deps: AutoMergeSweepDeps = {}
1005): Promise<AutoMergeSweepSummary> {
1006 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
1007 const evaluate =
1008 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
1009 const merge =
1010 deps.merge ??
1011 (async (cand) => {
1012 if (!cand.ownerUsername) {
1013 return {
1014 ok: false,
1015 error: "owner username unresolved",
1016 closedIssueNumbers: [],
1017 resolvedFiles: [],
1018 };
1019 }
1020 return performMerge({
1021 pr: {
1022 id: cand.prId,
1023 number: cand.prNumber,
1024 title: cand.prTitle,
1025 body: cand.prBody,
1026 baseBranch: cand.baseBranch,
1027 headBranch: cand.headBranch,
1028 repositoryId: cand.repositoryId,
1029 authorId: cand.authorUserId,
1030 state: cand.state as "open",
1031 isDraft: cand.isDraft,
1032 },
1033 ownerName: cand.ownerUsername,
1034 repoName: cand.repoName,
1035 actorUserId: cand.authorUserId,
1036 });
1037 });
1038 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
1039 const onMerged = deps.onMerged ?? defaultOnMerged;
1040 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
1041 const shouldShortCircuitAi =
1042 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
1043
1044 let candidates: SweepCandidate[] = [];
1045 try {
1046 candidates = await findCandidates(
1047 AUTO_MERGE_LOOKBACK_HOURS,
1048 AUTO_MERGE_MAX_PER_TICK
1049 );
1050 } catch (err) {
1051 console.error("[autopilot] auto-merge: findCandidates threw:", err);
1052 return { evaluated: 0, merged: 0, blocked: 0 };
1053 }
1054
1055 let evaluated = 0;
1056 let merged = 0;
1057 let blocked = 0;
1058
1059 for (const cand of candidates) {
1060 try {
1061 evaluated += 1;
1062
1063 // AI-key short-circuit: if the rule requires AI approval and we have
1064 // no key, treat as blocked without calling the evaluator (which would
1065 // log a misleading "AI review unavailable").
1066 let decision: AutoMergeDecision;
1067 if (await shouldShortCircuitAi(cand)) {
1068 decision = {
1069 merge: false,
1070 reason:
1071 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
1072 blocking: [
1073 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
1074 ],
1075 };
1076 } else {
1077 decision = await evaluate({
1078 pullRequestId: cand.prId,
1079 repositoryId: cand.repositoryId,
1080 baseBranch: cand.baseBranch,
1081 isDraft: cand.isDraft,
1082 authorUserId: cand.authorUserId,
1083 });
1084 }
1085
1086 // Always record the evaluation, regardless of outcome.
1087 try {
1088 await recordAttempt(cand.repositoryId, cand.prId, decision);
1089 } catch (err) {
1090 console.error(
1091 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
1092 err
1093 );
1094 }
1095
1096 if (!decision.merge) {
1097 blocked += 1;
1098 continue;
1099 }
1100
1101 // Perform the actual merge.
1102 const result = await merge(cand);
1103 if (result.ok) {
1104 merged += 1;
1105 await onMerged(cand, result);
1106 } else {
1107 blocked += 1;
1108 await onMergeFailed(cand, result.error || "unknown merge error");
1109 }
1110 } catch (err) {
1111 blocked += 1;
1112 console.error(
1113 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
1114 err
1115 );
1116 }
1117 }
1118
1119 console.log(
1120 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
1121 );
1122
1123 return { evaluated, merged, blocked };
1124}
1125
2b821b7Claude1126/**
1127 * Visits each distinct (repo, base_branch) that has queued rows and logs a
1128 * stub depth line. The actual gate-running + merge happens in the pulls
1129 * route; this tick is just a heartbeat so we can wire per-queue progress
1130 * through without duplicating merge logic.
1131 */
1132async function processMergeQueues(): Promise<void> {
1133 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
1134 try {
1135 const rows = await db
1136 .selectDistinct({
1137 repositoryId: mergeQueueEntries.repositoryId,
1138 baseBranch: mergeQueueEntries.baseBranch,
1139 })
1140 .from(mergeQueueEntries)
1141 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
1142 distinct = rows;
1143 } catch (err) {
1144 console.error("[autopilot] merge-queue: distinct query failed:", err);
1145 return;
1146 }
1147 for (const d of distinct) {
1148 try {
1149 const head = await peekHead(d.repositoryId, d.baseBranch);
1150 if (head) {
1151 console.log(
1152 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
1153 );
1154 }
1155 } catch (err) {
1156 console.error(
1157 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
1158 err
1159 );
1160 }
1161 }
1162}
1163
1164/**
1165 * Pick a small batch of repos that actually have dep rows and re-run
1166 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
1167 */
1168async function rescanAdvisoriesBatch(limit: number): Promise<void> {
1169 let repoIds: string[] = [];
1170 try {
1171 const rows = await db
1172 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
1173 .from(repoDependencies)
1174 .limit(limit);
1175 repoIds = rows.map((r) => r.repositoryId);
1176 } catch (err) {
1177 console.error("[autopilot] advisory-rescan: query failed:", err);
1178 return;
1179 }
1180 for (const id of repoIds) {
1181 try {
1182 await scanRepositoryForAlerts(id);
1183 } catch (err) {
1184 console.error(
1185 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
1186 err
1187 );
1188 }
1189 }
1190}
1191
1192/** Resolve the tick interval from env → opts → default. */
1193function resolveIntervalMs(optsMs?: number): number {
1194 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
1195 const raw = process.env.AUTOPILOT_INTERVAL_MS;
1196 if (raw) {
1197 const parsed = Number(raw);
1198 if (Number.isFinite(parsed) && parsed > 0) return parsed;
1199 }
1200 return DEFAULT_INTERVAL_MS;
1201}
1202
1203/**
1204 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
1205 * The first tick fires after `intervalMs`, not immediately, to keep boot
1206 * fast. Returns a `stop()` that clears the interval.
1207 */
1208export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
1209 if (process.env.AUTOPILOT_DISABLED === "1") {
1210 return { stop: () => {} };
1211 }
1212 const intervalMs = resolveIntervalMs(opts?.intervalMs);
1213 const tasks = opts?.tasks ?? defaultTasks();
1214 let running = false;
1215 const handle = setInterval(() => {
1216 if (running) return;
1217 running = true;
1218 void runAutopilotTick({ tasks, now: opts?.now })
1219 .catch(() => {
1220 // runAutopilotTick already never throws, but belt-and-braces.
1221 })
1222 .finally(() => {
1223 running = false;
1224 });
1225 }, intervalMs);
1226 return {
1227 stop: () => clearInterval(handle),
1228 };
1229}
1230
8e9f1d9Claude1231/** Last tick snapshot for observability. Module-level, swap-on-complete. */
1232let lastTick: AutopilotTickResult | null = null;
1233let tickCount = 0;
1234
1235/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
1236export function getLastTick(): AutopilotTickResult | null {
1237 return lastTick;
1238}
1239
1240/** Return the total number of completed ticks in this process. */
1241export function getTickCount(): number {
1242 return tickCount;
1243}
1244
2b821b7Claude1245/**
1246 * Run one tick: invokes every sub-task with its own try/catch, records a
1247 * per-task result, and emits a single summary line. Never throws.
1248 */
1249export async function runAutopilotTick(
1250 opts?: RunTickOpts
1251): Promise<AutopilotTickResult> {
1252 const now = opts?.now ?? Date.now;
1253 const tasks = opts?.tasks ?? defaultTasks();
1254 const startedAt = new Date(now()).toISOString();
1255 const results: AutopilotTaskResult[] = [];
1256 for (const t of tasks) {
1257 const t0 = now();
1258 try {
1259 await t.run();
1260 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
1261 } catch (err) {
1262 const message =
1263 err instanceof Error ? err.message : String(err ?? "unknown error");
1264 console.error(`[autopilot] ${t.name}: ${message}`);
1265 results.push({
1266 name: t.name,
1267 ok: false,
1268 durationMs: now() - t0,
1269 error: message,
1270 });
1271 }
1272 }
1273 const finishedAt = new Date(now()).toISOString();
1274 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
1275 const okCount = results.filter((r) => r.ok).length;
1276 console.log(
1277 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
1278 );
8e9f1d9Claude1279 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
1280 lastTick = result;
1281 tickCount += 1;
1282 return result;
2b821b7Claude1283}
1284
1285/** Exposed for unit tests. */
1286export const __test = {
1287 resolveIntervalMs,
1288 processMergeQueues,
1289 rescanAdvisoriesBatch,
1290 DEFAULT_INTERVAL_MS,
1291 ADVISORY_RESCAN_BATCH,
2b9055eClaude1292 AUTO_MERGE_LOOKBACK_HOURS,
1293 AUTO_MERGE_MAX_PER_TICK,
1294 AUTO_MERGE_COMMENT_MARKER,
534f04aClaude1295 PR_RISK_RESCORE_MAX_PER_TICK,
1296 PR_RISK_RESCORE_LOOKBACK_HOURS,
2b9055eClaude1297 defaultFindAutoMergeCandidates,
1298 defaultOnMerged,
1299 defaultOnMergeFailed,
1300 defaultShouldShortCircuitAi,
534f04aClaude1301 defaultFindPrRiskCandidates,
1302 defaultFilterNeedsScoring,
2b821b7Claude1303};