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
Blame · Line-by-line history

autopilot-ai-tasks.test.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-ai-tasks.test.tsBlame438 lines · 1 contributor
2b9055eClaude1/**
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});