Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit2b9055eunknown_key

feat(K3): AI-driven autopilot — auto-merge sweep + ai:build issue dispatcher

feat(K3): AI-driven autopilot — auto-merge sweep + ai:build issue dispatcher

Extends the 5-minute autopilot ticker (locked in BIBLE §4.5) with two
new tasks so the platform proactively does what a human would:

  • auto-merge-sweep — every tick, find open non-draft PRs whose base
    branch matched a K2 branch_protection rule with enable_auto_merge=true,
    call evaluateAutoMerge() per PR, and for each merge=true decision
    actually perform the merge. Records auto_merge.evaluated for every
    PR (success or block) plus auto_merge.merged or auto_merge.merge_failed
    on the outcome. Posts a stable <!-- gluecron:auto-merge:v1 --> marker
    comment so a re-run never double-merges or double-comments. Caps at
    50 PRs per tick; bounds the candidate set to the last 24h to avoid
    sweeping the entire history. AI-key short-circuits to a clean
    "blocked" decision when ANTHROPIC_API_KEY is unset and the rule has
    requireAiApproval=true.

  • ai-build-from-issues — every tick, find open issues labelled
    `ai:build` (case-insensitive), skip those already linked to a PR via
    J7 close-keywords or already dispatched (marker
    <!-- gluecron:ai-build:v1 -->), compose a spec via the existing
    buildSpecFromIssue helper, then call src/lib/spec-to-pr to open a
    draft PR. Posts the marker comment on the issue so we never
    re-dispatch. Cap 20 issues per tick. Fire-and-forget; spec-to-pr
    failures are caught and logged.

Both tasks are dependency-injected through their respective orchestrators
so the test surface can drive every branch without a live DB, ticker
loop, or git subprocess. Both respect AUTOPILOT_DISABLED=1 and skip
archived repos.

Files
  src/lib/pr-merge.ts (270 lines, new)
    Shared performMerge(prId) helper factored from src/routes/pulls.tsx.
    Pure `{ok, error}` return; never throws. Handles the git-update-ref
    / mergeWithAutoResolve switch, PR state flip, and the J7
    close-keyword auto-close pass. The HTTP route is NOT yet refactored
    to call this — spec accepted leaving it duplicated rather than
    cracking open the locked routes/pulls.tsx (the route's redirect-
    based error surface doesn't map cleanly to a single function
    return). Flagged as a follow-up.

  src/lib/ai-build-tasks.ts (364 lines, new)
    runAiBuildTaskOnce orchestrator + 5 collaborator interfaces
    (findIssues, listOpenPrs, postIssueComment, buildSpec, dispatchSpec).
    Idempotency via the gluecron:ai-build:v1 marker; cross-reference
    against open PRs via extractClosingRefs.

  src/lib/autopilot.ts (265 → 634 lines)
    Adds runAutoMergeSweep + the two new tasks to defaultTasks().
    Existing six tasks untouched per the locked-block rule.

  src/__tests__/autopilot-ai-tasks.test.ts (438 lines, new)
    14 tests: drafts skipped, archived repos skipped, merge-fn called
    exactly once per merge=true PR, ai:build label found, marker
    dedup honoured, AUTOPILOT_DISABLED short-circuit, AI-key absence
    short-circuit.

Tests
  bun test src/__tests__/autopilot-ai-tasks.test.ts  → 14/0
  bun test src/__tests__/autopilot.test.ts            → 10/0 (unchanged)
  bun test                                            → 1350/0 (stable
                                                        across 3 runs)

Follow-ups (not done; flagged):
  1. Refactor src/routes/pulls.tsx merge handler to call performMerge().
     Would also let us delete the inline git update-ref + J7 close-
     keyword loop from the route. Single shared code path.
  2. Add a synthetic `system`/`autopilot` user so auto-merge / ai-build
     marker comments don't credit the PR/issue author.
  3. Surface the new tasks on /admin/autopilot.
  4. Update BUILD_BIBLE.md §4.5 to add pr-merge.ts + ai-build-tasks.ts
     to LOCKED, bump the autopilot row's task count to 8.
Claude committed on May 13, 2026Parent: 2136cc5
4 files changed+144322b9055ec757dc428c742570ae10fb3677a0d621e
4 changed files+1443−2
Addedsrc/__tests__/autopilot-ai-tasks.test.ts+438−0View fileUnifiedSplit
1/**
2 * Block K3 — autopilot AI-driver task tests.
3 *
4 * Uses the dependency-injection seams added in `src/lib/autopilot.ts`
5 * (`runAutoMergeSweep`) and `src/lib/ai-build-tasks.ts`
6 * (`runAiBuildTaskOnce`) so we never hit the DB or the AI client.
7 *
8 * Covers the contract called out in the K3 spec:
9 * - auto-merge-sweep skips drafts
10 * - auto-merge-sweep skips archived repos
11 * - auto-merge-sweep invokes merge exactly once per `merge:true` PR
12 * - ai-build dispatches against `ai:build`-labelled issues
13 * - ai-build skips an issue with a marker comment already present
14 * - both tasks no-op cleanly when AUTOPILOT_DISABLED=1
15 */
16
17import { describe, it, expect, beforeEach, afterEach } from "bun:test";
18import {
19 startAutopilot,
20 runAutoMergeSweep,
21 type AutoMergeSweepDeps,
22 type AutopilotTask,
23} from "../lib/autopilot";
24import {
25 runAiBuildTaskOnce,
26 AI_BUILD_MARKER,
27 type AiBuildCandidate,
28} from "../lib/ai-build-tasks";
29import type {
30 AutoMergeContext,
31 AutoMergeDecision,
32} from "../lib/auto-merge";
33import type { PerformMergeResult } from "../lib/pr-merge";
34
35// ---------------------------------------------------------------------------
36// Test fixtures
37// ---------------------------------------------------------------------------
38
39type Candidate = Parameters<NonNullable<AutoMergeSweepDeps["merge"]>>[0];
40
41function makeCandidate(overrides: Partial<Candidate> = {}): Candidate {
42 return {
43 prId: "pr-1",
44 prNumber: 42,
45 prTitle: "Test PR",
46 prBody: "Closes #99",
47 baseBranch: "main",
48 headBranch: "feature",
49 isDraft: false,
50 repositoryId: "repo-1",
51 authorUserId: "user-1",
52 ownerUsername: "alice",
53 repoName: "demo",
54 state: "open",
55 ...overrides,
56 };
57}
58
59const okMerge: PerformMergeResult = {
60 ok: true,
61 closedIssueNumbers: [],
62 resolvedFiles: [],
63};
64
65const allowDecision: AutoMergeDecision = {
66 merge: true,
67 reason: "All auto-merge conditions met for 'main'.",
68};
69
70const blockDecision: AutoMergeDecision = {
71 merge: false,
72 reason: "blocked",
73 blocking: ["draft"],
74};
75
76// ---------------------------------------------------------------------------
77// auto-merge-sweep
78// ---------------------------------------------------------------------------
79
80describe("auto-merge-sweep", () => {
81 it("skips drafts at the candidate-finder layer (drafts must never enter the loop)", async () => {
82 // The default findCandidates filters drafts via SQL. We model that
83 // contract here: when the caller supplies drafts, the sweep STILL
84 // delegates the merge() call ONLY for non-draft PRs whose decision is
85 // merge:true. Drafts that somehow leak through must surface as blocked
86 // (decideAutoMerge will reject them).
87 const draft = makeCandidate({ prId: "draft-1", isDraft: true });
88 const open = makeCandidate({ prId: "open-1", isDraft: false });
89 const mergeCalls: string[] = [];
90
91 const summary = await runAutoMergeSweep({
92 findCandidates: async () => [draft, open],
93 // Simulate evaluateAutoMerge: drafts get blocked, opens get allowed.
94 evaluate: async (ctx: AutoMergeContext) =>
95 ctx.isDraft ? blockDecision : allowDecision,
96 merge: async (cand) => {
97 mergeCalls.push(cand.prId);
98 return okMerge;
99 },
100 recordAttempt: async () => {},
101 onMerged: async () => {},
102 onMergeFailed: async () => {},
103 shouldShortCircuitAi: async () => false,
104 });
105
106 expect(mergeCalls).toEqual(["open-1"]);
107 expect(summary.evaluated).toBe(2);
108 expect(summary.merged).toBe(1);
109 expect(summary.blocked).toBe(1);
110 });
111
112 it("skips archived repos (no candidates surfaced from the finder)", async () => {
113 // The default finder excludes archived repos in SQL. We simulate by
114 // supplying an empty result and confirming a clean no-op summary.
115 let findCalled = false;
116 let mergeCalled = 0;
117 const summary = await runAutoMergeSweep({
118 findCandidates: async () => {
119 findCalled = true;
120 return [];
121 },
122 evaluate: async () => allowDecision,
123 merge: async () => {
124 mergeCalled += 1;
125 return okMerge;
126 },
127 recordAttempt: async () => {},
128 onMerged: async () => {},
129 onMergeFailed: async () => {},
130 shouldShortCircuitAi: async () => false,
131 });
132 expect(findCalled).toBe(true);
133 expect(mergeCalled).toBe(0);
134 expect(summary).toEqual({ evaluated: 0, merged: 0, blocked: 0 });
135 });
136
137 it("invokes the merge function exactly once per merge:true PR and records an evaluated audit per PR", async () => {
138 const candidates = [
139 makeCandidate({ prId: "a" }),
140 makeCandidate({ prId: "b" }),
141 makeCandidate({ prId: "c" }),
142 ];
143 const mergeCalls: string[] = [];
144 const evaluatedAuditCalls: string[] = [];
145 const mergedSuccessCalls: string[] = [];
146
147 const summary = await runAutoMergeSweep({
148 findCandidates: async () => candidates,
149 // 'a' and 'c' are allowed, 'b' is blocked.
150 evaluate: async (ctx) =>
151 ctx.pullRequestId === "b" ? blockDecision : allowDecision,
152 merge: async (cand) => {
153 mergeCalls.push(cand.prId);
154 return okMerge;
155 },
156 recordAttempt: async (_repoId, prId) => {
157 evaluatedAuditCalls.push(prId);
158 },
159 onMerged: async (cand) => {
160 mergedSuccessCalls.push(cand.prId);
161 },
162 onMergeFailed: async () => {},
163 shouldShortCircuitAi: async () => false,
164 });
165
166 expect(mergeCalls).toEqual(["a", "c"]);
167 expect(mergedSuccessCalls).toEqual(["a", "c"]);
168 // recordAttempt fires for EVERY evaluation, not just successes.
169 expect(evaluatedAuditCalls.sort()).toEqual(["a", "b", "c"]);
170 expect(summary).toEqual({ evaluated: 3, merged: 2, blocked: 1 });
171 });
172
173 it("emits the merge_failed audit when performMerge returns ok=false (and does NOT emit merged audit)", async () => {
174 const cand = makeCandidate({ prId: "x" });
175 let mergedHits = 0;
176 let failedHits = 0;
177
178 const summary = await runAutoMergeSweep({
179 findCandidates: async () => [cand],
180 evaluate: async () => allowDecision,
181 merge: async () => ({
182 ok: false,
183 error: "git update-ref failed: not a fast-forward",
184 closedIssueNumbers: [],
185 resolvedFiles: [],
186 }),
187 recordAttempt: async () => {},
188 onMerged: async () => {
189 mergedHits += 1;
190 },
191 onMergeFailed: async () => {
192 failedHits += 1;
193 },
194 shouldShortCircuitAi: async () => false,
195 });
196
197 expect(mergedHits).toBe(0);
198 expect(failedHits).toBe(1);
199 expect(summary).toEqual({ evaluated: 1, merged: 0, blocked: 1 });
200 });
201
202 it("short-circuits AI-required rules when ANTHROPIC_API_KEY is unset (logged as blocked, not error)", async () => {
203 const cand = makeCandidate({ prId: "ai-1" });
204 let evaluateHits = 0;
205 let mergeHits = 0;
206
207 const summary = await runAutoMergeSweep({
208 findCandidates: async () => [cand],
209 shouldShortCircuitAi: async () => true,
210 evaluate: async () => {
211 evaluateHits += 1;
212 return allowDecision;
213 },
214 merge: async () => {
215 mergeHits += 1;
216 return okMerge;
217 },
218 recordAttempt: async () => {},
219 onMerged: async () => {},
220 onMergeFailed: async () => {},
221 });
222 expect(evaluateHits).toBe(0); // never invoked
223 expect(mergeHits).toBe(0); // never invoked
224 expect(summary).toEqual({ evaluated: 1, merged: 0, blocked: 1 });
225 });
226
227 it("never throws even when the candidate-finder throws — returns zero summary", async () => {
228 const summary = await runAutoMergeSweep({
229 findCandidates: async () => {
230 throw new Error("db blew up");
231 },
232 evaluate: async () => allowDecision,
233 merge: async () => okMerge,
234 recordAttempt: async () => {},
235 onMerged: async () => {},
236 onMergeFailed: async () => {},
237 shouldShortCircuitAi: async () => false,
238 });
239 expect(summary).toEqual({ evaluated: 0, merged: 0, blocked: 0 });
240 });
241
242 it("isolates per-PR failures — a thrown merge() doesn't stop later PRs", async () => {
243 const candidates = [
244 makeCandidate({ prId: "first" }),
245 makeCandidate({ prId: "second" }),
246 ];
247 const mergeCalls: string[] = [];
248 const summary = await runAutoMergeSweep({
249 findCandidates: async () => candidates,
250 evaluate: async () => allowDecision,
251 merge: async (cand) => {
252 mergeCalls.push(cand.prId);
253 if (cand.prId === "first") throw new Error("kaboom");
254 return okMerge;
255 },
256 recordAttempt: async () => {},
257 onMerged: async () => {},
258 onMergeFailed: async () => {},
259 shouldShortCircuitAi: async () => false,
260 });
261 expect(mergeCalls).toEqual(["first", "second"]);
262 expect(summary.evaluated).toBe(2);
263 expect(summary.merged).toBe(1);
264 expect(summary.blocked).toBe(1);
265 });
266});
267
268// ---------------------------------------------------------------------------
269// ai-build-from-issues
270// ---------------------------------------------------------------------------
271
272function makeAiBuildCandidate(
273 overrides: Partial<AiBuildCandidate> = {}
274): AiBuildCandidate {
275 return {
276 issueId: "issue-1",
277 issueNumber: 7,
278 issueTitle: "Add a sparkle button",
279 issueBody: "Should sparkle when clicked.",
280 repositoryId: "repo-1",
281 authorUserId: "user-1",
282 ownerUsername: "alice",
283 repoName: "demo",
284 defaultBranch: "main",
285 ...overrides,
286 };
287}
288
289describe("ai-build-from-issues", () => {
290 it("dispatches the spec-to-PR pipeline for an ai:build-labelled issue", async () => {
291 const dispatched: Array<{ repoId: string; spec: string; baseRef: string }> =
292 [];
293 const markersPosted: string[] = [];
294
295 const summary = await runAiBuildTaskOnce({
296 findCandidates: async () => [makeAiBuildCandidate()],
297 hasDispatchMarker: async () => false,
298 hasOpenLinkedPr: async () => false,
299 dispatcher: async (args) => {
300 dispatched.push({
301 repoId: args.repoId,
302 spec: args.spec,
303 baseRef: args.baseRef,
304 });
305 return { ok: true, prNumber: 123 };
306 },
307 postMarkerComment: async (issueId) => {
308 markersPosted.push(issueId);
309 },
310 });
311
312 expect(dispatched.length).toBe(1);
313 expect(dispatched[0].repoId).toBe("repo-1");
314 expect(dispatched[0].baseRef).toBe("main");
315 // buildSpecFromIssue prefixes with "Implement:" and includes "Closes #N".
316 expect(dispatched[0].spec).toContain("Implement: Add a sparkle button");
317 expect(dispatched[0].spec).toContain("Closes #7");
318 expect(markersPosted).toEqual(["issue-1"]);
319 expect(summary).toEqual({ queued: 1, skipped: 0 });
320 });
321
322 it("skips an issue that already has the dispatch marker comment", async () => {
323 const dispatched: string[] = [];
324 const markersPosted: string[] = [];
325
326 const summary = await runAiBuildTaskOnce({
327 findCandidates: async () => [makeAiBuildCandidate({ issueId: "i-already" })],
328 hasDispatchMarker: async (id) => id === "i-already",
329 hasOpenLinkedPr: async () => false,
330 dispatcher: async (args) => {
331 dispatched.push(args.repoId);
332 return { ok: true, prNumber: 1 };
333 },
334 postMarkerComment: async (id) => {
335 markersPosted.push(id);
336 },
337 });
338
339 expect(dispatched).toEqual([]);
340 expect(markersPosted).toEqual([]);
341 expect(summary).toEqual({ queued: 0, skipped: 1 });
342 });
343
344 it("skips an issue that already has an open PR closing it via close-keywords", async () => {
345 let dispatcherHits = 0;
346 const summary = await runAiBuildTaskOnce({
347 findCandidates: async () => [makeAiBuildCandidate({ issueNumber: 42 })],
348 hasDispatchMarker: async () => false,
349 hasOpenLinkedPr: async (_repoId, n) => n === 42,
350 dispatcher: async () => {
351 dispatcherHits += 1;
352 return { ok: true, prNumber: 1 };
353 },
354 postMarkerComment: async () => {},
355 });
356 expect(dispatcherHits).toBe(0);
357 expect(summary).toEqual({ queued: 0, skipped: 1 });
358 });
359
360 it("swallows dispatcher failures without crashing the tick", async () => {
361 let markerPosted = false;
362 const summary = await runAiBuildTaskOnce({
363 findCandidates: async () => [makeAiBuildCandidate()],
364 hasDispatchMarker: async () => false,
365 hasOpenLinkedPr: async () => false,
366 dispatcher: async () => {
367 throw new Error("anthropic 500");
368 },
369 postMarkerComment: async () => {
370 markerPosted = true;
371 },
372 });
373 // Marker still posted (idempotency takes priority over success).
374 expect(markerPosted).toBe(true);
375 // The issue counts as queued because we did our part — we don't retry.
376 expect(summary.queued).toBe(1);
377 });
378
379 it("returns zero summary if findCandidates throws", async () => {
380 const summary = await runAiBuildTaskOnce({
381 findCandidates: async () => {
382 throw new Error("db down");
383 },
384 });
385 expect(summary).toEqual({ queued: 0, skipped: 0 });
386 });
387
388 it("uses the AI_BUILD_MARKER constant in the marker body", async () => {
389 let receivedBody = "";
390 await runAiBuildTaskOnce({
391 findCandidates: async () => [makeAiBuildCandidate()],
392 hasDispatchMarker: async () => false,
393 hasOpenLinkedPr: async () => false,
394 dispatcher: async () => ({ ok: true, prNumber: 1 }),
395 postMarkerComment: async (_id, _author, body) => {
396 receivedBody = body;
397 },
398 });
399 expect(receivedBody.includes(AI_BUILD_MARKER)).toBe(true);
400 });
401});
402
403// ---------------------------------------------------------------------------
404// AUTOPILOT_DISABLED=1 — both tasks no-op when the parent loop never runs.
405// ---------------------------------------------------------------------------
406
407describe("autopilot disabled — neither AI task fires", () => {
408 const originalDisabled = process.env.AUTOPILOT_DISABLED;
409 afterEach(() => {
410 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
411 else process.env.AUTOPILOT_DISABLED = originalDisabled;
412 });
413
414 it("startAutopilot with AUTOPILOT_DISABLED=1 doesn't run injected AI tasks", async () => {
415 process.env.AUTOPILOT_DISABLED = "1";
416 let autoMergeRan = 0;
417 let aiBuildRan = 0;
418 const tasks: AutopilotTask[] = [
419 {
420 name: "auto-merge-sweep",
421 run: async () => {
422 autoMergeRan += 1;
423 },
424 },
425 {
426 name: "ai-build-from-issues",
427 run: async () => {
428 aiBuildRan += 1;
429 },
430 },
431 ];
432 const { stop } = startAutopilot({ intervalMs: 5, tasks });
433 await new Promise((r) => setTimeout(r, 40));
434 stop();
435 expect(autoMergeRan).toBe(0);
436 expect(aiBuildRan).toBe(0);
437 });
438});
Addedsrc/lib/ai-build-tasks.ts+364−0View fileUnifiedSplit
1/**
2 * Block K3 — AI-build dispatcher (issues tagged `ai:build`).
3 *
4 * Walks open issues whose label set includes a (case-insensitive) `ai:build`
5 * label and, for each one, dispatches a Spec-to-PR build off the existing
6 * `src/lib/spec-to-pr.ts` pipeline. Cheap idempotency: every dispatched
7 * issue gets a marker comment containing `AI_BUILD_MARKER`; on subsequent
8 * ticks that marker tells us "already handled, skip".
9 *
10 * Every dispatch is fire-and-forget — failures are logged and swallowed so
11 * one bad issue can't wedge the autopilot tick.
12 *
13 * Inputs are dependency-injected so the autopilot test suite can exercise
14 * the loop without touching the DB or the AI client.
15 *
16 * NOT in this module:
17 * - The Spec-to-PR pipeline itself (lives in `src/lib/spec-to-pr.ts`).
18 * - The autopilot wrapper / timing concerns (lives in `src/lib/autopilot.ts`).
19 */
20
21import { and, eq, ilike, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 issueComments,
25 issueLabels,
26 issues,
27 labels,
28 pullRequests,
29 repositories,
30 users,
31} from "../db/schema";
32import { extractClosingRefsMulti } from "./close-keywords";
33import { buildSpecFromIssue } from "../routes/specs";
34
35/**
36 * Stable marker baked into the issue comment so subsequent ticks can detect
37 * "already dispatched" without race conditions. Versioned so we can bump
38 * the contract later without re-dispatching every old issue.
39 */
40export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
41
42const DEFAULT_MAX_ISSUES_PER_TICK = 20;
43
44export interface AiBuildCandidate {
45 issueId: string;
46 issueNumber: number;
47 issueTitle: string;
48 issueBody: string | null;
49 repositoryId: string;
50 authorUserId: string;
51 /** Resolved repo owner username; null if the row is somehow orphaned. */
52 ownerUsername: string | null;
53 /** Repo name (for logging/branch naming downstream). */
54 repoName: string;
55 /** Default branch — passed to spec-to-PR as `baseRef`. */
56 defaultBranch: string;
57}
58
59export interface SpecDispatcher {
60 /**
61 * Same signature as `createSpecPR` in `src/lib/spec-to-pr.ts`.
62 * Returns `ok:false` when ANTHROPIC_API_KEY is unset (gracefully).
63 */
64 (args: {
65 repoId: string;
66 spec: string;
67 baseRef: string;
68 userId: string;
69 }): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }>;
70}
71
72export interface AiBuildTaskDeps {
73 /** Override how candidates are sourced (DI for tests). */
74 findCandidates?: (limit: number) => Promise<AiBuildCandidate[]>;
75 /** Override whether a candidate is already marker-tagged (DI for tests). */
76 hasDispatchMarker?: (issueId: string) => Promise<boolean>;
77 /**
78 * Override whether an open PR already closes this issue via close-keywords.
79 * Returns true if some open PR's title/body references `closes #N`.
80 */
81 hasOpenLinkedPr?: (
82 repositoryId: string,
83 issueNumber: number
84 ) => Promise<boolean>;
85 /** Inject the dispatcher (real one lives in spec-to-pr.ts). */
86 dispatcher?: SpecDispatcher;
87 /** Inject the marker-comment writer (DI for tests). */
88 postMarkerComment?: (
89 issueId: string,
90 authorUserId: string,
91 body: string
92 ) => Promise<void>;
93 /** Override the per-tick cap. */
94 maxIssuesPerTick?: number;
95}
96
97export interface AiBuildTaskSummary {
98 queued: number;
99 skipped: number;
100}
101
102/**
103 * Default implementation of `findCandidates`. Joins open issues to their
104 * label set, filters on a case-insensitive `ai:build` label name, and
105 * resolves repo metadata + owner so the dispatcher has everything it needs.
106 *
107 * Skips archived repos. Cap is applied at the SQL layer.
108 */
109async function defaultFindCandidates(
110 limit: number
111): Promise<AiBuildCandidate[]> {
112 try {
113 const rows = await db
114 .select({
115 issueId: issues.id,
116 issueNumber: issues.number,
117 issueTitle: issues.title,
118 issueBody: issues.body,
119 repositoryId: issues.repositoryId,
120 authorUserId: issues.authorId,
121 ownerUsername: users.username,
122 repoName: repositories.name,
123 defaultBranch: repositories.defaultBranch,
124 })
125 .from(issues)
126 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
127 .leftJoin(users, eq(users.id, repositories.ownerId))
128 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
129 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
130 .where(
131 and(
132 eq(issues.state, "open"),
133 eq(repositories.isArchived, false),
134 ilike(labels.name, "ai:build")
135 )
136 )
137 .limit(limit);
138 return rows.map((r) => ({
139 issueId: r.issueId,
140 issueNumber: r.issueNumber,
141 issueTitle: r.issueTitle,
142 issueBody: r.issueBody,
143 repositoryId: r.repositoryId,
144 authorUserId: r.authorUserId,
145 ownerUsername: r.ownerUsername ?? null,
146 repoName: r.repoName,
147 defaultBranch: r.defaultBranch || "main",
148 }));
149 } catch (err) {
150 console.error("[autopilot] ai-build: candidate query failed:", err);
151 return [];
152 }
153}
154
155/**
156 * Default implementation of `hasDispatchMarker`. Looks for ANY comment on
157 * the issue whose body contains `AI_BUILD_MARKER`.
158 */
159async function defaultHasDispatchMarker(issueId: string): Promise<boolean> {
160 try {
161 const rows = await db
162 .select({ id: issueComments.id })
163 .from(issueComments)
164 .where(
165 and(
166 eq(issueComments.issueId, issueId),
167 sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
168 )
169 )
170 .limit(1);
171 return rows.length > 0;
172 } catch {
173 // Be conservative: on DB error, treat as already-dispatched so we don't
174 // spam the issue on a transient outage.
175 return true;
176 }
177}
178
179/**
180 * Default implementation of `hasOpenLinkedPr`. Scans open PRs in the same
181 * repo and uses `extractClosingRefsMulti` against title + body to see if
182 * any of them references the issue with a closing keyword.
183 */
184async function defaultHasOpenLinkedPr(
185 repositoryId: string,
186 issueNumber: number
187): Promise<boolean> {
188 try {
189 const rows = await db
190 .select({ title: pullRequests.title, body: pullRequests.body })
191 .from(pullRequests)
192 .where(
193 and(
194 eq(pullRequests.repositoryId, repositoryId),
195 eq(pullRequests.state, "open")
196 )
197 );
198 for (const r of rows) {
199 const refs = extractClosingRefsMulti([r.title, r.body]);
200 if (refs.includes(issueNumber)) return true;
201 }
202 return false;
203 } catch {
204 return false;
205 }
206}
207
208/**
209 * Default dispatcher. Dynamic-imports `createSpecPR` from `spec-to-pr.ts`
210 * the same way `src/routes/specs.tsx` does, so we tolerate optional builds
211 * where the module might be absent or missing the export.
212 */
213async function defaultDispatcher(args: {
214 repoId: string;
215 spec: string;
216 baseRef: string;
217 userId: string;
218}): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }> {
219 try {
220 const mod: any = await import("./spec-to-pr");
221 const fn = mod && (mod.createSpecPR || mod.default?.createSpecPR);
222 if (typeof fn !== "function") {
223 return { ok: false, error: "createSpecPR not exported by spec-to-pr.ts" };
224 }
225 const res = await fn(args);
226 // Normalise — createSpecPR may return additional fields; we only need ok/prNumber.
227 if (res && res.ok) return { ok: true, prNumber: res.prNumber };
228 return {
229 ok: false,
230 error: (res && "error" in res && res.error) || "unknown error",
231 };
232 } catch (err) {
233 return {
234 ok: false,
235 error: err instanceof Error ? err.message : String(err),
236 };
237 }
238}
239
240/**
241 * Default marker-comment writer. Posts a single issue comment authored by
242 * the issue's own author so we don't need a separate "autopilot" user
243 * (avoids dependency on a system user existing).
244 */
245async function defaultPostMarkerComment(
246 issueId: string,
247 authorUserId: string,
248 body: string
249): Promise<void> {
250 try {
251 await db.insert(issueComments).values({
252 issueId,
253 authorId: authorUserId,
254 body,
255 });
256 } catch (err) {
257 console.error("[autopilot] ai-build: marker insert failed:", err);
258 }
259}
260
261/**
262 * One iteration of the ai-build dispatcher. Returns a summary suitable
263 * for the autopilot tick log. Never throws.
264 */
265export async function runAiBuildTaskOnce(
266 deps: AiBuildTaskDeps = {}
267): Promise<AiBuildTaskSummary> {
268 const limit = deps.maxIssuesPerTick ?? DEFAULT_MAX_ISSUES_PER_TICK;
269 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
270 const hasDispatchMarker = deps.hasDispatchMarker ?? defaultHasDispatchMarker;
271 const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr;
272 const dispatcher = deps.dispatcher ?? defaultDispatcher;
273 const postMarkerComment = deps.postMarkerComment ?? defaultPostMarkerComment;
274
275 let candidates: AiBuildCandidate[] = [];
276 try {
277 candidates = await findCandidates(limit);
278 } catch (err) {
279 console.error("[autopilot] ai-build: findCandidates threw:", err);
280 return { queued: 0, skipped: 0 };
281 }
282
283 let queued = 0;
284 let skipped = 0;
285
286 for (const cand of candidates) {
287 try {
288 // Sanity: we need an owner to build the on-disk path further down.
289 if (!cand.ownerUsername) {
290 skipped += 1;
291 continue;
292 }
293
294 // Skip if there's already an open PR that closes this issue via
295 // close-keyword convention.
296 if (await hasOpenLinkedPr(cand.repositoryId, cand.issueNumber)) {
297 skipped += 1;
298 continue;
299 }
300
301 // Skip if we've already dispatched (marker comment present).
302 if (await hasDispatchMarker(cand.issueId)) {
303 skipped += 1;
304 continue;
305 }
306
307 const spec = buildSpecFromIssue({
308 number: cand.issueNumber,
309 title: cand.issueTitle,
310 body: cand.issueBody,
311 });
312
313 // Post the marker BEFORE dispatching. This way, if the dispatcher is
314 // slow or transiently fails, a subsequent tick won't double-fire.
315 // The marker is the source of truth for idempotency.
316 await postMarkerComment(
317 cand.issueId,
318 cand.authorUserId,
319 `${AI_BUILD_MARKER}\nQueued an AI-build off this issue's spec. The PR (if any) will reference this issue via "Closes #${cand.issueNumber}".`
320 );
321
322 // Fire the dispatcher. Errors are swallowed — the marker has already
323 // been posted so we won't retry. (Operators can delete the marker
324 // comment to force a re-run.)
325 try {
326 const res = await dispatcher({
327 repoId: cand.repositoryId,
328 spec,
329 baseRef: cand.defaultBranch,
330 userId: cand.authorUserId,
331 });
332 if (!res.ok) {
333 console.error(
334 `[autopilot] ai-build: dispatcher failed for issue=${cand.issueId}: ${res.error}`
335 );
336 }
337 } catch (err) {
338 console.error(
339 `[autopilot] ai-build: dispatcher threw for issue=${cand.issueId}:`,
340 err
341 );
342 }
343 queued += 1;
344 } catch (err) {
345 console.error(
346 `[autopilot] ai-build: per-issue failure for issue=${cand.issueId}:`,
347 err
348 );
349 skipped += 1;
350 }
351 }
352
353 return { queued, skipped };
354}
355
356/** Test-only surface. */
357export const __test = {
358 defaultFindCandidates,
359 defaultHasDispatchMarker,
360 defaultHasOpenLinkedPr,
361 defaultDispatcher,
362 defaultPostMarkerComment,
363 DEFAULT_MAX_ISSUES_PER_TICK,
364};
Modifiedsrc/lib/autopilot.ts+371−2View fileUnifiedSplit
99 * try/caught so a single failure never blocks the others.
1010 */
1111
12import { sql } from "drizzle-orm";
12import { and, eq, gte, sql } from "drizzle-orm";
1313import { db } from "../db";
14import { mergeQueueEntries, repoDependencies } from "../db/schema";
14import {
15 mergeQueueEntries,
16 prComments,
17 pullRequests,
18 repoDependencies,
19 repositories,
20 users,
21} from "../db/schema";
1522import { syncAllDue } from "./mirrors";
1623import { peekHead } from "./merge-queue";
1724import { sendDigestsToAll } from "./email-digest";
1825import { scanRepositoryForAlerts } from "./advisories";
1926import { releaseExpiredWaitTimers } from "./environments";
2027import { runScheduledWorkflowsTick } from "./scheduled-workflows";
28import {
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";
2138
2239export interface AutopilotTaskResult {
2340 name: string;
5067
5168const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
5269const ADVISORY_RESCAN_BATCH = 5;
70/** 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 -->";
5376
5477/**
5578 * Default task set. Each task is a thin wrapper around an existing locked
93116 await runScheduledWorkflowsTick();
94117 },
95118 },
119 {
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 },
96134 ];
97135}
98136
137// ---------------------------------------------------------------------------
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
99461/**
100462 * Visits each distinct (repo, base_branch) that has queued rows and logs a
101463 * stub depth line. The actual gate-running + merge happens in the pulls
262624 rescanAdvisoriesBatch,
263625 DEFAULT_INTERVAL_MS,
264626 ADVISORY_RESCAN_BATCH,
627 AUTO_MERGE_LOOKBACK_HOURS,
628 AUTO_MERGE_MAX_PER_TICK,
629 AUTO_MERGE_COMMENT_MARKER,
630 defaultFindAutoMergeCandidates,
631 defaultOnMerged,
632 defaultOnMergeFailed,
633 defaultShouldShortCircuitAi,
265634};
Addedsrc/lib/pr-merge.ts+270−0View fileUnifiedSplit
1/**
2 * Block K3 — Shared PR merge executor.
3 *
4 * Factors the side-effecting merge mechanics out of the
5 * `POST /:owner/:repo/pulls/:number/merge` HTTP handler in `src/routes/pulls.tsx`
6 * so the autopilot's `auto-merge-sweep` task can perform a merge without
7 * replicating route logic. The HTTP handler retains its own gating chain
8 * (gate checks, branch-protection re-evaluation, error redirects); this
9 * module only covers the post-decision mechanics:
10 *
11 * 1. Run the actual ref update (`git update-ref` for ff/clean merges,
12 * delegating to `mergeWithAutoResolve` when the K2-style decision
13 * flagged conflicts and AI conflict-resolution is enabled).
14 * 2. Flip `pull_requests.state` to `merged`, stamp `mergedAt` / `mergedBy`.
15 * 3. Run J7 close-keyword scanning — close any referenced open issues in
16 * the same repo and post the back-link comment.
17 *
18 * Pure error-funnel: every failure is returned as `{ok:false, error}`; we
19 * never throw. Callers decide how to surface the error (HTTP redirect vs.
20 * audit row).
21 *
22 * Intentionally NOT in this file:
23 * - Gate evaluation / branch-protection (use `evaluateAutoMerge` in K2,
24 * or the inline chain in the HTTP handler).
25 * - AI review comment posting (the auto-merge audit/comment is the
26 * autopilot task's responsibility).
27 */
28
29import { and, eq } from "drizzle-orm";
30import { db } from "../db";
31import {
32 issueComments,
33 issues,
34 pullRequests,
35 type PullRequest,
36} from "../db/schema";
37import { getRepoPath } from "../git/repository";
38import { mergeWithAutoResolve } from "./merge-resolver";
39import { isAiReviewEnabled } from "./ai-review";
40import { extractClosingRefsMulti } from "./close-keywords";
41
42export interface PerformMergeArgs {
43 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
44 pr: Pick<
45 PullRequest,
46 | "id"
47 | "number"
48 | "title"
49 | "body"
50 | "baseBranch"
51 | "headBranch"
52 | "repositoryId"
53 | "authorId"
54 | "state"
55 | "isDraft"
56 >;
57 ownerName: string;
58 repoName: string;
59 /** Whose user id to stamp on `merged_by` + close-keyword comments. */
60 actorUserId: string;
61 /**
62 * When true, indicates the caller's gate matrix saw a `Merge check` failure
63 * — we should route through `mergeWithAutoResolve` (Claude-assisted
64 * resolution) instead of a plain ref update. The autopilot sweep currently
65 * passes `false` because `evaluateAutoMerge` already requires green gates.
66 */
67 hasConflicts?: boolean;
68}
69
70export interface PerformMergeResult {
71 ok: boolean;
72 error?: string;
73 /**
74 * Issue numbers that were auto-closed by J7 close-keyword scanning.
75 * Empty array on no matches or on close-keyword failure (never throws).
76 */
77 closedIssueNumbers: number[];
78 /**
79 * Files that the AI conflict resolver touched, when `hasConflicts` routed
80 * through `mergeWithAutoResolve`. Empty when a plain ref update was used.
81 */
82 resolvedFiles: string[];
83}
84
85/**
86 * Internal helper: run the actual git operation (ref update or
87 * Claude-assisted merge). Returns `{ok}` so the caller decides whether to
88 * flip DB state.
89 */
90async function executeGitMerge(args: {
91 ownerName: string;
92 repoName: string;
93 baseBranch: string;
94 headBranch: string;
95 prNumber: number;
96 prTitle: string;
97 hasConflicts: boolean;
98}): Promise<{ ok: true; resolvedFiles: string[] } | { ok: false; error: string }> {
99 const repoDir = getRepoPath(args.ownerName, args.repoName);
100
101 if (args.hasConflicts && isAiReviewEnabled()) {
102 const mergeResult = await mergeWithAutoResolve(
103 args.ownerName,
104 args.repoName,
105 args.baseBranch,
106 args.headBranch,
107 `Merge pull request #${args.prNumber}: ${args.prTitle}`
108 );
109 if (!mergeResult.success) {
110 return {
111 ok: false,
112 error: mergeResult.error || "Auto-merge failed",
113 };
114 }
115 return { ok: true, resolvedFiles: mergeResult.resolvedFiles };
116 }
117
118 try {
119 const proc = Bun.spawn(
120 [
121 "git",
122 "update-ref",
123 `refs/heads/${args.baseBranch}`,
124 `refs/heads/${args.headBranch}`,
125 ],
126 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
127 );
128 const exit = await proc.exited;
129 if (exit !== 0) {
130 const errText = await new Response(proc.stderr).text();
131 return {
132 ok: false,
133 error: `git update-ref failed: ${errText.trim() || `exit ${exit}`}`,
134 };
135 }
136 return { ok: true, resolvedFiles: [] };
137 } catch (err) {
138 return {
139 ok: false,
140 error: `git update-ref threw: ${err instanceof Error ? err.message : String(err)}`,
141 };
142 }
143}
144
145/**
146 * Apply J7 close-keyword scanning. Best-effort — failures swallowed and
147 * surfaced via the returned array (which is empty on any error).
148 */
149async function applyCloseKeywords(args: {
150 pr: PerformMergeArgs["pr"];
151 actorUserId: string;
152}): Promise<number[]> {
153 const closed: number[] = [];
154 try {
155 const refs = extractClosingRefsMulti([args.pr.title, args.pr.body]);
156 for (const n of refs) {
157 const [issue] = await db
158 .select()
159 .from(issues)
160 .where(
161 and(
162 eq(issues.repositoryId, args.pr.repositoryId),
163 eq(issues.number, n)
164 )
165 )
166 .limit(1);
167 if (!issue || issue.state !== "open") continue;
168 await db
169 .update(issues)
170 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
171 .where(eq(issues.id, issue.id));
172 await db.insert(issueComments).values({
173 issueId: issue.id,
174 authorId: args.actorUserId,
175 body: `Closed by pull request #${args.pr.number}.`,
176 });
177 closed.push(n);
178 }
179 } catch {
180 // J7 invariant: close-keyword failures never block the merge.
181 }
182 return closed;
183}
184
185/**
186 * Run a PR merge end-to-end (git + DB + close-keywords). Caller is
187 * responsible for having pre-validated that the merge is allowed.
188 *
189 * Returns:
190 * - ok=true with `closedIssueNumbers` + `resolvedFiles` on full success.
191 * - ok=false with `error` if the git step failed; DB is left untouched.
192 * (DB-update failures are bubbled up the same way.)
193 */
194export async function performMerge(
195 args: PerformMergeArgs
196): Promise<PerformMergeResult> {
197 // Defence-in-depth: refuse to act on PRs that aren't actually open/non-draft.
198 if (args.pr.state !== "open") {
199 return {
200 ok: false,
201 error: `PR is not open (state=${args.pr.state}).`,
202 closedIssueNumbers: [],
203 resolvedFiles: [],
204 };
205 }
206 if (args.pr.isDraft) {
207 return {
208 ok: false,
209 error: "PR is a draft — drafts cannot be merged.",
210 closedIssueNumbers: [],
211 resolvedFiles: [],
212 };
213 }
214
215 const gitResult = await executeGitMerge({
216 ownerName: args.ownerName,
217 repoName: args.repoName,
218 baseBranch: args.pr.baseBranch,
219 headBranch: args.pr.headBranch,
220 prNumber: args.pr.number,
221 prTitle: args.pr.title,
222 hasConflicts: args.hasConflicts === true,
223 });
224 if (!gitResult.ok) {
225 return {
226 ok: false,
227 error: gitResult.error,
228 closedIssueNumbers: [],
229 resolvedFiles: [],
230 };
231 }
232
233 try {
234 await db
235 .update(pullRequests)
236 .set({
237 state: "merged",
238 mergedAt: new Date(),
239 mergedBy: args.actorUserId,
240 updatedAt: new Date(),
241 })
242 .where(eq(pullRequests.id, args.pr.id));
243 } catch (err) {
244 return {
245 ok: false,
246 error: `DB update failed after git merge: ${
247 err instanceof Error ? err.message : String(err)
248 }`,
249 closedIssueNumbers: [],
250 resolvedFiles: gitResult.resolvedFiles,
251 };
252 }
253
254 const closedIssueNumbers = await applyCloseKeywords({
255 pr: args.pr,
256 actorUserId: args.actorUserId,
257 });
258
259 return {
260 ok: true,
261 closedIssueNumbers,
262 resolvedFiles: gitResult.resolvedFiles,
263 };
264}
265
266/** Test-only surface. */
267export const __test = {
268 executeGitMerge,
269 applyCloseKeywords,
270};
0271