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

spec-to-pr.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.

spec-to-pr.test.tsBlame596 lines · 2 contributors
23d1a81Claude1/**
950ef90Claude2 * Tests for src/lib/spec-to-pr.ts.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — no DB, no Claude. Always run.
7 * - parseFrontMatter / serialiseSpec round-trip.
8 * - specBasename derivation.
9 * - createSpecPR's fail-fast guards (no API key, empty spec).
10 *
11 * 2. End-to-end runSpecToPr — exercises the autopilot-driven flow with
12 * a fake AI client + a real bare repo on disk. Gated on HAS_DB so the
13 * DB-less sandbox still gets signal from the git-side assertions.
23d1a81Claude14 */
950ef90Claude15
16import { describe, it, expect, beforeAll, afterAll, afterEach } from "bun:test";
17import { join } from "path";
18import { rm, mkdir } from "fs/promises";
19import { eq } from "drizzle-orm";
20import {
21 createSpecPR,
22 runSpecToPr,
23 parseFrontMatter,
24 serialiseSpec,
25 specBasename,
26 markSpecShipped,
27 AI_SPEC_LABEL,
28 AI_SPEC_PR_MARKER,
29} from "../lib/spec-to-pr";
30import {
31 initBareRepo,
32 createOrUpdateFileOnBranch,
33 getBlob,
34 refExists,
35} from "../git/repository";
36import {
37 runSpecToPrTaskOnce,
38 type SpecToPrCandidate,
39} from "../lib/autopilot-spec-to-pr";
40import { db } from "../db";
41import {
42 pullRequests,
43 repositories,
44 users,
45} from "../db/schema";
46import { _resetClientForTests as resetSpecAiClient } from "../lib/spec-ai";
47
48const HAS_DB = Boolean(process.env.DATABASE_URL);
49
50const TEST_REPOS = join(
51 import.meta.dir,
52 "../../.test-repos-spec-to-pr-" + Date.now()
53);
54
55beforeAll(async () => {
56 process.env.GIT_REPOS_PATH = TEST_REPOS;
57 await rm(TEST_REPOS, { recursive: true, force: true });
58 await mkdir(TEST_REPOS, { recursive: true });
59});
60
61afterAll(async () => {
62 await rm(TEST_REPOS, { recursive: true, force: true });
63});
64
65// ---------------------------------------------------------------------------
66// createSpecPR — fail-fast guards (DB-less)
67// ---------------------------------------------------------------------------
68
69describe("createSpecPR — guards", () => {
14c3cc8Claude70 const originalKey = process.env.ANTHROPIC_API_KEY;
71
72 afterEach(() => {
73 if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
74 else process.env.ANTHROPIC_API_KEY = originalKey;
75 });
76
77 it("returns ok:false when ANTHROPIC_API_KEY is missing", async () => {
78 delete process.env.ANTHROPIC_API_KEY;
23d1a81Claude79 const result = await createSpecPR({
80 repoId: "00000000-0000-0000-0000-000000000000",
81 spec: "test",
82 userId: "00000000-0000-0000-0000-000000000000",
83 });
14c3cc8Claude84 expect(result.ok).toBe(false);
23d1a81Claude85 if (!result.ok) {
86 expect(result.error).toContain("ANTHROPIC_API_KEY");
87 }
14c3cc8Claude88 });
89
23d1a81Claude90 it("returns ok:false when spec is empty", async () => {
ea52715copilot-swe-agent[bot]91 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
23d1a81Claude92 const result = await createSpecPR({
93 repoId: "00000000-0000-0000-0000-000000000000",
94 spec: " ",
95 userId: "00000000-0000-0000-0000-000000000000",
96 });
14c3cc8Claude97 expect(result.ok).toBe(false);
23d1a81Claude98 if (!result.ok) {
99 expect(result.error).toContain("empty");
100 }
14c3cc8Claude101 });
102});
950ef90Claude103
104// ---------------------------------------------------------------------------
105// Pure front-matter helpers
106// ---------------------------------------------------------------------------
107
108describe("parseFrontMatter", () => {
109 it("returns empty front-matter when the document has none", () => {
110 const out = parseFrontMatter("just a body");
111 expect(out.hasFrontMatter).toBe(false);
112 expect(out.body).toBe("just a body");
113 expect(out.frontMatter).toEqual({});
114 });
115
116 it("parses simple key: value pairs", () => {
117 const out = parseFrontMatter("---\ntitle: Add dark mode\nstatus: ready\n---\nbody here");
118 expect(out.hasFrontMatter).toBe(true);
119 expect(out.frontMatter.title).toBe("Add dark mode");
120 expect(out.frontMatter.status).toBe("ready");
121 expect(out.body).toBe("body here");
122 });
123
124 it("strips matched surrounding quotes", () => {
125 const out = parseFrontMatter('---\ntitle: "Quoted: title"\n---\nbody');
126 expect(out.frontMatter.title).toBe("Quoted: title");
127 });
128
129 it("tolerates CRLF line endings", () => {
130 const out = parseFrontMatter("---\r\ntitle: T\r\nstatus: ready\r\n---\r\nbody");
131 expect(out.frontMatter.title).toBe("T");
132 expect(out.frontMatter.status).toBe("ready");
133 });
134});
135
136describe("serialiseSpec", () => {
137 it("round-trips parsed front-matter", () => {
138 const raw = "---\ntitle: Add dark mode\nstatus: ready\n---\nbody here\n";
139 const parsed = parseFrontMatter(raw);
140 const out = serialiseSpec(parsed.frontMatter, parsed.body);
141 const reparsed = parseFrontMatter(out);
142 expect(reparsed.frontMatter.title).toBe("Add dark mode");
143 expect(reparsed.frontMatter.status).toBe("ready");
144 expect(reparsed.body.trim()).toBe("body here");
145 });
146
147 it("quotes values containing colons", () => {
148 const out = serialiseSpec({ title: "Foo: bar" }, "body");
149 // The quoted form must round-trip back to the same string.
150 const reparsed = parseFrontMatter(out);
151 expect(reparsed.frontMatter.title).toBe("Foo: bar");
152 });
153});
154
155describe("specBasename", () => {
156 it("strips the directory and .md extension", () => {
157 expect(specBasename(".gluecron/specs/foo-bar.md")).toBe("foo-bar");
158 });
159
160 it("falls back to 'spec' for unusable input", () => {
161 expect(specBasename(".gluecron/specs/!!!.md")).toBe("spec");
162 });
163});
164
165// ---------------------------------------------------------------------------
166// runSpecToPr — guard short-circuits (DB-less)
167// ---------------------------------------------------------------------------
168
169describe("runSpecToPr — guards", () => {
170 const originalKey = process.env.ANTHROPIC_API_KEY;
171 const originalDisabled = process.env.AUTOPILOT_DISABLED;
172
173 afterEach(() => {
174 if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
175 else process.env.ANTHROPIC_API_KEY = originalKey;
176 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
177 else process.env.AUTOPILOT_DISABLED = originalDisabled;
178 });
179
180 it("short-circuits when AUTOPILOT_DISABLED=1", async () => {
181 process.env.AUTOPILOT_DISABLED = "1";
182 process.env.ANTHROPIC_API_KEY = "x";
183 const result = await runSpecToPr({
184 repositoryId: "00000000-0000-0000-0000-000000000000",
185 specPath: ".gluecron/specs/foo.md",
186 });
187 expect(result.ok).toBe(false);
188 if (!result.ok) expect(result.error).toContain("AUTOPILOT_DISABLED");
189 });
190
191 it("short-circuits when ANTHROPIC_API_KEY is missing", async () => {
192 delete process.env.AUTOPILOT_DISABLED;
193 delete process.env.ANTHROPIC_API_KEY;
194 const result = await runSpecToPr({
195 repositoryId: "00000000-0000-0000-0000-000000000000",
196 specPath: ".gluecron/specs/foo.md",
197 });
198 expect(result.ok).toBe(false);
199 if (!result.ok) expect(result.error).toContain("ANTHROPIC_API_KEY");
200 });
201
202 it("rejects paths outside .gluecron/specs/", async () => {
203 delete process.env.AUTOPILOT_DISABLED;
204 process.env.ANTHROPIC_API_KEY = "x";
205 const result = await runSpecToPr({
206 repositoryId: "00000000-0000-0000-0000-000000000000",
207 specPath: "src/evil.md",
208 });
209 expect(result.ok).toBe(false);
210 if (!result.ok) expect(result.error).toContain(".gluecron/specs");
211 });
212
213 it("rejects non-.md paths", async () => {
214 delete process.env.AUTOPILOT_DISABLED;
215 process.env.ANTHROPIC_API_KEY = "x";
216 const result = await runSpecToPr({
217 repositoryId: "00000000-0000-0000-0000-000000000000",
218 specPath: ".gluecron/specs/foo.txt",
219 });
220 expect(result.ok).toBe(false);
221 if (!result.ok) expect(result.error).toContain(".md");
222 });
223});
224
225// ---------------------------------------------------------------------------
226// Autopilot task — short-circuit + dependency-injection coverage (DB-less)
227// ---------------------------------------------------------------------------
228
229describe("runSpecToPrTaskOnce", () => {
230 const originalKey = process.env.ANTHROPIC_API_KEY;
231 const originalDisabled = process.env.AUTOPILOT_DISABLED;
232
233 afterEach(() => {
234 if (originalKey === undefined) delete process.env.ANTHROPIC_API_KEY;
235 else process.env.ANTHROPIC_API_KEY = originalKey;
236 if (originalDisabled === undefined) delete process.env.AUTOPILOT_DISABLED;
237 else process.env.AUTOPILOT_DISABLED = originalDisabled;
238 });
239
240 it("no-ops when AUTOPILOT_DISABLED=1", async () => {
241 process.env.AUTOPILOT_DISABLED = "1";
242 process.env.ANTHROPIC_API_KEY = "x";
243 let called = 0;
244 const out = await runSpecToPrTaskOnce({
245 findCandidates: async () => {
246 called += 1;
247 return [];
248 },
249 });
250 expect(out).toEqual({ considered: 0, dispatched: 0, skipped: 0, failed: 0 });
251 expect(called).toBe(0);
252 });
253
254 it("no-ops when ANTHROPIC_API_KEY is unset", async () => {
255 delete process.env.AUTOPILOT_DISABLED;
256 delete process.env.ANTHROPIC_API_KEY;
257 let called = 0;
258 const out = await runSpecToPrTaskOnce({
259 findCandidates: async () => {
260 called += 1;
261 return [];
262 },
263 });
264 expect(out.dispatched).toBe(0);
265 expect(called).toBe(0);
266 });
267
268 it("dispatches a ready spec exactly once and counts the result", async () => {
269 delete process.env.AUTOPILOT_DISABLED;
270 process.env.ANTHROPIC_API_KEY = "x";
271 const cand: SpecToPrCandidate = {
272 repositoryId: "repo-1",
273 ownerName: "alice",
274 repoName: "demo",
275 defaultBranch: "main",
276 specPath: ".gluecron/specs/foo.md",
277 };
278 let dispatchCount = 0;
279 const out = await runSpecToPrTaskOnce({
280 findCandidates: async () => [cand],
281 hasOpenLinkedPr: async () => false,
282 dispatcher: async () => {
283 dispatchCount += 1;
284 return { ok: true, branch: "ai-spec/foo-1", prNumber: 7, status: "building" };
285 },
286 });
287 expect(dispatchCount).toBe(1);
288 expect(out.dispatched).toBe(1);
289 expect(out.skipped).toBe(0);
290 expect(out.failed).toBe(0);
291 expect(out.considered).toBe(1);
292 });
293
294 it("skips a spec whose PR already exists", async () => {
295 delete process.env.AUTOPILOT_DISABLED;
296 process.env.ANTHROPIC_API_KEY = "x";
297 let dispatched = 0;
298 const out = await runSpecToPrTaskOnce({
299 findCandidates: async () => [
300 {
301 repositoryId: "r",
302 ownerName: "alice",
303 repoName: "demo",
304 defaultBranch: "main",
305 specPath: ".gluecron/specs/foo.md",
306 },
307 ],
308 hasOpenLinkedPr: async () => true,
309 dispatcher: async () => {
310 dispatched += 1;
311 return { ok: true, branch: "x", prNumber: 1, status: "building" };
312 },
313 });
314 expect(dispatched).toBe(0);
315 expect(out.skipped).toBe(1);
316 expect(out.dispatched).toBe(0);
317 });
318
319 it("counts dispatcher failures separately from skips", async () => {
320 delete process.env.AUTOPILOT_DISABLED;
321 process.env.ANTHROPIC_API_KEY = "x";
322 const out = await runSpecToPrTaskOnce({
323 findCandidates: async () => [
324 {
325 repositoryId: "r",
326 ownerName: "alice",
327 repoName: "demo",
328 defaultBranch: "main",
329 specPath: ".gluecron/specs/foo.md",
330 },
331 ],
332 hasOpenLinkedPr: async () => false,
333 dispatcher: async () => ({ ok: false, error: "boom" }),
334 });
335 expect(out.failed).toBe(1);
336 expect(out.dispatched).toBe(0);
337 });
338});
339
340// ---------------------------------------------------------------------------
341// End-to-end runSpecToPr — fake Claude + real bare repo
342// ---------------------------------------------------------------------------
343
344/**
345 * Build a fake global `fetch` that returns the canned Claude JSON envelope.
346 * The Anthropic SDK calls `fetch` under the hood, so swapping it lets us
347 * exercise the full pipeline without an API key or network.
348 */
349function withFakeAnthropic<T>(
350 responseText: string,
351 fn: () => Promise<T>
352): Promise<T> {
353 const originalFetch = globalThis.fetch;
354 // Reset the cached Anthropic client so it captures the stubbed fetch.
355 resetSpecAiClient();
356 (globalThis as any).fetch = async () => {
357 return new Response(
358 JSON.stringify({
359 id: "msg_test",
360 type: "message",
361 role: "assistant",
362 model: "claude-sonnet-4-6",
363 content: [{ type: "text", text: responseText }],
364 stop_reason: "end_turn",
365 usage: { input_tokens: 1, output_tokens: 1 },
366 }),
367 {
368 status: 200,
369 headers: { "content-type": "application/json" },
370 }
371 );
372 };
373 return fn().finally(() => {
374 globalThis.fetch = originalFetch;
375 resetSpecAiClient();
376 });
377}
378
379describe.skipIf(!HAS_DB)("runSpecToPr — end-to-end with fake Claude", () => {
380 it(
381 "opens a PR, tags it, and rewrites the spec status to building",
382 async () => {
383 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
384 delete process.env.AUTOPILOT_DISABLED;
385
386 const username = `spectopr_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
387 const [u] = await db
388 .insert(users)
389 .values({
390 username,
391 email: `${username}@example.com`,
392 passwordHash: "x",
393 })
394 .returning({ id: users.id });
395
396 const repoName = `subject_${Date.now()}`;
397 const [r] = await db
398 .insert(repositories)
399 .values({
400 ownerId: u.id,
401 name: repoName,
402 diskPath: `/tmp/${username}/${repoName}`,
403 defaultBranch: "main",
404 })
405 .returning({ id: repositories.id });
406
407 await initBareRepo(username, repoName);
408
409 // Seed a minimal repo: a source file + a ready spec.
410 const seedSrc = await createOrUpdateFileOnBranch({
411 owner: username,
412 name: repoName,
413 branch: "main",
414 filePath: "src/app.ts",
415 bytes: new TextEncoder().encode("export const hello = 'world';\n"),
416 message: "seed src",
417 authorName: "Seeder",
418 authorEmail: "s@e.com",
419 });
420 if ("error" in seedSrc) throw new Error("seed src failed");
421
422 const specBody =
423 "---\ntitle: Add greeting\nstatus: ready\n---\n\nAdd a `greet()` helper to src/app.ts that returns 'hi'.\n";
424 const seedSpec = await createOrUpdateFileOnBranch({
425 owner: username,
426 name: repoName,
427 branch: "main",
428 filePath: ".gluecron/specs/greet.md",
429 bytes: new TextEncoder().encode(specBody),
430 message: "seed spec",
431 authorName: "Seeder",
432 authorEmail: "s@e.com",
433 });
434 if ("error" in seedSpec) throw new Error("seed spec failed");
435
436 const cannedEdits = JSON.stringify({
437 summary: "Add greet() helper",
438 edits: [
439 {
440 action: "edit",
441 path: "src/app.ts",
442 content:
443 "export const hello = 'world';\nexport function greet(): string { return 'hi'; }\n",
444 },
445 ],
446 });
447
448 const result = await withFakeAnthropic(cannedEdits, () =>
449 runSpecToPr({
450 repositoryId: r.id,
451 specPath: ".gluecron/specs/greet.md",
452 })
453 );
454
455 expect(result.ok).toBe(true);
456 if (!result.ok) return;
457 expect(result.branch.startsWith("ai-spec/greet-")).toBe(true);
458 expect(typeof result.prNumber).toBe("number");
459 expect(result.status).toBe("building");
460
461 // Branch exists and contains the patched file.
462 expect(
463 await refExists(username, repoName, `refs/heads/${result.branch}`)
464 ).toBe(true);
465 const blob = await getBlob(username, repoName, result.branch, "src/app.ts");
466 expect(blob).not.toBeNull();
467 expect(blob!.content).toContain("greet()");
468
469 // PR row exists with the right marker + label citation.
470 const [pr] = await db
471 .select({
472 number: pullRequests.number,
473 headBranch: pullRequests.headBranch,
474 baseBranch: pullRequests.baseBranch,
475 body: pullRequests.body,
476 title: pullRequests.title,
477 })
478 .from(pullRequests)
479 .where(eq(pullRequests.repositoryId, r.id))
480 .limit(1);
481 expect(pr).toBeTruthy();
482 expect(pr!.headBranch).toBe(result.branch);
483 expect(pr!.baseBranch).toBe("main");
484 expect(pr!.title.startsWith("[spec]")).toBe(true);
485 expect(pr!.body).toContain(AI_SPEC_PR_MARKER);
486 expect(pr!.body).toContain(AI_SPEC_LABEL);
487 expect(pr!.body).toContain(".gluecron/specs/greet.md");
488
489 // The spec file should now be `status: building` on main.
490 const updatedSpec = await getBlob(
491 username,
492 repoName,
493 "main",
494 ".gluecron/specs/greet.md"
495 );
496 expect(updatedSpec).not.toBeNull();
497 const parsed = parseFrontMatter(updatedSpec!.content);
498 expect(parsed.frontMatter.status).toBe("building");
499 expect(parsed.frontMatter.pr).toBe(String(pr!.number));
500
501 // Re-running against the (now `building`) spec should NOT open another
502 // PR — both because the status is not `ready` AND because the dedup
503 // query will find the previous PR body referencing the spec path.
504 const second = await withFakeAnthropic(cannedEdits, () =>
505 runSpecToPr({
506 repositoryId: r.id,
507 specPath: ".gluecron/specs/greet.md",
508 })
509 );
510 expect(second.ok).toBe(false);
511 if (!second.ok) {
512 // Either status mismatch OR the dedup short-circuit — both are
513 // valid "doesn't re-trigger" signals.
514 expect(
515 second.error.includes("not ready") ||
516 second.error.includes("PR already exists")
517 ).toBe(true);
518 }
519
520 // Now mark shipped and re-read.
521 const shipped = await markSpecShipped({
522 ownerName: username,
523 repoName,
524 defaultBranch: "main",
525 specPath: ".gluecron/specs/greet.md",
526 prNumber: pr!.number,
527 });
528 expect(shipped.ok).toBe(true);
529 const finalSpec = await getBlob(
530 username,
531 repoName,
532 "main",
533 ".gluecron/specs/greet.md"
534 );
535 const finalParsed = parseFrontMatter(finalSpec!.content);
536 expect(finalParsed.frontMatter.status).toBe("shipped");
537 },
538 25000
539 );
540
541 it("refuses a spec whose status is not 'ready'", async () => {
542 process.env.ANTHROPIC_API_KEY = "anthropic-test-placeholder";
543 delete process.env.AUTOPILOT_DISABLED;
544
545 const username = `spectopr_draft_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
546 const [u] = await db
547 .insert(users)
548 .values({
549 username,
550 email: `${username}@example.com`,
551 passwordHash: "x",
552 })
553 .returning({ id: users.id });
554
555 const repoName = `subject_${Date.now()}`;
556 const [r] = await db
557 .insert(repositories)
558 .values({
559 ownerId: u.id,
560 name: repoName,
561 diskPath: `/tmp/${username}/${repoName}`,
562 defaultBranch: "main",
563 })
564 .returning({ id: repositories.id });
565
566 await initBareRepo(username, repoName);
567 const seed = await createOrUpdateFileOnBranch({
568 owner: username,
569 name: repoName,
570 branch: "main",
571 filePath: ".gluecron/specs/draft.md",
572 bytes: new TextEncoder().encode(
573 "---\ntitle: Draft\nstatus: draft\n---\n\nNot ready yet.\n"
574 ),
575 message: "seed draft",
576 authorName: "Seeder",
577 authorEmail: "s@e.com",
578 });
579 if ("error" in seed) throw new Error("seed failed");
580
581 // Should refuse before calling the AI — so we don't even need a fetch stub.
582 const result = await runSpecToPr({
583 repositoryId: r.id,
584 specPath: ".gluecron/specs/draft.md",
585 });
586 expect(result.ok).toBe(false);
587 if (!result.ok) expect(result.error).toContain("not ready");
588
589 // No PR should have been opened.
590 const prs = await db
591 .select({ id: pullRequests.id })
592 .from(pullRequests)
593 .where(eq(pullRequests.repositoryId, r.id));
594 expect(prs.length).toBe(0);
595 });
596});