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.tsBlame981 lines · 1 contributor
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";
2b821b7Claude49
50export interface AutopilotTaskResult {
51 name: string;
52 ok: boolean;
53 durationMs: number;
54 error?: string;
55}
56
57export interface AutopilotTickResult {
58 startedAt: string;
59 finishedAt: string;
60 tasks: AutopilotTaskResult[];
61}
62
63export interface AutopilotTask {
64 name: string;
65 run: () => Promise<void>;
66}
67
68export interface StartAutopilotOpts {
69 intervalMs?: number;
70 now?: () => number;
71 tasks?: AutopilotTask[];
72}
73
74export interface RunTickOpts {
75 tasks?: AutopilotTask[];
76 now?: () => number;
77}
78
79const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
80const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude81/** K3 — recency window for auto-merge candidate selection. */
82const AUTO_MERGE_LOOKBACK_HOURS = 24;
83/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
84const AUTO_MERGE_MAX_PER_TICK = 50;
85/** K3 — stable marker for the auto-merge audit comment. */
86const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
534f04aClaude87/** M3 — hard cap on PRs scored per tick (runaway protection). */
88const PR_RISK_RESCORE_MAX_PER_TICK = 20;
89/** M3 — recency window for the pr-risk-rescore sweep. */
90const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
2b821b7Claude91
92/**
93 * Default task set. Each task is a thin wrapper around an existing locked
94 * helper — no gate/merge logic is duplicated here.
95 */
96export function defaultTasks(): AutopilotTask[] {
97 return [
98 {
99 name: "mirror-sync",
100 run: async () => {
101 await syncAllDue();
102 },
103 },
104 {
105 name: "merge-queue",
106 run: async () => {
107 await processMergeQueues();
108 },
109 },
110 {
111 name: "weekly-digest",
112 run: async () => {
113 await sendDigestsToAll();
114 },
115 },
116 {
117 name: "advisory-rescan",
118 run: async () => {
119 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
120 },
121 },
a76d984Claude122 {
123 name: "wait-timer-release",
124 run: async () => {
125 await releaseExpiredWaitTimers();
126 },
127 },
665c8bfClaude128 {
129 name: "scheduled-workflows",
130 run: async () => {
131 await runScheduledWorkflowsTick();
132 },
133 },
2b9055eClaude134 {
135 name: "auto-merge-sweep",
136 run: async () => {
137 await runAutoMergeSweep();
138 },
139 },
140 {
141 name: "ai-build-from-issues",
142 run: async () => {
143 const summary = await runAiBuildTaskOnce();
144 console.log(
145 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
146 );
147 },
148 },
46d6165Claude149 {
150 name: "sleep-mode-digest",
151 run: async () => {
152 const summary = await runSleepModeDigestTaskOnce();
153 console.log(
154 `[autopilot] sleep-mode-digest: sent=${summary.sent} skipped=${summary.skipped}`
155 );
156 },
157 },
534f04aClaude158 {
159 name: "stale-pr-sweep",
160 run: async () => {
161 // Two-stage gate: poke at 7d stale, close at 14d after poke
162 // (when the repo opts in via `auto_close_stale_prs`).
163 // Wrapped in try/catch so a finder crash never wedges the tick.
164 try {
165 const summary = await runStalePrSweepOnce();
166 console.log(
167 `[autopilot] stale-pr-sweep: poked=${summary.poked} closed=${summary.closed}`
168 );
169 } catch (err) {
170 console.error("[autopilot] stale-pr-sweep: threw:", err);
171 }
172 },
173 },
174 {
175 name: "stale-issue-sweep",
176 run: async () => {
177 // Mirror of stale-pr-sweep with the issue thresholds (30d/60d).
178 try {
179 const summary = await runStaleIssueSweepOnce();
180 console.log(
181 `[autopilot] stale-issue-sweep: poked=${summary.poked} closed=${summary.closed}`
182 );
183 } catch (err) {
184 console.error("[autopilot] stale-issue-sweep: threw:", err);
185 }
186 },
187 },
188 {
189 name: "pr-risk-rescore",
190 run: async () => {
191 const summary = await runPrRiskRescoreTaskOnce();
192 console.log(
193 `[autopilot] pr-risk-rescore: scored=${summary.scored} skipped=${summary.skipped}`
194 );
195 },
196 },
2b821b7Claude197 ];
198}
199
46d6165Claude200// ---------------------------------------------------------------------------
201// L1 — sleep-mode-digest
202// ---------------------------------------------------------------------------
203
204export interface SleepModeDigestCandidate {
205 userId: string;
206 digestHourUtc: number;
207 lastDigestSentAt: Date | null;
208}
209
210export interface SleepModeDigestTaskDeps {
211 /** Override the candidate finder. */
212 findCandidates?: (cap: number) => Promise<SleepModeDigestCandidate[]>;
213 /** Override the send-one-user helper (DI for tests). */
214 sendOne?: (userId: string) => Promise<{ ok: boolean; reason?: string }>;
215 /** Override the wall clock (DI for tests). */
216 now?: () => Date;
217 /** Override the per-tick cap. */
218 cap?: number;
219 /** Override the cooldown hours. */
220 cooldownHours?: number;
221}
222
223export interface SleepModeDigestTaskSummary {
224 sent: number;
225 skipped: number;
226}
227
228/**
229 * Default candidate-finder. Returns enabled users whose
230 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
231 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
232 * timezone-independent of any SQL `extract(hour ...)` behaviour.
233 */
234async function defaultFindSleepModeCandidates(
235 cap: number
236): Promise<SleepModeDigestCandidate[]> {
237 try {
238 const rows = await db
239 .select({
240 userId: users.id,
241 digestHourUtc: users.sleepModeDigestHourUtc,
242 lastDigestSentAt: users.lastDigestSentAt,
243 })
244 .from(users)
245 .where(eq(users.sleepModeEnabled, true))
246 .limit(cap);
247 return rows.map((r) => ({
248 userId: r.userId,
249 digestHourUtc: r.digestHourUtc,
250 lastDigestSentAt: r.lastDigestSentAt,
251 }));
252 } catch (err) {
253 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
254 return [];
255 }
256}
257
258/**
259 * One iteration of the sleep-mode-digest task. Never throws.
260 *
261 * Per-user filters (applied in JS so we can DI a clock):
262 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
263 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
264 * configured local UTC hour.
265 *
266 * Caps at `SLEEP_MODE_USER_CAP_PER_TICK` (100) users per tick.
267 */
268export async function runSleepModeDigestTaskOnce(
269 deps: SleepModeDigestTaskDeps = {}
270): Promise<SleepModeDigestTaskSummary> {
271 const findCandidates =
272 deps.findCandidates ?? defaultFindSleepModeCandidates;
273 const sendOne = deps.sendOne ?? sendSleepModeDigestForUser;
274 const now = deps.now ?? (() => new Date());
275 const cap = deps.cap ?? SLEEP_MODE_USER_CAP_PER_TICK;
276 const cooldownHours = deps.cooldownHours ?? SLEEP_MODE_COOLDOWN_HOURS;
277
278 let candidates: SleepModeDigestCandidate[] = [];
279 try {
280 candidates = await findCandidates(cap);
281 } catch (err) {
282 console.error("[autopilot] sleep-mode-digest: findCandidates threw:", err);
283 return { sent: 0, skipped: 0 };
284 }
285
286 const nowDate = now();
287 const currentHour = nowDate.getUTCHours();
288 const cooldownMs = cooldownHours * 60 * 60 * 1000;
289
290 let sent = 0;
291 let skipped = 0;
292
293 for (const cand of candidates) {
294 try {
295 // Hour-match: must equal the user's configured UTC delivery hour.
296 if (cand.digestHourUtc !== currentHour) {
297 skipped += 1;
298 continue;
299 }
300 // Cooldown: skip if we sent within the last cooldown window.
301 if (
302 cand.lastDigestSentAt &&
303 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
304 cooldownMs
305 ) {
306 skipped += 1;
307 continue;
308 }
309 const result = await sendOne(cand.userId);
310 if (result.ok) sent += 1;
311 else skipped += 1;
312 } catch (err) {
313 skipped += 1;
314 console.error(
315 `[autopilot] sleep-mode-digest: per-user failure for user=${cand.userId}:`,
316 err
317 );
318 }
319 }
320
321 return { sent, skipped };
322}
323
534f04aClaude324// ---------------------------------------------------------------------------
325// M3 — pr-risk-rescore
326// ---------------------------------------------------------------------------
327
328export interface PrRiskRescoreCandidate {
329 pullRequestId: string;
330 headBranch: string;
331 updatedAt: Date;
332}
333
334export interface PrRiskRescoreTaskDeps {
335 /** Override candidate finder for tests. */
336 findCandidates?: (
337 lookbackHours: number,
338 cap: number
339 ) => Promise<PrRiskRescoreCandidate[]>;
340 /** Override score computation for tests. */
341 scoreOne?: (prId: string) => Promise<{ ok: boolean }>;
342 /** Override per-tick cap. */
343 cap?: number;
344 /** Override lookback. */
345 lookbackHours?: number;
346}
347
348export interface PrRiskRescoreTaskSummary {
349 scored: number;
350 skipped: number;
351}
352
353/**
354 * Default candidate-finder. Returns open, non-draft PRs from non-archived
355 * repos whose `updated_at` falls inside the lookback window. The "scored
356 * at all" filter is applied as a second pass via `defaultFilterNeedsScoring`.
357 */
358async function defaultFindPrRiskCandidates(
359 lookbackHours: number,
360 cap: number
361): Promise<PrRiskRescoreCandidate[]> {
362 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
363 try {
364 const rows = await db
365 .select({
366 pullRequestId: pullRequests.id,
367 headBranch: pullRequests.headBranch,
368 updatedAt: pullRequests.updatedAt,
369 })
370 .from(pullRequests)
371 .innerJoin(
372 repositories,
373 eq(repositories.id, pullRequests.repositoryId)
374 )
375 .where(
376 and(
377 eq(pullRequests.state, "open"),
378 eq(pullRequests.isDraft, false),
379 eq(repositories.isArchived, false),
380 gte(pullRequests.updatedAt, cutoff)
381 )
382 )
383 .orderBy(sql`${pullRequests.updatedAt} DESC`)
384 .limit(cap);
385 return rows.map((r) => ({
386 pullRequestId: r.pullRequestId,
387 headBranch: r.headBranch,
388 updatedAt: r.updatedAt,
389 }));
390 } catch (err) {
391 console.error("[autopilot] pr-risk-rescore: candidate query failed:", err);
392 return [];
393 }
394}
395
396/**
397 * Drop candidates that already have ANY cached score row. The unique
398 * constraint on (pull_request_id, commit_sha) handles the "score-the-
399 * same-SHA-twice" case at persist time; this filter just keeps the work
400 * list small enough to fit under the per-tick cap when many PRs are
401 * being pushed concurrently.
402 */
403async function defaultFilterNeedsScoring(
404 candidates: PrRiskRescoreCandidate[]
405): Promise<PrRiskRescoreCandidate[]> {
406 if (candidates.length === 0) return [];
407 try {
408 const rows = await db
409 .select({ pullRequestId: prRiskScores.pullRequestId })
410 .from(prRiskScores);
411 const scoredIds = new Set(rows.map((r) => r.pullRequestId));
412 return candidates.filter((c) => !scoredIds.has(c.pullRequestId));
413 } catch (err) {
414 console.error("[autopilot] pr-risk-rescore: filter query failed:", err);
415 // Fail-open: better to score everything than silently skip.
416 return candidates;
417 }
418}
419
420/**
421 * One iteration of the pr-risk-rescore task. Never throws. Compute risk
422 * for up to `cap` recently-touched open PRs that have no cached score
423 * yet, so reviewers usually see a populated card on first visit.
424 */
425export async function runPrRiskRescoreTaskOnce(
426 deps: PrRiskRescoreTaskDeps = {}
427): Promise<PrRiskRescoreTaskSummary> {
428 const findCandidates = deps.findCandidates ?? defaultFindPrRiskCandidates;
429 const scoreOne =
430 deps.scoreOne ??
431 (async (prId: string) => {
432 try {
433 const result = await computePrRiskForPullRequest(prId);
434 return { ok: result !== null };
435 } catch {
436 return { ok: false };
437 }
438 });
439 const cap = deps.cap ?? PR_RISK_RESCORE_MAX_PER_TICK;
440 const lookbackHours =
441 deps.lookbackHours ?? PR_RISK_RESCORE_LOOKBACK_HOURS;
442
443 let candidates: PrRiskRescoreCandidate[] = [];
444 try {
445 candidates = await findCandidates(lookbackHours, cap);
446 } catch (err) {
447 console.error("[autopilot] pr-risk-rescore: findCandidates threw:", err);
448 return { scored: 0, skipped: 0 };
449 }
450
451 // Only score PRs missing a cached row. Skip filter when the caller
452 // injected a custom finder (tests pass already-filtered lists).
453 const needsScoring =
454 deps.findCandidates === undefined
455 ? await defaultFilterNeedsScoring(candidates)
456 : candidates;
457
458 let scored = 0;
459 let skipped = 0;
460 for (const cand of needsScoring.slice(0, cap)) {
461 try {
462 const result = await scoreOne(cand.pullRequestId);
463 if (result.ok) scored += 1;
464 else skipped += 1;
465 } catch (err) {
466 skipped += 1;
467 console.error(
468 `[autopilot] pr-risk-rescore: per-PR failure for pr=${cand.pullRequestId}:`,
469 err
470 );
471 }
472 }
473 if (needsScoring.length > cap) {
474 skipped += needsScoring.length - cap;
475 }
476
477 return { scored, skipped };
478}
479
2b9055eClaude480// ---------------------------------------------------------------------------
481// K3 — auto-merge-sweep
482// ---------------------------------------------------------------------------
483
484interface SweepCandidate {
485 prId: string;
486 prNumber: number;
487 prTitle: string;
488 prBody: string | null;
489 baseBranch: string;
490 headBranch: string;
491 isDraft: boolean;
492 repositoryId: string;
493 authorUserId: string;
494 ownerUsername: string | null;
495 repoName: string;
496 state: string;
497}
498
499export interface AutoMergeSweepDeps {
500 /** Inject candidate-finder for tests. */
501 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
502 /** Inject evaluator for tests. */
503 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
504 /** Inject the merge executor for tests. */
505 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
506 /** Inject the audit-recording side-effect for tests. */
507 recordAttempt?: (
508 repoId: string,
509 prId: string,
510 decision: AutoMergeDecision
511 ) => Promise<void>;
512 /** Inject the audit/comment side-effects for the merged path (tests). */
513 onMerged?: (
514 cand: SweepCandidate,
515 result: PerformMergeResult
516 ) => Promise<void>;
517 /** Inject the audit side-effect for the merge-failed path (tests). */
518 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
519 /** Inject the AI-key short-circuit signal for tests. */
520 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
521}
522
523export interface AutoMergeSweepSummary {
524 evaluated: number;
525 merged: number;
526 blocked: number;
527}
528
529/**
530 * Default candidate-finder. Selects open, non-draft PRs from non-archived
531 * repos whose `updated_at` is within the lookback window. Joins repo +
532 * owner so the merge executor doesn't need extra round trips. Cap is
533 * enforced at the SQL layer.
534 */
535async function defaultFindAutoMergeCandidates(
536 lookbackHours: number,
537 limit: number
538): Promise<SweepCandidate[]> {
539 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
540 try {
541 const rows = await db
542 .select({
543 prId: pullRequests.id,
544 prNumber: pullRequests.number,
545 prTitle: pullRequests.title,
546 prBody: pullRequests.body,
547 baseBranch: pullRequests.baseBranch,
548 headBranch: pullRequests.headBranch,
549 isDraft: pullRequests.isDraft,
550 repositoryId: pullRequests.repositoryId,
551 authorUserId: pullRequests.authorId,
552 ownerUsername: users.username,
553 repoName: repositories.name,
554 state: pullRequests.state,
555 })
556 .from(pullRequests)
557 .innerJoin(
558 repositories,
559 eq(repositories.id, pullRequests.repositoryId)
560 )
561 .leftJoin(users, eq(users.id, repositories.ownerId))
562 .where(
563 and(
564 eq(pullRequests.state, "open"),
565 eq(pullRequests.isDraft, false),
566 eq(repositories.isArchived, false),
567 gte(pullRequests.updatedAt, cutoff)
568 )
569 )
570 .limit(limit);
571 return rows.map((r) => ({
572 prId: r.prId,
573 prNumber: r.prNumber,
574 prTitle: r.prTitle,
575 prBody: r.prBody,
576 baseBranch: r.baseBranch,
577 headBranch: r.headBranch,
578 isDraft: r.isDraft,
579 repositoryId: r.repositoryId,
580 authorUserId: r.authorUserId,
581 ownerUsername: r.ownerUsername ?? null,
582 repoName: r.repoName,
583 state: r.state,
584 }));
585 } catch (err) {
586 console.error("[autopilot] auto-merge: candidate query failed:", err);
587 return [];
588 }
589}
590
591/**
592 * Determine whether the matched branch_protection rule on this PR
593 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
594 * case the AI-approval check would inevitably fail downstream, so we
595 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
596 * — keeps the log readable and prevents misleading "AI review unavailable"
597 * lines in the audit trail.
598 */
599async function defaultShouldShortCircuitAi(
600 cand: SweepCandidate
601): Promise<boolean> {
602 if (process.env.ANTHROPIC_API_KEY) return false;
603 try {
604 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
605 return !!(rule && rule.requireAiApproval);
606 } catch {
607 return false;
608 }
609}
610
611/**
612 * Default success-path: post an `auto_merge.merged` audit row + a stable
613 * marker comment on the PR so a partial-merge retry doesn't double-post.
614 * Both are best-effort; failures are logged not thrown.
615 */
616async function defaultOnMerged(
617 cand: SweepCandidate,
618 result: PerformMergeResult
619): Promise<void> {
620 try {
621 await audit({
622 repositoryId: cand.repositoryId,
623 action: "auto_merge.merged",
624 targetType: "pull_request",
625 targetId: cand.prId,
626 metadata: {
627 prNumber: cand.prNumber,
628 baseBranch: cand.baseBranch,
629 headBranch: cand.headBranch,
630 closedIssueNumbers: result.closedIssueNumbers,
631 resolvedFiles: result.resolvedFiles,
632 },
633 });
634 } catch (err) {
635 console.error("[autopilot] auto-merge: merged audit failed:", err);
636 }
637 try {
638 await db.insert(prComments).values({
639 pullRequestId: cand.prId,
640 authorId: cand.authorUserId,
641 isAiReview: true,
642 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
643 });
644 } catch (err) {
645 console.error("[autopilot] auto-merge: comment insert failed:", err);
646 }
647}
648
649/** Default failure-path: only an audit row; no comment (we may retry). */
650async function defaultOnMergeFailed(
651 cand: SweepCandidate,
652 error: string
653): Promise<void> {
654 try {
655 await audit({
656 repositoryId: cand.repositoryId,
657 action: "auto_merge.merge_failed",
658 targetType: "pull_request",
659 targetId: cand.prId,
660 metadata: {
661 prNumber: cand.prNumber,
662 baseBranch: cand.baseBranch,
663 headBranch: cand.headBranch,
664 error,
665 },
666 });
667 } catch (err) {
668 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
669 }
670}
671
672/**
673 * Execute one sweep over recently-updated open PRs. For each, evaluate
674 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
675 * record the merged/merge-failed audit row + comment. Always record the
676 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
677 *
678 * Returns a counts summary that the autopilot prints as the tick log line.
679 * Never throws.
680 */
681export async function runAutoMergeSweep(
682 deps: AutoMergeSweepDeps = {}
683): Promise<AutoMergeSweepSummary> {
684 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
685 const evaluate =
686 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
687 const merge =
688 deps.merge ??
689 (async (cand) => {
690 if (!cand.ownerUsername) {
691 return {
692 ok: false,
693 error: "owner username unresolved",
694 closedIssueNumbers: [],
695 resolvedFiles: [],
696 };
697 }
698 return performMerge({
699 pr: {
700 id: cand.prId,
701 number: cand.prNumber,
702 title: cand.prTitle,
703 body: cand.prBody,
704 baseBranch: cand.baseBranch,
705 headBranch: cand.headBranch,
706 repositoryId: cand.repositoryId,
707 authorId: cand.authorUserId,
708 state: cand.state as "open",
709 isDraft: cand.isDraft,
710 },
711 ownerName: cand.ownerUsername,
712 repoName: cand.repoName,
713 actorUserId: cand.authorUserId,
714 });
715 });
716 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
717 const onMerged = deps.onMerged ?? defaultOnMerged;
718 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
719 const shouldShortCircuitAi =
720 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
721
722 let candidates: SweepCandidate[] = [];
723 try {
724 candidates = await findCandidates(
725 AUTO_MERGE_LOOKBACK_HOURS,
726 AUTO_MERGE_MAX_PER_TICK
727 );
728 } catch (err) {
729 console.error("[autopilot] auto-merge: findCandidates threw:", err);
730 return { evaluated: 0, merged: 0, blocked: 0 };
731 }
732
733 let evaluated = 0;
734 let merged = 0;
735 let blocked = 0;
736
737 for (const cand of candidates) {
738 try {
739 evaluated += 1;
740
741 // AI-key short-circuit: if the rule requires AI approval and we have
742 // no key, treat as blocked without calling the evaluator (which would
743 // log a misleading "AI review unavailable").
744 let decision: AutoMergeDecision;
745 if (await shouldShortCircuitAi(cand)) {
746 decision = {
747 merge: false,
748 reason:
749 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
750 blocking: [
751 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
752 ],
753 };
754 } else {
755 decision = await evaluate({
756 pullRequestId: cand.prId,
757 repositoryId: cand.repositoryId,
758 baseBranch: cand.baseBranch,
759 isDraft: cand.isDraft,
760 authorUserId: cand.authorUserId,
761 });
762 }
763
764 // Always record the evaluation, regardless of outcome.
765 try {
766 await recordAttempt(cand.repositoryId, cand.prId, decision);
767 } catch (err) {
768 console.error(
769 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
770 err
771 );
772 }
773
774 if (!decision.merge) {
775 blocked += 1;
776 continue;
777 }
778
779 // Perform the actual merge.
780 const result = await merge(cand);
781 if (result.ok) {
782 merged += 1;
783 await onMerged(cand, result);
784 } else {
785 blocked += 1;
786 await onMergeFailed(cand, result.error || "unknown merge error");
787 }
788 } catch (err) {
789 blocked += 1;
790 console.error(
791 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
792 err
793 );
794 }
795 }
796
797 console.log(
798 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
799 );
800
801 return { evaluated, merged, blocked };
802}
803
2b821b7Claude804/**
805 * Visits each distinct (repo, base_branch) that has queued rows and logs a
806 * stub depth line. The actual gate-running + merge happens in the pulls
807 * route; this tick is just a heartbeat so we can wire per-queue progress
808 * through without duplicating merge logic.
809 */
810async function processMergeQueues(): Promise<void> {
811 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
812 try {
813 const rows = await db
814 .selectDistinct({
815 repositoryId: mergeQueueEntries.repositoryId,
816 baseBranch: mergeQueueEntries.baseBranch,
817 })
818 .from(mergeQueueEntries)
819 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
820 distinct = rows;
821 } catch (err) {
822 console.error("[autopilot] merge-queue: distinct query failed:", err);
823 return;
824 }
825 for (const d of distinct) {
826 try {
827 const head = await peekHead(d.repositoryId, d.baseBranch);
828 if (head) {
829 console.log(
830 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
831 );
832 }
833 } catch (err) {
834 console.error(
835 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
836 err
837 );
838 }
839 }
840}
841
842/**
843 * Pick a small batch of repos that actually have dep rows and re-run
844 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
845 */
846async function rescanAdvisoriesBatch(limit: number): Promise<void> {
847 let repoIds: string[] = [];
848 try {
849 const rows = await db
850 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
851 .from(repoDependencies)
852 .limit(limit);
853 repoIds = rows.map((r) => r.repositoryId);
854 } catch (err) {
855 console.error("[autopilot] advisory-rescan: query failed:", err);
856 return;
857 }
858 for (const id of repoIds) {
859 try {
860 await scanRepositoryForAlerts(id);
861 } catch (err) {
862 console.error(
863 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
864 err
865 );
866 }
867 }
868}
869
870/** Resolve the tick interval from env → opts → default. */
871function resolveIntervalMs(optsMs?: number): number {
872 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
873 const raw = process.env.AUTOPILOT_INTERVAL_MS;
874 if (raw) {
875 const parsed = Number(raw);
876 if (Number.isFinite(parsed) && parsed > 0) return parsed;
877 }
878 return DEFAULT_INTERVAL_MS;
879}
880
881/**
882 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
883 * The first tick fires after `intervalMs`, not immediately, to keep boot
884 * fast. Returns a `stop()` that clears the interval.
885 */
886export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
887 if (process.env.AUTOPILOT_DISABLED === "1") {
888 return { stop: () => {} };
889 }
890 const intervalMs = resolveIntervalMs(opts?.intervalMs);
891 const tasks = opts?.tasks ?? defaultTasks();
892 let running = false;
893 const handle = setInterval(() => {
894 if (running) return;
895 running = true;
896 void runAutopilotTick({ tasks, now: opts?.now })
897 .catch(() => {
898 // runAutopilotTick already never throws, but belt-and-braces.
899 })
900 .finally(() => {
901 running = false;
902 });
903 }, intervalMs);
904 return {
905 stop: () => clearInterval(handle),
906 };
907}
908
8e9f1d9Claude909/** Last tick snapshot for observability. Module-level, swap-on-complete. */
910let lastTick: AutopilotTickResult | null = null;
911let tickCount = 0;
912
913/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
914export function getLastTick(): AutopilotTickResult | null {
915 return lastTick;
916}
917
918/** Return the total number of completed ticks in this process. */
919export function getTickCount(): number {
920 return tickCount;
921}
922
2b821b7Claude923/**
924 * Run one tick: invokes every sub-task with its own try/catch, records a
925 * per-task result, and emits a single summary line. Never throws.
926 */
927export async function runAutopilotTick(
928 opts?: RunTickOpts
929): Promise<AutopilotTickResult> {
930 const now = opts?.now ?? Date.now;
931 const tasks = opts?.tasks ?? defaultTasks();
932 const startedAt = new Date(now()).toISOString();
933 const results: AutopilotTaskResult[] = [];
934 for (const t of tasks) {
935 const t0 = now();
936 try {
937 await t.run();
938 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
939 } catch (err) {
940 const message =
941 err instanceof Error ? err.message : String(err ?? "unknown error");
942 console.error(`[autopilot] ${t.name}: ${message}`);
943 results.push({
944 name: t.name,
945 ok: false,
946 durationMs: now() - t0,
947 error: message,
948 });
949 }
950 }
951 const finishedAt = new Date(now()).toISOString();
952 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
953 const okCount = results.filter((r) => r.ok).length;
954 console.log(
955 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
956 );
8e9f1d9Claude957 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
958 lastTick = result;
959 tickCount += 1;
960 return result;
2b821b7Claude961}
962
963/** Exposed for unit tests. */
964export const __test = {
965 resolveIntervalMs,
966 processMergeQueues,
967 rescanAdvisoriesBatch,
968 DEFAULT_INTERVAL_MS,
969 ADVISORY_RESCAN_BATCH,
2b9055eClaude970 AUTO_MERGE_LOOKBACK_HOURS,
971 AUTO_MERGE_MAX_PER_TICK,
972 AUTO_MERGE_COMMENT_MARKER,
534f04aClaude973 PR_RISK_RESCORE_MAX_PER_TICK,
974 PR_RISK_RESCORE_LOOKBACK_HOURS,
2b9055eClaude975 defaultFindAutoMergeCandidates,
976 defaultOnMerged,
977 defaultOnMergeFailed,
978 defaultShouldShortCircuitAi,
534f04aClaude979 defaultFindPrRiskCandidates,
980 defaultFilterNeedsScoring,
2b821b7Claude981};