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