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.tsBlame634 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";
2b821b7Claude38
39export interface AutopilotTaskResult {
40 name: string;
41 ok: boolean;
42 durationMs: number;
43 error?: string;
44}
45
46export interface AutopilotTickResult {
47 startedAt: string;
48 finishedAt: string;
49 tasks: AutopilotTaskResult[];
50}
51
52export interface AutopilotTask {
53 name: string;
54 run: () => Promise<void>;
55}
56
57export interface StartAutopilotOpts {
58 intervalMs?: number;
59 now?: () => number;
60 tasks?: AutopilotTask[];
61}
62
63export interface RunTickOpts {
64 tasks?: AutopilotTask[];
65 now?: () => number;
66}
67
68const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
69const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude70/** K3 — recency window for auto-merge candidate selection. */
71const AUTO_MERGE_LOOKBACK_HOURS = 24;
72/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
73const AUTO_MERGE_MAX_PER_TICK = 50;
74/** K3 — stable marker for the auto-merge audit comment. */
75const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
2b821b7Claude76
77/**
78 * Default task set. Each task is a thin wrapper around an existing locked
79 * helper — no gate/merge logic is duplicated here.
80 */
81export function defaultTasks(): AutopilotTask[] {
82 return [
83 {
84 name: "mirror-sync",
85 run: async () => {
86 await syncAllDue();
87 },
88 },
89 {
90 name: "merge-queue",
91 run: async () => {
92 await processMergeQueues();
93 },
94 },
95 {
96 name: "weekly-digest",
97 run: async () => {
98 await sendDigestsToAll();
99 },
100 },
101 {
102 name: "advisory-rescan",
103 run: async () => {
104 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
105 },
106 },
a76d984Claude107 {
108 name: "wait-timer-release",
109 run: async () => {
110 await releaseExpiredWaitTimers();
111 },
112 },
665c8bfClaude113 {
114 name: "scheduled-workflows",
115 run: async () => {
116 await runScheduledWorkflowsTick();
117 },
118 },
2b9055eClaude119 {
120 name: "auto-merge-sweep",
121 run: async () => {
122 await runAutoMergeSweep();
123 },
124 },
125 {
126 name: "ai-build-from-issues",
127 run: async () => {
128 const summary = await runAiBuildTaskOnce();
129 console.log(
130 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
131 );
132 },
133 },
2b821b7Claude134 ];
135}
136
2b9055eClaude137// ---------------------------------------------------------------------------
138// K3 — auto-merge-sweep
139// ---------------------------------------------------------------------------
140
141interface SweepCandidate {
142 prId: string;
143 prNumber: number;
144 prTitle: string;
145 prBody: string | null;
146 baseBranch: string;
147 headBranch: string;
148 isDraft: boolean;
149 repositoryId: string;
150 authorUserId: string;
151 ownerUsername: string | null;
152 repoName: string;
153 state: string;
154}
155
156export interface AutoMergeSweepDeps {
157 /** Inject candidate-finder for tests. */
158 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
159 /** Inject evaluator for tests. */
160 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
161 /** Inject the merge executor for tests. */
162 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
163 /** Inject the audit-recording side-effect for tests. */
164 recordAttempt?: (
165 repoId: string,
166 prId: string,
167 decision: AutoMergeDecision
168 ) => Promise<void>;
169 /** Inject the audit/comment side-effects for the merged path (tests). */
170 onMerged?: (
171 cand: SweepCandidate,
172 result: PerformMergeResult
173 ) => Promise<void>;
174 /** Inject the audit side-effect for the merge-failed path (tests). */
175 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
176 /** Inject the AI-key short-circuit signal for tests. */
177 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
178}
179
180export interface AutoMergeSweepSummary {
181 evaluated: number;
182 merged: number;
183 blocked: number;
184}
185
186/**
187 * Default candidate-finder. Selects open, non-draft PRs from non-archived
188 * repos whose `updated_at` is within the lookback window. Joins repo +
189 * owner so the merge executor doesn't need extra round trips. Cap is
190 * enforced at the SQL layer.
191 */
192async function defaultFindAutoMergeCandidates(
193 lookbackHours: number,
194 limit: number
195): Promise<SweepCandidate[]> {
196 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
197 try {
198 const rows = await db
199 .select({
200 prId: pullRequests.id,
201 prNumber: pullRequests.number,
202 prTitle: pullRequests.title,
203 prBody: pullRequests.body,
204 baseBranch: pullRequests.baseBranch,
205 headBranch: pullRequests.headBranch,
206 isDraft: pullRequests.isDraft,
207 repositoryId: pullRequests.repositoryId,
208 authorUserId: pullRequests.authorId,
209 ownerUsername: users.username,
210 repoName: repositories.name,
211 state: pullRequests.state,
212 })
213 .from(pullRequests)
214 .innerJoin(
215 repositories,
216 eq(repositories.id, pullRequests.repositoryId)
217 )
218 .leftJoin(users, eq(users.id, repositories.ownerId))
219 .where(
220 and(
221 eq(pullRequests.state, "open"),
222 eq(pullRequests.isDraft, false),
223 eq(repositories.isArchived, false),
224 gte(pullRequests.updatedAt, cutoff)
225 )
226 )
227 .limit(limit);
228 return rows.map((r) => ({
229 prId: r.prId,
230 prNumber: r.prNumber,
231 prTitle: r.prTitle,
232 prBody: r.prBody,
233 baseBranch: r.baseBranch,
234 headBranch: r.headBranch,
235 isDraft: r.isDraft,
236 repositoryId: r.repositoryId,
237 authorUserId: r.authorUserId,
238 ownerUsername: r.ownerUsername ?? null,
239 repoName: r.repoName,
240 state: r.state,
241 }));
242 } catch (err) {
243 console.error("[autopilot] auto-merge: candidate query failed:", err);
244 return [];
245 }
246}
247
248/**
249 * Determine whether the matched branch_protection rule on this PR
250 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
251 * case the AI-approval check would inevitably fail downstream, so we
252 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
253 * — keeps the log readable and prevents misleading "AI review unavailable"
254 * lines in the audit trail.
255 */
256async function defaultShouldShortCircuitAi(
257 cand: SweepCandidate
258): Promise<boolean> {
259 if (process.env.ANTHROPIC_API_KEY) return false;
260 try {
261 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
262 return !!(rule && rule.requireAiApproval);
263 } catch {
264 return false;
265 }
266}
267
268/**
269 * Default success-path: post an `auto_merge.merged` audit row + a stable
270 * marker comment on the PR so a partial-merge retry doesn't double-post.
271 * Both are best-effort; failures are logged not thrown.
272 */
273async function defaultOnMerged(
274 cand: SweepCandidate,
275 result: PerformMergeResult
276): Promise<void> {
277 try {
278 await audit({
279 repositoryId: cand.repositoryId,
280 action: "auto_merge.merged",
281 targetType: "pull_request",
282 targetId: cand.prId,
283 metadata: {
284 prNumber: cand.prNumber,
285 baseBranch: cand.baseBranch,
286 headBranch: cand.headBranch,
287 closedIssueNumbers: result.closedIssueNumbers,
288 resolvedFiles: result.resolvedFiles,
289 },
290 });
291 } catch (err) {
292 console.error("[autopilot] auto-merge: merged audit failed:", err);
293 }
294 try {
295 await db.insert(prComments).values({
296 pullRequestId: cand.prId,
297 authorId: cand.authorUserId,
298 isAiReview: true,
299 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
300 });
301 } catch (err) {
302 console.error("[autopilot] auto-merge: comment insert failed:", err);
303 }
304}
305
306/** Default failure-path: only an audit row; no comment (we may retry). */
307async function defaultOnMergeFailed(
308 cand: SweepCandidate,
309 error: string
310): Promise<void> {
311 try {
312 await audit({
313 repositoryId: cand.repositoryId,
314 action: "auto_merge.merge_failed",
315 targetType: "pull_request",
316 targetId: cand.prId,
317 metadata: {
318 prNumber: cand.prNumber,
319 baseBranch: cand.baseBranch,
320 headBranch: cand.headBranch,
321 error,
322 },
323 });
324 } catch (err) {
325 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
326 }
327}
328
329/**
330 * Execute one sweep over recently-updated open PRs. For each, evaluate
331 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
332 * record the merged/merge-failed audit row + comment. Always record the
333 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
334 *
335 * Returns a counts summary that the autopilot prints as the tick log line.
336 * Never throws.
337 */
338export async function runAutoMergeSweep(
339 deps: AutoMergeSweepDeps = {}
340): Promise<AutoMergeSweepSummary> {
341 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
342 const evaluate =
343 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
344 const merge =
345 deps.merge ??
346 (async (cand) => {
347 if (!cand.ownerUsername) {
348 return {
349 ok: false,
350 error: "owner username unresolved",
351 closedIssueNumbers: [],
352 resolvedFiles: [],
353 };
354 }
355 return performMerge({
356 pr: {
357 id: cand.prId,
358 number: cand.prNumber,
359 title: cand.prTitle,
360 body: cand.prBody,
361 baseBranch: cand.baseBranch,
362 headBranch: cand.headBranch,
363 repositoryId: cand.repositoryId,
364 authorId: cand.authorUserId,
365 state: cand.state as "open",
366 isDraft: cand.isDraft,
367 },
368 ownerName: cand.ownerUsername,
369 repoName: cand.repoName,
370 actorUserId: cand.authorUserId,
371 });
372 });
373 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
374 const onMerged = deps.onMerged ?? defaultOnMerged;
375 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
376 const shouldShortCircuitAi =
377 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
378
379 let candidates: SweepCandidate[] = [];
380 try {
381 candidates = await findCandidates(
382 AUTO_MERGE_LOOKBACK_HOURS,
383 AUTO_MERGE_MAX_PER_TICK
384 );
385 } catch (err) {
386 console.error("[autopilot] auto-merge: findCandidates threw:", err);
387 return { evaluated: 0, merged: 0, blocked: 0 };
388 }
389
390 let evaluated = 0;
391 let merged = 0;
392 let blocked = 0;
393
394 for (const cand of candidates) {
395 try {
396 evaluated += 1;
397
398 // AI-key short-circuit: if the rule requires AI approval and we have
399 // no key, treat as blocked without calling the evaluator (which would
400 // log a misleading "AI review unavailable").
401 let decision: AutoMergeDecision;
402 if (await shouldShortCircuitAi(cand)) {
403 decision = {
404 merge: false,
405 reason:
406 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
407 blocking: [
408 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
409 ],
410 };
411 } else {
412 decision = await evaluate({
413 pullRequestId: cand.prId,
414 repositoryId: cand.repositoryId,
415 baseBranch: cand.baseBranch,
416 isDraft: cand.isDraft,
417 authorUserId: cand.authorUserId,
418 });
419 }
420
421 // Always record the evaluation, regardless of outcome.
422 try {
423 await recordAttempt(cand.repositoryId, cand.prId, decision);
424 } catch (err) {
425 console.error(
426 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
427 err
428 );
429 }
430
431 if (!decision.merge) {
432 blocked += 1;
433 continue;
434 }
435
436 // Perform the actual merge.
437 const result = await merge(cand);
438 if (result.ok) {
439 merged += 1;
440 await onMerged(cand, result);
441 } else {
442 blocked += 1;
443 await onMergeFailed(cand, result.error || "unknown merge error");
444 }
445 } catch (err) {
446 blocked += 1;
447 console.error(
448 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
449 err
450 );
451 }
452 }
453
454 console.log(
455 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
456 );
457
458 return { evaluated, merged, blocked };
459}
460
2b821b7Claude461/**
462 * Visits each distinct (repo, base_branch) that has queued rows and logs a
463 * stub depth line. The actual gate-running + merge happens in the pulls
464 * route; this tick is just a heartbeat so we can wire per-queue progress
465 * through without duplicating merge logic.
466 */
467async function processMergeQueues(): Promise<void> {
468 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
469 try {
470 const rows = await db
471 .selectDistinct({
472 repositoryId: mergeQueueEntries.repositoryId,
473 baseBranch: mergeQueueEntries.baseBranch,
474 })
475 .from(mergeQueueEntries)
476 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
477 distinct = rows;
478 } catch (err) {
479 console.error("[autopilot] merge-queue: distinct query failed:", err);
480 return;
481 }
482 for (const d of distinct) {
483 try {
484 const head = await peekHead(d.repositoryId, d.baseBranch);
485 if (head) {
486 console.log(
487 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
488 );
489 }
490 } catch (err) {
491 console.error(
492 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
493 err
494 );
495 }
496 }
497}
498
499/**
500 * Pick a small batch of repos that actually have dep rows and re-run
501 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
502 */
503async function rescanAdvisoriesBatch(limit: number): Promise<void> {
504 let repoIds: string[] = [];
505 try {
506 const rows = await db
507 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
508 .from(repoDependencies)
509 .limit(limit);
510 repoIds = rows.map((r) => r.repositoryId);
511 } catch (err) {
512 console.error("[autopilot] advisory-rescan: query failed:", err);
513 return;
514 }
515 for (const id of repoIds) {
516 try {
517 await scanRepositoryForAlerts(id);
518 } catch (err) {
519 console.error(
520 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
521 err
522 );
523 }
524 }
525}
526
527/** Resolve the tick interval from env → opts → default. */
528function resolveIntervalMs(optsMs?: number): number {
529 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
530 const raw = process.env.AUTOPILOT_INTERVAL_MS;
531 if (raw) {
532 const parsed = Number(raw);
533 if (Number.isFinite(parsed) && parsed > 0) return parsed;
534 }
535 return DEFAULT_INTERVAL_MS;
536}
537
538/**
539 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
540 * The first tick fires after `intervalMs`, not immediately, to keep boot
541 * fast. Returns a `stop()` that clears the interval.
542 */
543export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
544 if (process.env.AUTOPILOT_DISABLED === "1") {
545 return { stop: () => {} };
546 }
547 const intervalMs = resolveIntervalMs(opts?.intervalMs);
548 const tasks = opts?.tasks ?? defaultTasks();
549 let running = false;
550 const handle = setInterval(() => {
551 if (running) return;
552 running = true;
553 void runAutopilotTick({ tasks, now: opts?.now })
554 .catch(() => {
555 // runAutopilotTick already never throws, but belt-and-braces.
556 })
557 .finally(() => {
558 running = false;
559 });
560 }, intervalMs);
561 return {
562 stop: () => clearInterval(handle),
563 };
564}
565
8e9f1d9Claude566/** Last tick snapshot for observability. Module-level, swap-on-complete. */
567let lastTick: AutopilotTickResult | null = null;
568let tickCount = 0;
569
570/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
571export function getLastTick(): AutopilotTickResult | null {
572 return lastTick;
573}
574
575/** Return the total number of completed ticks in this process. */
576export function getTickCount(): number {
577 return tickCount;
578}
579
2b821b7Claude580/**
581 * Run one tick: invokes every sub-task with its own try/catch, records a
582 * per-task result, and emits a single summary line. Never throws.
583 */
584export async function runAutopilotTick(
585 opts?: RunTickOpts
586): Promise<AutopilotTickResult> {
587 const now = opts?.now ?? Date.now;
588 const tasks = opts?.tasks ?? defaultTasks();
589 const startedAt = new Date(now()).toISOString();
590 const results: AutopilotTaskResult[] = [];
591 for (const t of tasks) {
592 const t0 = now();
593 try {
594 await t.run();
595 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
596 } catch (err) {
597 const message =
598 err instanceof Error ? err.message : String(err ?? "unknown error");
599 console.error(`[autopilot] ${t.name}: ${message}`);
600 results.push({
601 name: t.name,
602 ok: false,
603 durationMs: now() - t0,
604 error: message,
605 });
606 }
607 }
608 const finishedAt = new Date(now()).toISOString();
609 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
610 const okCount = results.filter((r) => r.ok).length;
611 console.log(
612 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
613 );
8e9f1d9Claude614 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
615 lastTick = result;
616 tickCount += 1;
617 return result;
2b821b7Claude618}
619
620/** Exposed for unit tests. */
621export const __test = {
622 resolveIntervalMs,
623 processMergeQueues,
624 rescanAdvisoriesBatch,
625 DEFAULT_INTERVAL_MS,
626 ADVISORY_RESCAN_BATCH,
2b9055eClaude627 AUTO_MERGE_LOOKBACK_HOURS,
628 AUTO_MERGE_MAX_PER_TICK,
629 AUTO_MERGE_COMMENT_MARKER,
630 defaultFindAutoMergeCandidates,
631 defaultOnMerged,
632 defaultOnMergeFailed,
633 defaultShouldShortCircuitAi,
2b821b7Claude634};