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