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