Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
autopilot.ts18.5 KB · 634 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
/**
 * Autopilot — self-sufficiency loop.
 *
 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
 * weekly digests, advisory rescans) on an interval so the host runs itself
 * without an external cron. All sub-tasks are injected so tests can stub them
 * without touching the DB; the default task set wires real helpers from the
 * locked libs. Nothing here throws — every sub-task and the outer tick are
 * try/caught so a single failure never blocks the others.
 */

import { and, eq, gte, sql } from "drizzle-orm";
import { db } from "../db";
import {
  mergeQueueEntries,
  prComments,
  pullRequests,
  repoDependencies,
  repositories,
  users,
} from "../db/schema";
import { syncAllDue } from "./mirrors";
import { peekHead } from "./merge-queue";
import { sendDigestsToAll } from "./email-digest";
import { scanRepositoryForAlerts } from "./advisories";
import { releaseExpiredWaitTimers } from "./environments";
import { runScheduledWorkflowsTick } from "./scheduled-workflows";
import {
  evaluateAutoMerge,
  recordAutoMergeAttempt,
  type AutoMergeContext,
  type AutoMergeDecision,
} from "./auto-merge";
import { matchProtection } from "./branch-protection";
import { performMerge, type PerformMergeResult } from "./pr-merge";
import { audit } from "./notify";
import { runAiBuildTaskOnce } from "./ai-build-tasks";

export interface AutopilotTaskResult {
  name: string;
  ok: boolean;
  durationMs: number;
  error?: string;
}

export interface AutopilotTickResult {
  startedAt: string;
  finishedAt: string;
  tasks: AutopilotTaskResult[];
}

export interface AutopilotTask {
  name: string;
  run: () => Promise<void>;
}

export interface StartAutopilotOpts {
  intervalMs?: number;
  now?: () => number;
  tasks?: AutopilotTask[];
}

export interface RunTickOpts {
  tasks?: AutopilotTask[];
  now?: () => number;
}

const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
const ADVISORY_RESCAN_BATCH = 5;
/** K3 — recency window for auto-merge candidate selection. */
const AUTO_MERGE_LOOKBACK_HOURS = 24;
/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
const AUTO_MERGE_MAX_PER_TICK = 50;
/** K3 — stable marker for the auto-merge audit comment. */
const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";

/**
 * Default task set. Each task is a thin wrapper around an existing locked
 * helper — no gate/merge logic is duplicated here.
 */
export function defaultTasks(): AutopilotTask[] {
  return [
    {
      name: "mirror-sync",
      run: async () => {
        await syncAllDue();
      },
    },
    {
      name: "merge-queue",
      run: async () => {
        await processMergeQueues();
      },
    },
    {
      name: "weekly-digest",
      run: async () => {
        await sendDigestsToAll();
      },
    },
    {
      name: "advisory-rescan",
      run: async () => {
        await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
      },
    },
    {
      name: "wait-timer-release",
      run: async () => {
        await releaseExpiredWaitTimers();
      },
    },
    {
      name: "scheduled-workflows",
      run: async () => {
        await runScheduledWorkflowsTick();
      },
    },
    {
      name: "auto-merge-sweep",
      run: async () => {
        await runAutoMergeSweep();
      },
    },
    {
      name: "ai-build-from-issues",
      run: async () => {
        const summary = await runAiBuildTaskOnce();
        console.log(
          `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
        );
      },
    },
  ];
}

// ---------------------------------------------------------------------------
// K3 — auto-merge-sweep
// ---------------------------------------------------------------------------

interface SweepCandidate {
  prId: string;
  prNumber: number;
  prTitle: string;
  prBody: string | null;
  baseBranch: string;
  headBranch: string;
  isDraft: boolean;
  repositoryId: string;
  authorUserId: string;
  ownerUsername: string | null;
  repoName: string;
  state: string;
}

export interface AutoMergeSweepDeps {
  /** Inject candidate-finder for tests. */
  findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
  /** Inject evaluator for tests. */
  evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
  /** Inject the merge executor for tests. */
  merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
  /** Inject the audit-recording side-effect for tests. */
  recordAttempt?: (
    repoId: string,
    prId: string,
    decision: AutoMergeDecision
  ) => Promise<void>;
  /** Inject the audit/comment side-effects for the merged path (tests). */
  onMerged?: (
    cand: SweepCandidate,
    result: PerformMergeResult
  ) => Promise<void>;
  /** Inject the audit side-effect for the merge-failed path (tests). */
  onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
  /** Inject the AI-key short-circuit signal for tests. */
  shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
}

export interface AutoMergeSweepSummary {
  evaluated: number;
  merged: number;
  blocked: number;
}

/**
 * Default candidate-finder. Selects open, non-draft PRs from non-archived
 * repos whose `updated_at` is within the lookback window. Joins repo +
 * owner so the merge executor doesn't need extra round trips. Cap is
 * enforced at the SQL layer.
 */
async function defaultFindAutoMergeCandidates(
  lookbackHours: number,
  limit: number
): Promise<SweepCandidate[]> {
  const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
  try {
    const rows = await db
      .select({
        prId: pullRequests.id,
        prNumber: pullRequests.number,
        prTitle: pullRequests.title,
        prBody: pullRequests.body,
        baseBranch: pullRequests.baseBranch,
        headBranch: pullRequests.headBranch,
        isDraft: pullRequests.isDraft,
        repositoryId: pullRequests.repositoryId,
        authorUserId: pullRequests.authorId,
        ownerUsername: users.username,
        repoName: repositories.name,
        state: pullRequests.state,
      })
      .from(pullRequests)
      .innerJoin(
        repositories,
        eq(repositories.id, pullRequests.repositoryId)
      )
      .leftJoin(users, eq(users.id, repositories.ownerId))
      .where(
        and(
          eq(pullRequests.state, "open"),
          eq(pullRequests.isDraft, false),
          eq(repositories.isArchived, false),
          gte(pullRequests.updatedAt, cutoff)
        )
      )
      .limit(limit);
    return rows.map((r) => ({
      prId: r.prId,
      prNumber: r.prNumber,
      prTitle: r.prTitle,
      prBody: r.prBody,
      baseBranch: r.baseBranch,
      headBranch: r.headBranch,
      isDraft: r.isDraft,
      repositoryId: r.repositoryId,
      authorUserId: r.authorUserId,
      ownerUsername: r.ownerUsername ?? null,
      repoName: r.repoName,
      state: r.state,
    }));
  } catch (err) {
    console.error("[autopilot] auto-merge: candidate query failed:", err);
    return [];
  }
}

/**
 * Determine whether the matched branch_protection rule on this PR
 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
 * case the AI-approval check would inevitably fail downstream, so we
 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
 * — keeps the log readable and prevents misleading "AI review unavailable"
 * lines in the audit trail.
 */
async function defaultShouldShortCircuitAi(
  cand: SweepCandidate
): Promise<boolean> {
  if (process.env.ANTHROPIC_API_KEY) return false;
  try {
    const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
    return !!(rule && rule.requireAiApproval);
  } catch {
    return false;
  }
}

/**
 * Default success-path: post an `auto_merge.merged` audit row + a stable
 * marker comment on the PR so a partial-merge retry doesn't double-post.
 * Both are best-effort; failures are logged not thrown.
 */
async function defaultOnMerged(
  cand: SweepCandidate,
  result: PerformMergeResult
): Promise<void> {
  try {
    await audit({
      repositoryId: cand.repositoryId,
      action: "auto_merge.merged",
      targetType: "pull_request",
      targetId: cand.prId,
      metadata: {
        prNumber: cand.prNumber,
        baseBranch: cand.baseBranch,
        headBranch: cand.headBranch,
        closedIssueNumbers: result.closedIssueNumbers,
        resolvedFiles: result.resolvedFiles,
      },
    });
  } catch (err) {
    console.error("[autopilot] auto-merge: merged audit failed:", err);
  }
  try {
    await db.insert(prComments).values({
      pullRequestId: cand.prId,
      authorId: cand.authorUserId,
      isAiReview: true,
      body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
    });
  } catch (err) {
    console.error("[autopilot] auto-merge: comment insert failed:", err);
  }
}

/** Default failure-path: only an audit row; no comment (we may retry). */
async function defaultOnMergeFailed(
  cand: SweepCandidate,
  error: string
): Promise<void> {
  try {
    await audit({
      repositoryId: cand.repositoryId,
      action: "auto_merge.merge_failed",
      targetType: "pull_request",
      targetId: cand.prId,
      metadata: {
        prNumber: cand.prNumber,
        baseBranch: cand.baseBranch,
        headBranch: cand.headBranch,
        error,
      },
    });
  } catch (err) {
    console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
  }
}

/**
 * Execute one sweep over recently-updated open PRs. For each, evaluate
 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
 * record the merged/merge-failed audit row + comment. Always record the
 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
 *
 * Returns a counts summary that the autopilot prints as the tick log line.
 * Never throws.
 */
export async function runAutoMergeSweep(
  deps: AutoMergeSweepDeps = {}
): Promise<AutoMergeSweepSummary> {
  const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
  const evaluate =
    deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
  const merge =
    deps.merge ??
    (async (cand) => {
      if (!cand.ownerUsername) {
        return {
          ok: false,
          error: "owner username unresolved",
          closedIssueNumbers: [],
          resolvedFiles: [],
        };
      }
      return performMerge({
        pr: {
          id: cand.prId,
          number: cand.prNumber,
          title: cand.prTitle,
          body: cand.prBody,
          baseBranch: cand.baseBranch,
          headBranch: cand.headBranch,
          repositoryId: cand.repositoryId,
          authorId: cand.authorUserId,
          state: cand.state as "open",
          isDraft: cand.isDraft,
        },
        ownerName: cand.ownerUsername,
        repoName: cand.repoName,
        actorUserId: cand.authorUserId,
      });
    });
  const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
  const onMerged = deps.onMerged ?? defaultOnMerged;
  const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
  const shouldShortCircuitAi =
    deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;

  let candidates: SweepCandidate[] = [];
  try {
    candidates = await findCandidates(
      AUTO_MERGE_LOOKBACK_HOURS,
      AUTO_MERGE_MAX_PER_TICK
    );
  } catch (err) {
    console.error("[autopilot] auto-merge: findCandidates threw:", err);
    return { evaluated: 0, merged: 0, blocked: 0 };
  }

  let evaluated = 0;
  let merged = 0;
  let blocked = 0;

  for (const cand of candidates) {
    try {
      evaluated += 1;

      // AI-key short-circuit: if the rule requires AI approval and we have
      // no key, treat as blocked without calling the evaluator (which would
      // log a misleading "AI review unavailable").
      let decision: AutoMergeDecision;
      if (await shouldShortCircuitAi(cand)) {
        decision = {
          merge: false,
          reason:
            "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
          blocking: [
            "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
          ],
        };
      } else {
        decision = await evaluate({
          pullRequestId: cand.prId,
          repositoryId: cand.repositoryId,
          baseBranch: cand.baseBranch,
          isDraft: cand.isDraft,
          authorUserId: cand.authorUserId,
        });
      }

      // Always record the evaluation, regardless of outcome.
      try {
        await recordAttempt(cand.repositoryId, cand.prId, decision);
      } catch (err) {
        console.error(
          `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
          err
        );
      }

      if (!decision.merge) {
        blocked += 1;
        continue;
      }

      // Perform the actual merge.
      const result = await merge(cand);
      if (result.ok) {
        merged += 1;
        await onMerged(cand, result);
      } else {
        blocked += 1;
        await onMergeFailed(cand, result.error || "unknown merge error");
      }
    } catch (err) {
      blocked += 1;
      console.error(
        `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
        err
      );
    }
  }

  console.log(
    `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
  );

  return { evaluated, merged, blocked };
}

/**
 * Visits each distinct (repo, base_branch) that has queued rows and logs a
 * stub depth line. The actual gate-running + merge happens in the pulls
 * route; this tick is just a heartbeat so we can wire per-queue progress
 * through without duplicating merge logic.
 */
async function processMergeQueues(): Promise<void> {
  let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
  try {
    const rows = await db
      .selectDistinct({
        repositoryId: mergeQueueEntries.repositoryId,
        baseBranch: mergeQueueEntries.baseBranch,
      })
      .from(mergeQueueEntries)
      .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
    distinct = rows;
  } catch (err) {
    console.error("[autopilot] merge-queue: distinct query failed:", err);
    return;
  }
  for (const d of distinct) {
    try {
      const head = await peekHead(d.repositoryId, d.baseBranch);
      if (head) {
        console.log(
          `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
        );
      }
    } catch (err) {
      console.error(
        `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
        err
      );
    }
  }
}

/**
 * Pick a small batch of repos that actually have dep rows and re-run
 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
 */
async function rescanAdvisoriesBatch(limit: number): Promise<void> {
  let repoIds: string[] = [];
  try {
    const rows = await db
      .selectDistinct({ repositoryId: repoDependencies.repositoryId })
      .from(repoDependencies)
      .limit(limit);
    repoIds = rows.map((r) => r.repositoryId);
  } catch (err) {
    console.error("[autopilot] advisory-rescan: query failed:", err);
    return;
  }
  for (const id of repoIds) {
    try {
      await scanRepositoryForAlerts(id);
    } catch (err) {
      console.error(
        `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
        err
      );
    }
  }
}

/** Resolve the tick interval from env → opts → default. */
function resolveIntervalMs(optsMs?: number): number {
  if (typeof optsMs === "number" && optsMs > 0) return optsMs;
  const raw = process.env.AUTOPILOT_INTERVAL_MS;
  if (raw) {
    const parsed = Number(raw);
    if (Number.isFinite(parsed) && parsed > 0) return parsed;
  }
  return DEFAULT_INTERVAL_MS;
}

/**
 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
 * The first tick fires after `intervalMs`, not immediately, to keep boot
 * fast. Returns a `stop()` that clears the interval.
 */
export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
  if (process.env.AUTOPILOT_DISABLED === "1") {
    return { stop: () => {} };
  }
  const intervalMs = resolveIntervalMs(opts?.intervalMs);
  const tasks = opts?.tasks ?? defaultTasks();
  let running = false;
  const handle = setInterval(() => {
    if (running) return;
    running = true;
    void runAutopilotTick({ tasks, now: opts?.now })
      .catch(() => {
        // runAutopilotTick already never throws, but belt-and-braces.
      })
      .finally(() => {
        running = false;
      });
  }, intervalMs);
  return {
    stop: () => clearInterval(handle),
  };
}

/** Last tick snapshot for observability. Module-level, swap-on-complete. */
let lastTick: AutopilotTickResult | null = null;
let tickCount = 0;

/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
export function getLastTick(): AutopilotTickResult | null {
  return lastTick;
}

/** Return the total number of completed ticks in this process. */
export function getTickCount(): number {
  return tickCount;
}

/**
 * Run one tick: invokes every sub-task with its own try/catch, records a
 * per-task result, and emits a single summary line. Never throws.
 */
export async function runAutopilotTick(
  opts?: RunTickOpts
): Promise<AutopilotTickResult> {
  const now = opts?.now ?? Date.now;
  const tasks = opts?.tasks ?? defaultTasks();
  const startedAt = new Date(now()).toISOString();
  const results: AutopilotTaskResult[] = [];
  for (const t of tasks) {
    const t0 = now();
    try {
      await t.run();
      results.push({ name: t.name, ok: true, durationMs: now() - t0 });
    } catch (err) {
      const message =
        err instanceof Error ? err.message : String(err ?? "unknown error");
      console.error(`[autopilot] ${t.name}: ${message}`);
      results.push({
        name: t.name,
        ok: false,
        durationMs: now() - t0,
        error: message,
      });
    }
  }
  const finishedAt = new Date(now()).toISOString();
  const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
  const okCount = results.filter((r) => r.ok).length;
  console.log(
    `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
  );
  const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
  lastTick = result;
  tickCount += 1;
  return result;
}

/** Exposed for unit tests. */
export const __test = {
  resolveIntervalMs,
  processMergeQueues,
  rescanAdvisoriesBatch,
  DEFAULT_INTERVAL_MS,
  ADVISORY_RESCAN_BATCH,
  AUTO_MERGE_LOOKBACK_HOURS,
  AUTO_MERGE_MAX_PER_TICK,
  AUTO_MERGE_COMMENT_MARKER,
  defaultFindAutoMergeCandidates,
  defaultOnMerged,
  defaultOnMergeFailed,
  defaultShouldShortCircuitAi,
};