Commit950ef90unknown_key
feat(spec-to-pr): autopilot picks up .gluecron/specs/*.md and opens implementation PRs
5 files changed+1659−15950ef904f0039654674cc0b309e190766f20ada3
5 changed files+1659−15
Modifiedsrc/__tests__/spec-to-pr.test.ts+561−7View fileUnifiedSplit
@@ -1,12 +1,72 @@
1import { describe, it, expect, afterEach } from "bun:test";
2import { createSpecPR } from "../lib/spec-to-pr";
3
41/**
5 * The real pipeline (context → AI → git → PR insert) lives in
6 * `spec-context`, `spec-ai`, and `spec-git` tests. Here we only cover the
7 * fail-fast guards that don't require DB/disk/AI.
2 * 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.
814 */
9describe("createSpecPR", () => {
15
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", () => {
1070 const originalKey = process.env.ANTHROPIC_API_KEY;
1171
1272 afterEach(() => {
@@ -40,3 +100,497 @@ describe("createSpecPR", () => {
40100 }
41101 });
42102});
103
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});
Addedsrc/lib/autopilot-spec-to-pr.ts+305−0View fileUnifiedSplit
@@ -0,0 +1,305 @@
1/**
2 * Autopilot task — spec-to-PR loop.
3 *
4 * Every tick, walk every repo the owner-user maintains and look at the
5 * files under `.gluecron/specs/*.md`. For each spec whose front-matter is
6 * `status: ready` AND that does not yet have an open PR pointing at the
7 * same spec path, dispatch `runSpecToPr`.
8 *
9 * Mirrors the dependency-injection shape of `ai-build-tasks.ts`:
10 * - `findCandidates` — surface ready specs across enabled repos.
11 * - `hasOpenLinkedPr` — short-circuit if a previous tick already opened a PR.
12 * - `dispatcher` — runs `runSpecToPr`. Tests inject a stub.
13 *
14 * Skip rules (graceful no-ops):
15 * - `AUTOPILOT_DISABLED=1` short-circuits the task entirely.
16 * - Missing `ANTHROPIC_API_KEY` short-circuits (the dispatcher would also
17 * refuse, but skipping here saves a DB round-trip per tick).
18 * - Repos flagged as archived are filtered out at the SQL layer.
19 *
20 * Never throws. Per-repo failures are logged and swallowed so one broken
21 * spec can't wedge the autopilot tick.
22 */
23
24import { and, eq, like } from "drizzle-orm";
25import { join } from "path";
26import { db } from "../db";
27import { pullRequests, repositories, users } from "../db/schema";
28import { getBlob, getTreeRecursive } from "../git/repository";
29import {
30 AI_SPEC_PR_MARKER,
31 parseFrontMatter,
32 runSpecToPr,
33 type RunSpecToPrResult,
34} from "./spec-to-pr";
35
36/** Hard cap to bound work per tick on big multi-tenant deployments. */
37const DEFAULT_MAX_SPECS_PER_TICK = 10;
38/** Per-repo cap so one busy repo can't starve the rest. */
39const DEFAULT_MAX_SPECS_PER_REPO = 3;
40
41export interface SpecToPrCandidate {
42 repositoryId: string;
43 ownerName: string;
44 repoName: string;
45 defaultBranch: string;
46 /** Path inside the repo, e.g. `.gluecron/specs/foo.md`. */
47 specPath: string;
48}
49
50export interface SpecToPrDispatcher {
51 (args: {
52 repositoryId: string;
53 specPath: string;
54 baseSha?: string;
55 }): Promise<RunSpecToPrResult>;
56}
57
58export interface SpecToPrTaskDeps {
59 /** Inject candidate finder (defaults walk every enabled repo). */
60 findCandidates?: (
61 limit: number,
62 perRepoLimit: number
63 ) => Promise<SpecToPrCandidate[]>;
64 /** Inject the dedup check (looks for any PR body referencing the spec path). */
65 hasOpenLinkedPr?: (
66 repositoryId: string,
67 specPath: string
68 ) => Promise<boolean>;
69 /** Inject the dispatcher (real one calls `runSpecToPr`). */
70 dispatcher?: SpecToPrDispatcher;
71 /** Override the per-tick cap. */
72 maxSpecsPerTick?: number;
73 /** Override the per-repo cap. */
74 maxSpecsPerRepo?: number;
75}
76
77export interface SpecToPrTaskSummary {
78 considered: number;
79 dispatched: number;
80 skipped: number;
81 failed: number;
82}
83
84/**
85 * Default candidate finder. For every non-archived repo (capped via
86 * `limit`), list `.gluecron/specs/*.md` on the default branch, parse each
87 * file's front-matter, and surface specs whose status is `ready`.
88 *
89 * We intentionally cap the per-repo result at `perRepoLimit` so one repo
90 * with hundreds of ready specs can't monopolise the tick.
91 */
92async function defaultFindCandidates(
93 limit: number,
94 perRepoLimit: number
95): Promise<SpecToPrCandidate[]> {
96 let repoRows: Array<{
97 id: string;
98 name: string;
99 defaultBranch: string;
100 ownerName: string | null;
101 }>;
102 try {
103 repoRows = await db
104 .select({
105 id: repositories.id,
106 name: repositories.name,
107 defaultBranch: repositories.defaultBranch,
108 ownerName: users.username,
109 })
110 .from(repositories)
111 .innerJoin(users, eq(users.id, repositories.ownerId))
112 .where(eq(repositories.isArchived, false))
113 .limit(200);
114 } catch (err) {
115 console.error("[autopilot] spec-to-pr: repo query failed:", err);
116 return [];
117 }
118
119 const out: SpecToPrCandidate[] = [];
120 for (const repo of repoRows) {
121 if (out.length >= limit) break;
122 if (!repo.ownerName) continue;
123 const defaultBranch = repo.defaultBranch || "main";
124 let specPaths: string[] = [];
125 try {
126 const tree = await getTreeRecursive(repo.ownerName, repo.name, defaultBranch, 5000);
127 if (!tree) continue;
128 specPaths = tree.tree
129 .filter(
130 (e) =>
131 e.type === "blob" &&
132 e.path.startsWith(".gluecron/specs/") &&
133 e.path.toLowerCase().endsWith(".md")
134 )
135 .map((e) => e.path)
136 .slice(0, perRepoLimit * 5); // headroom — we'll filter by status next
137 } catch (err) {
138 console.warn(
139 `[autopilot] spec-to-pr: tree scan failed for ${repo.ownerName}/${repo.name}:`,
140 err instanceof Error ? err.message : err
141 );
142 continue;
143 }
144
145 let perRepo = 0;
146 for (const path of specPaths) {
147 if (perRepo >= perRepoLimit) break;
148 if (out.length >= limit) break;
149 try {
150 const blob = await getBlob(
151 repo.ownerName,
152 repo.name,
153 defaultBranch,
154 path
155 );
156 if (!blob || blob.isBinary) continue;
157 const parsed = parseFrontMatter(blob.content);
158 const status = (parsed.frontMatter.status || "").toLowerCase();
159 if (status !== "ready") continue;
160 out.push({
161 repositoryId: repo.id,
162 ownerName: repo.ownerName,
163 repoName: repo.name,
164 defaultBranch,
165 specPath: path,
166 });
167 perRepo += 1;
168 } catch (err) {
169 console.warn(
170 `[autopilot] spec-to-pr: blob read failed for ${repo.ownerName}/${repo.name}:${path}:`,
171 err instanceof Error ? err.message : err
172 );
173 }
174 }
175 }
176
177 return out;
178}
179
180/**
181 * Default dedup check. We look for any PR (open or otherwise) whose body
182 * embeds the spec path; the autopilot writes the spec path into the PR
183 * body so this is a reliable signal.
184 */
185async function defaultHasOpenLinkedPr(
186 repositoryId: string,
187 specPath: string
188): Promise<boolean> {
189 try {
190 const rows = await db
191 .select({ id: pullRequests.id })
192 .from(pullRequests)
193 .where(
194 and(
195 eq(pullRequests.repositoryId, repositoryId),
196 like(pullRequests.body, `%${AI_SPEC_PR_MARKER}%`),
197 like(pullRequests.body, `%${specPath}%`)
198 )
199 )
200 .limit(1);
201 return rows.length > 0;
202 } catch {
203 return false;
204 }
205}
206
207/** Default dispatcher just calls `runSpecToPr`. */
208async function defaultDispatcher(args: {
209 repositoryId: string;
210 specPath: string;
211 baseSha?: string;
212}): Promise<RunSpecToPrResult> {
213 try {
214 return await runSpecToPr(args);
215 } catch (err) {
216 return {
217 ok: false,
218 error: err instanceof Error ? err.message : String(err),
219 };
220 }
221}
222
223/**
224 * One iteration of the spec-to-pr loop. Returns a counts summary suitable
225 * for the autopilot tick log. Never throws.
226 */
227export async function runSpecToPrTaskOnce(
228 deps: SpecToPrTaskDeps = {}
229): Promise<SpecToPrTaskSummary> {
230 // Graceful gates — keep the work-skip cheap.
231 if (process.env.AUTOPILOT_DISABLED === "1") {
232 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
233 }
234 if (!process.env.ANTHROPIC_API_KEY) {
235 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
236 }
237
238 const limit = deps.maxSpecsPerTick ?? DEFAULT_MAX_SPECS_PER_TICK;
239 const perRepoLimit = deps.maxSpecsPerRepo ?? DEFAULT_MAX_SPECS_PER_REPO;
240 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
241 const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr;
242 const dispatcher = deps.dispatcher ?? defaultDispatcher;
243
244 let candidates: SpecToPrCandidate[] = [];
245 try {
246 candidates = await findCandidates(limit, perRepoLimit);
247 } catch (err) {
248 console.error("[autopilot] spec-to-pr: findCandidates threw:", err);
249 return { considered: 0, dispatched: 0, skipped: 0, failed: 0 };
250 }
251
252 let dispatched = 0;
253 let skipped = 0;
254 let failed = 0;
255
256 for (const cand of candidates) {
257 try {
258 if (await hasOpenLinkedPr(cand.repositoryId, cand.specPath)) {
259 skipped += 1;
260 continue;
261 }
262 const result = await dispatcher({
263 repositoryId: cand.repositoryId,
264 specPath: cand.specPath,
265 });
266 if (result.ok) {
267 dispatched += 1;
268 } else {
269 failed += 1;
270 console.warn(
271 `[autopilot] spec-to-pr: dispatch failed for ${cand.ownerName}/${cand.repoName}:${cand.specPath}: ${result.error}`
272 );
273 }
274 } catch (err) {
275 failed += 1;
276 console.error(
277 `[autopilot] spec-to-pr: per-spec failure for ${cand.specPath}:`,
278 err
279 );
280 }
281 }
282
283 return {
284 considered: candidates.length,
285 dispatched,
286 skipped,
287 failed,
288 };
289}
290
291// Re-export the disk-path helper so callers can build the same paths the
292// task computes internally — useful in admin tooling / observability.
293export function repoDiskPath(ownerName: string, repoName: string): string {
294 const base = process.env.GIT_REPOS_PATH || "./repos";
295 return join(base, ownerName, `${repoName}.git`);
296}
297
298/** Test-only exports. */
299export const __test = {
300 defaultFindCandidates,
301 defaultHasOpenLinkedPr,
302 defaultDispatcher,
303 DEFAULT_MAX_SPECS_PER_TICK,
304 DEFAULT_MAX_SPECS_PER_REPO,
305};
Modifiedsrc/lib/autopilot.ts+26−3View fileUnifiedSplit
@@ -60,6 +60,7 @@ import {
6060 runDailyStandupTaskOnce,
6161 runWeeklyStandupTaskOnce,
6262} from "./ai-standup";
63import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
6364
6465export interface AutopilotTaskResult {
6566 name: string;
@@ -105,6 +106,9 @@ const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
105106/** Proactive monitor cadence — Claude scans platform telemetry hourly. */
106107const PROACTIVE_MONITOR_INTERVAL_MS = 60 * 60 * 1000;
107108let _lastProactiveMonitorAt = 0;
109/** Spec-to-PR cadence — autopilot scans `.gluecron/specs/*.md` every 2 minutes. */
110const SPEC_TO_PR_INTERVAL_MS = 2 * 60 * 1000;
111let _lastSpecToPrAt = 0;
108112
109113/**
110114 * Default task set. Each task is a thin wrapper around an existing locked
@@ -271,9 +275,7 @@ export function defaultTasks(): AutopilotTask[] {
271275 // AI CI Healer — autonomous CI failure → root-cause → patch PR loop.
272276 // Polls every tick (5 min) for failed workflow_runs that finished
273277 // at least HEAL_MIN_AGE_MS ago and haven't been processed yet.
274 // Skips when ANTHROPIC_API_KEY is unset (handled inside the lib);
275 // AUTOPILOT_DISABLED=1 short-circuits the wrapping startAutopilot
276 // call already, but the lib double-checks for direct callers.
278 // Skips when ANTHROPIC_API_KEY is unset.
277279 name: "ci-healer",
278280 run: async () => {
279281 if (!process.env.ANTHROPIC_API_KEY) return;
@@ -287,6 +289,27 @@ export function defaultTasks(): AutopilotTask[] {
287289 }
288290 },
289291 },
292 {
293 // Spec-to-PR autopilot — picks up `.gluecron/specs/*.md` files whose
294 // front-matter status is `ready`, asks Claude to implement the spec,
295 // opens a draft PR tagged `ai:spec-implementation`. Cadence-gated
296 // to every 2 minutes.
297 name: "spec-to-pr",
298 run: async () => {
299 if (!process.env.ANTHROPIC_API_KEY) return;
300 const now = Date.now();
301 if (now - _lastSpecToPrAt < SPEC_TO_PR_INTERVAL_MS) return;
302 _lastSpecToPrAt = now;
303 try {
304 const summary = await runSpecToPrTaskOnce();
305 console.log(
306 `[autopilot] spec-to-pr: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
307 );
308 } catch (err) {
309 console.error("[autopilot] spec-to-pr: threw:", err);
310 }
311 },
312 },
290313 {
291314 // BLOCK S4 — Synthetic monitor.
292315 //
Modifiedsrc/lib/spec-to-pr.ts+522−2View fileUnifiedSplit
@@ -16,12 +16,23 @@
1616 * — the route (`src/routes/specs.tsx`) renders the error inline.
1717 */
1818import { join } from "path";
19import { eq } from "drizzle-orm";
19import { and, eq, like } from "drizzle-orm";
2020import { db } from "../db";
21import { repositories, users, pullRequests } from "../db/schema";
21import {
22 repositories,
23 users,
24 pullRequests,
25 labels,
26 prComments,
27} from "../db/schema";
2228import { buildSpecContext } from "./spec-context";
2329import { generateSpecEdits } from "./spec-ai";
2430import { applyEditsToNewBranch } from "./spec-git";
31import {
32 getBlob,
33 createOrUpdateFileOnBranch,
34 getTreeRecursive,
35} from "../git/repository";
2536
2637export type SpecPRArgs = {
2738 repoId: string;
@@ -183,3 +194,512 @@ export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
183194 };
184195 }
185196}
197
198// ---------------------------------------------------------------------------
199// runSpecToPr — autopilot-driven spec-file flow.
200//
201// Reads a spec file living at `.gluecron/specs/<name>.md` inside the repo,
202// parses its YAML-style front-matter, and (when `status: ready`) generates a
203// PR off the same context/AI/git pipeline as `createSpecPR`. After the PR is
204// opened the spec file is rewritten with `status: building`. After successful
205// completion the file is rewritten to `status: shipped` (with the new PR
206// number recorded). Failures rewrite to `status: failed` with the error.
207//
208// Distinct from `createSpecPR` in two ways:
209// 1. Spec text lives in the repo, not a route body — supplied via `specPath`.
210// 2. PRs are tagged with the `ai:spec-implementation` label (created on
211// demand on the repo for parity with the existing `ai:proposed-patch`
212// label flow in `ai-patch-generator.ts`).
213// ---------------------------------------------------------------------------
214
215/** Label name surfaced (and ensured present) for spec-driven PRs. */
216export const AI_SPEC_LABEL = "ai:spec-implementation";
217
218/** Marker baked into the PR body so the autopilot loop can detect "already implemented". */
219export const AI_SPEC_PR_MARKER = "<!-- gluecron:ai-spec-implementation:v1 -->";
220
221export type SpecStatus = "draft" | "ready" | "building" | "shipped" | "failed";
222
223export interface ParsedSpec {
224 frontMatter: Record<string, string>;
225 body: string;
226 raw: string;
227 /** True when the document opened with a `---` fence. */
228 hasFrontMatter: boolean;
229}
230
231export interface RunSpecToPrArgs {
232 repositoryId: string;
233 /** Path to the spec markdown inside the repo, e.g. `.gluecron/specs/foo.md`. */
234 specPath: string;
235 /** Optional base sha override. Falls back to repo default branch HEAD. */
236 baseSha?: string;
237}
238
239export type RunSpecToPrResult =
240 | { ok: true; branch: string; prNumber: number; status: SpecStatus }
241 | { ok: false; error: string; status?: SpecStatus };
242
243/**
244 * Parse YAML-style front-matter. Intentionally tiny — just `key: value` pairs.
245 * Anything we can't parse is treated as an absent front-matter block.
246 */
247export function parseFrontMatter(raw: string): ParsedSpec {
248 if (typeof raw !== "string" || !raw) {
249 return { frontMatter: {}, body: "", raw: raw || "", hasFrontMatter: false };
250 }
251 const fenced = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
252 if (!fenced) {
253 return { frontMatter: {}, body: raw, raw, hasFrontMatter: false };
254 }
255 const block = fenced[1];
256 const body = fenced[2] || "";
257 const fm: Record<string, string> = {};
258 for (const line of block.split(/\r?\n/)) {
259 const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
260 if (!m) continue;
261 let value = m[2].trim();
262 // Strip matched quotes around the value.
263 if (
264 (value.startsWith('"') && value.endsWith('"')) ||
265 (value.startsWith("'") && value.endsWith("'"))
266 ) {
267 value = value.slice(1, -1);
268 }
269 fm[m[1]] = value;
270 }
271 return { frontMatter: fm, body, raw, hasFrontMatter: true };
272}
273
274/**
275 * Serialise the spec back to a markdown document with the supplied
276 * front-matter map. Keys are emitted in the order returned by `Object.keys`
277 * which preserves insertion order in JS.
278 */
279export function serialiseSpec(
280 frontMatter: Record<string, string>,
281 body: string
282): string {
283 const lines: string[] = ["---"];
284 for (const [k, v] of Object.entries(frontMatter)) {
285 const val = String(v ?? "");
286 // Quote values that contain `:` or leading/trailing whitespace so the
287 // round-trip stays parseable.
288 const needsQuote = /[:#]/.test(val) || /^\s|\s$/.test(val);
289 lines.push(`${k}: ${needsQuote ? JSON.stringify(val) : val}`);
290 }
291 lines.push("---");
292 // Always end the front-matter with a single newline before the body, and
293 // preserve the body verbatim (callers pass body that already starts with
294 // a newline or content).
295 const trimmedBody = body.startsWith("\n") ? body.slice(1) : body;
296 return `${lines.join("\n")}\n${trimmedBody}`;
297}
298
299/**
300 * Read a spec file from the repo via git plumbing. Returns null when the
301 * file is missing or binary. Failures are funnelled into `ok:false`.
302 */
303async function readSpecFile(
304 ownerName: string,
305 repoName: string,
306 ref: string,
307 specPath: string
308): Promise<{ ok: true; content: string } | { ok: false; error: string }> {
309 try {
310 const blob = await getBlob(ownerName, repoName, ref, specPath);
311 if (!blob) return { ok: false, error: "spec file not found" };
312 if (blob.isBinary) return { ok: false, error: "spec file is binary" };
313 return { ok: true, content: blob.content };
314 } catch (err) {
315 return {
316 ok: false,
317 error: `getBlob failed: ${err instanceof Error ? err.message : String(err)}`,
318 };
319 }
320}
321
322/**
323 * Rewrite the spec file on the repo's default branch with an updated
324 * front-matter status. Best-effort — failures are logged but do not block
325 * the PR from being opened.
326 */
327async function updateSpecStatus(args: {
328 ownerName: string;
329 repoName: string;
330 branch: string;
331 specPath: string;
332 current: ParsedSpec;
333 status: SpecStatus;
334 extra?: Record<string, string>;
335 authorName: string;
336 authorEmail: string;
337}): Promise<{ ok: true; commitSha: string } | { ok: false; error: string }> {
338 const fm: Record<string, string> = { ...args.current.frontMatter };
339 fm.status = args.status;
340 if (args.extra) {
341 for (const [k, v] of Object.entries(args.extra)) fm[k] = v;
342 }
343 const newRaw = args.current.hasFrontMatter
344 ? serialiseSpec(fm, args.current.body)
345 : serialiseSpec(fm, args.current.raw);
346
347 const res = await createOrUpdateFileOnBranch({
348 owner: args.ownerName,
349 name: args.repoName,
350 branch: args.branch,
351 filePath: args.specPath,
352 bytes: new TextEncoder().encode(newRaw),
353 message: `chore(spec): mark ${args.specPath} as ${args.status}`,
354 authorName: args.authorName,
355 authorEmail: args.authorEmail,
356 });
357 if ("error" in res) return { ok: false, error: res.error };
358 return { ok: true, commitSha: res.commitSha };
359}
360
361/**
362 * Ensure the `ai:spec-implementation` label row exists on the repo.
363 * Best-effort — failures are swallowed.
364 */
365async function ensureSpecLabel(repositoryId: string): Promise<void> {
366 try {
367 await db
368 .insert(labels)
369 .values({
370 repositoryId,
371 name: AI_SPEC_LABEL,
372 color: "#36c5d6",
373 description:
374 "PR auto-opened by spec-to-PR autopilot from a .gluecron/specs/*.md file",
375 })
376 .onConflictDoNothing?.();
377 } catch {
378 /* ignore — label is a UX nicety, not load-bearing */
379 }
380}
381
382/**
383 * Derive a slug from the spec path's basename. `.gluecron/specs/foo-bar.md`
384 * → `foo-bar`.
385 */
386export function specBasename(specPath: string): string {
387 const last = specPath.split("/").pop() || specPath;
388 const noExt = last.replace(/\.md$/i, "");
389 return noExt.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "spec";
390}
391
392/**
393 * Driver entry point used by both the autopilot loop and direct callers.
394 * Returns the new branch/PR on success, or a structured error on failure.
395 * Never throws.
396 */
397export async function runSpecToPr(
398 args: RunSpecToPrArgs
399): Promise<RunSpecToPrResult> {
400 // 1. Hard gates. We short-circuit fast when the platform is configured
401 // to skip AI work, so callers (autopilot) don't fan out unnecessary
402 // DB / git reads.
403 if (process.env.AUTOPILOT_DISABLED === "1") {
404 return { ok: false, error: "AUTOPILOT_DISABLED" };
405 }
406 if (!process.env.ANTHROPIC_API_KEY) {
407 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
408 }
409 if (!args.specPath || !args.specPath.endsWith(".md")) {
410 return { ok: false, error: "specPath must be a .md file" };
411 }
412 if (
413 args.specPath.includes("..") ||
414 args.specPath.startsWith("/") ||
415 !args.specPath.startsWith(".gluecron/specs/")
416 ) {
417 return {
418 ok: false,
419 error: "specPath must live under .gluecron/specs/",
420 };
421 }
422
423 // 2. Resolve repo + owner.
424 let repoRow:
425 | {
426 id: string;
427 name: string;
428 defaultBranch: string;
429 ownerId: string;
430 ownerName: string | null;
431 }
432 | undefined;
433 try {
434 const rows = await db
435 .select({
436 id: repositories.id,
437 name: repositories.name,
438 defaultBranch: repositories.defaultBranch,
439 ownerId: repositories.ownerId,
440 ownerName: users.username,
441 })
442 .from(repositories)
443 .leftJoin(users, eq(users.id, repositories.ownerId))
444 .where(eq(repositories.id, args.repositoryId))
445 .limit(1);
446 repoRow = rows[0];
447 } catch {
448 return { ok: false, error: "db lookup failed" };
449 }
450 if (!repoRow || !repoRow.ownerName) {
451 return { ok: false, error: "repo not found" };
452 }
453
454 const ownerName = repoRow.ownerName;
455 const repoName = repoRow.name;
456 const defaultBranch = repoRow.defaultBranch || "main";
457 const baseRef = args.baseSha && args.baseSha.trim() ? args.baseSha : defaultBranch;
458
459 // 3. Read the spec file at the base ref.
460 const specRead = await readSpecFile(ownerName, repoName, baseRef, args.specPath);
461 if (!specRead.ok) return { ok: false, error: specRead.error };
462 const parsed = parseFrontMatter(specRead.content);
463
464 const status = (parsed.frontMatter.status || "draft").toLowerCase() as SpecStatus;
465 if (status !== "ready") {
466 return {
467 ok: false,
468 error: `spec status is ${status}, not ready`,
469 status,
470 };
471 }
472
473 const title =
474 (parsed.frontMatter.title && parsed.frontMatter.title.trim()) ||
475 specBasename(args.specPath);
476 const specBody = parsed.body.trim() || parsed.raw.trim();
477 if (!specBody) return { ok: false, error: "spec body is empty" };
478
479 // 4. Idempotency: skip if a PR already exists referencing this spec path.
480 try {
481 const existing = await db
482 .select({ number: pullRequests.number })
483 .from(pullRequests)
484 .where(
485 and(
486 eq(pullRequests.repositoryId, repoRow.id),
487 like(pullRequests.body, `%${args.specPath}%`)
488 )
489 )
490 .limit(1);
491 if (existing.length > 0) {
492 return {
493 ok: false,
494 error: `PR already exists for this spec (#${existing[0].number})`,
495 status: "building",
496 };
497 }
498 } catch {
499 // Non-fatal — if the dedup query failed, fall through.
500 }
501
502 // 5. Build context + ask Claude for edits. Reuse the existing helpers so
503 // we don't fork the prompt surface.
504 const base = process.env.GIT_REPOS_PATH || "./repos";
505 const repoDiskPath = join(base, ownerName, `${repoName}.git`);
506
507 const ctx = await buildSpecContext({
508 repoDiskPath,
509 spec: specBody,
510 defaultBranch: baseRef,
511 });
512 if (!ctx.ok) {
513 return { ok: false, error: `context build failed: ${ctx.error}` };
514 }
515
516 // Augment the context's file list with a recursive listing so Claude
517 // sees the whole tree (capped). This makes spec-driven runs more
518 // robust against monorepo layouts than the default 500-line list.
519 try {
520 const recursive = await getTreeRecursive(ownerName, repoName, baseRef, 1500);
521 if (recursive && recursive.tree.length > 0) {
522 const blobPaths = recursive.tree
523 .filter((e) => e.type === "blob")
524 .map((e) => e.path);
525 // Replace the file list with the recursive view, but keep it bounded.
526 ctx.context.fileList = blobPaths.slice(0, 500);
527 }
528 } catch {
529 /* keep the original list — recursive scan is a nice-to-have */
530 }
531
532 const ai = await generateSpecEdits({
533 spec: specBody,
534 fileList: ctx.context.fileList,
535 relevantFiles: ctx.context.relevantFiles,
536 defaultBranch: ctx.context.defaultBranch,
537 });
538 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
539 if (ai.edits.length === 0) {
540 return { ok: false, error: "AI proposed no changes" };
541 }
542
543 // 6. Apply edits to a new branch.
544 const branchName = `ai-spec/${specBasename(args.specPath)}-${Date.now()}`;
545 const authorName = "Gluecron Autopilot";
546 const authorEmail = "autopilot@gluecron.com";
547 const commitSubject = ai.summary || `spec: ${title}`.slice(0, 80);
548 const commitBody = `Generated by spec-to-PR autopilot.\n\nSpec: ${args.specPath}\n\n${specBody}`;
549 const commitMessage = `${commitSubject}\n\n${commitBody}`;
550
551 const applied = await applyEditsToNewBranch({
552 repoDiskPath,
553 baseRef,
554 edits: ai.edits,
555 branchName,
556 commitMessage,
557 authorName,
558 authorEmail,
559 });
560 if (!applied.ok) {
561 return { ok: false, error: `git apply failed: ${applied.error}` };
562 }
563
564 // 7. Ensure the spec-implementation label exists on the repo.
565 await ensureSpecLabel(repoRow.id);
566
567 // 8. Insert the PR row.
568 let prNumber: number | null = null;
569 let prId: string | null = null;
570 try {
571 const quotedSpec = specBody
572 .split("\n")
573 .map((l) => `> ${l}`)
574 .join("\n");
575 const filesList = applied.filesChanged.map((p) => `- \`${p}\``).join("\n");
576 const body = [
577 AI_SPEC_PR_MARKER,
578 `## Spec-to-PR — \`${args.specPath}\``,
579 "",
580 "### Spec",
581 quotedSpec,
582 "",
583 "### Summary of changes",
584 ai.summary || "_(no summary provided)_",
585 "",
586 "### Files changed",
587 filesList || "_(none)_",
588 "",
589 "---",
590 "",
591 `Spec path: \`${args.specPath}\``,
592 `Label: \`${AI_SPEC_LABEL}\``,
593 "",
594 "_Auto-generated by Gluecron spec-to-PR autopilot. Review every line before merging._",
595 ].join("\n");
596
597 const [pr] = await db
598 .insert(pullRequests)
599 .values({
600 repositoryId: repoRow.id,
601 authorId: repoRow.ownerId,
602 title: `[spec] ${title}`.slice(0, 200),
603 body,
604 baseBranch: defaultBranch,
605 headBranch: applied.branchName,
606 isDraft: true,
607 })
608 .returning({ number: pullRequests.number, id: pullRequests.id });
609 if (pr) {
610 prNumber = pr.number;
611 prId = pr.id;
612 }
613 } catch (err) {
614 return {
615 ok: false,
616 error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`,
617 };
618 }
619
620 if (prNumber == null || prId == null) {
621 return { ok: false, error: "PR insert returned no row" };
622 }
623
624 // 9. Drop a marker comment surfacing the label (mirrors ai-patch-generator).
625 try {
626 await db.insert(prComments).values({
627 pullRequestId: prId,
628 authorId: repoRow.ownerId,
629 isAiReview: true,
630 body: `${AI_SPEC_PR_MARKER}\nApplied label: \`${AI_SPEC_LABEL}\``,
631 });
632 } catch {
633 /* best-effort */
634 }
635
636 // 10. Rewrite the spec file to `status: building` so the autopilot loop
637 // doesn't re-pick it on the next tick. Best-effort.
638 try {
639 await updateSpecStatus({
640 ownerName,
641 repoName,
642 branch: defaultBranch,
643 specPath: args.specPath,
644 current: parsed,
645 status: "building",
646 extra: { pr: String(prNumber) },
647 authorName,
648 authorEmail,
649 });
650 } catch {
651 /* status update is non-fatal */
652 }
653
654 return {
655 ok: true,
656 branch: applied.branchName,
657 prNumber,
658 status: "building",
659 };
660}
661
662/**
663 * Mark a spec file as `shipped` once its PR is merged. Called from the PR
664 * merge webhook / merge handler in a follow-up wiring (kept here so the
665 * autopilot loop and the merge path use the same writer).
666 */
667export async function markSpecShipped(args: {
668 ownerName: string;
669 repoName: string;
670 defaultBranch: string;
671 specPath: string;
672 prNumber: number;
673}): Promise<{ ok: true } | { ok: false; error: string }> {
674 const read = await readSpecFile(
675 args.ownerName,
676 args.repoName,
677 args.defaultBranch,
678 args.specPath
679 );
680 if (!read.ok) return { ok: false, error: read.error };
681 const parsed = parseFrontMatter(read.content);
682 const res = await updateSpecStatus({
683 ownerName: args.ownerName,
684 repoName: args.repoName,
685 branch: args.defaultBranch,
686 specPath: args.specPath,
687 current: parsed,
688 status: "shipped",
689 extra: { pr: String(args.prNumber) },
690 authorName: "Gluecron Autopilot",
691 authorEmail: "autopilot@gluecron.com",
692 });
693 if (!res.ok) return { ok: false, error: res.error };
694 return { ok: true };
695}
696
697/** Test-only exports of internal helpers. */
698export const __specTest = {
699 parseFrontMatter,
700 serialiseSpec,
701 specBasename,
702 readSpecFile,
703 updateSpecStatus,
704 ensureSpecLabel,
705};
Modifiedsrc/routes/specs.tsx+245−3View fileUnifiedSplit
@@ -18,14 +18,19 @@
1818 * the dynamic-import behaviour are unchanged.
1919 */
2020import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
21import { and, eq, like } from "drizzle-orm";
2222import { db } from "../db";
23import { repositories, users, issues } from "../db/schema";
23import { repositories, users, issues, pullRequests } from "../db/schema";
2424import { Layout } from "../views/layout";
2525import { RepoHeader } from "../views/components";
2626import { softAuth, requireAuth } from "../middleware/auth";
2727import type { AuthEnv } from "../middleware/auth";
28import { listBranches } from "../git/repository";
28import { listBranches, getBlob, getTreeRecursive } from "../git/repository";
29import {
30 AI_SPEC_PR_MARKER,
31 parseFrontMatter,
32 type SpecStatus,
33} from "../lib/spec-to-pr";
2934
3035const specs = new Hono<AuthEnv>();
3136
@@ -825,4 +830,241 @@ specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
825830 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
826831});
827832
833// ─── GET /specs — dashboard listing all specs across the user's repos ──────
834//
835// Walks every repo the signed-in user owns, looks at `.gluecron/specs/*.md`
836// on the default branch, parses the front-matter for `status:` + `title:`,
837// and surfaces a flat list with linked PR (when one exists). Soft-fails on
838// any repo that can't be scanned — the dashboard still renders.
839
840interface SpecRow {
841 ownerName: string;
842 repoName: string;
843 specPath: string;
844 title: string;
845 status: SpecStatus;
846 prNumber: number | null;
847}
848
849async function collectSpecsForOwner(ownerId: string): Promise<SpecRow[]> {
850 let owner: { username: string } | undefined;
851 try {
852 const [row] = await db
853 .select({ username: users.username })
854 .from(users)
855 .where(eq(users.id, ownerId))
856 .limit(1);
857 owner = row;
858 } catch {
859 return [];
860 }
861 if (!owner) return [];
862
863 let repos: Array<{ id: string; name: string; defaultBranch: string }> = [];
864 try {
865 repos = await db
866 .select({
867 id: repositories.id,
868 name: repositories.name,
869 defaultBranch: repositories.defaultBranch,
870 })
871 .from(repositories)
872 .where(
873 and(eq(repositories.ownerId, ownerId), eq(repositories.isArchived, false))
874 )
875 .limit(100);
876 } catch {
877 return [];
878 }
879
880 const out: SpecRow[] = [];
881 for (const repo of repos) {
882 const branch = repo.defaultBranch || "main";
883 let paths: string[] = [];
884 try {
885 const tree = await getTreeRecursive(owner.username, repo.name, branch, 5000);
886 if (!tree) continue;
887 paths = tree.tree
888 .filter(
889 (e) =>
890 e.type === "blob" &&
891 e.path.startsWith(".gluecron/specs/") &&
892 e.path.toLowerCase().endsWith(".md")
893 )
894 .map((e) => e.path);
895 } catch {
896 continue;
897 }
898 for (const p of paths) {
899 let content = "";
900 try {
901 const blob = await getBlob(owner.username, repo.name, branch, p);
902 if (!blob || blob.isBinary) continue;
903 content = blob.content;
904 } catch {
905 continue;
906 }
907 const parsed = parseFrontMatter(content);
908 const title =
909 (parsed.frontMatter.title && parsed.frontMatter.title.trim()) ||
910 (p.split("/").pop() || p).replace(/\.md$/i, "");
911 const statusRaw = (parsed.frontMatter.status || "draft").toLowerCase();
912 const status: SpecStatus =
913 statusRaw === "draft" ||
914 statusRaw === "ready" ||
915 statusRaw === "building" ||
916 statusRaw === "shipped" ||
917 statusRaw === "failed"
918 ? (statusRaw as SpecStatus)
919 : "draft";
920
921 // Best-effort PR lookup: any PR whose body mentions the spec path
922 // and carries the spec marker. Newest first.
923 let prNumber: number | null = null;
924 try {
925 const [pr] = await db
926 .select({ number: pullRequests.number })
927 .from(pullRequests)
928 .where(
929 and(
930 eq(pullRequests.repositoryId, repo.id),
931 like(pullRequests.body, `%${AI_SPEC_PR_MARKER}%`),
932 like(pullRequests.body, `%${p}%`)
933 )
934 )
935 .orderBy(pullRequests.createdAt)
936 .limit(1);
937 if (pr) prNumber = pr.number;
938 } catch {
939 prNumber = null;
940 }
941
942 out.push({
943 ownerName: owner.username,
944 repoName: repo.name,
945 specPath: p,
946 title,
947 status,
948 prNumber,
949 });
950 }
951 }
952 return out;
953}
954
955specs.get("/specs", softAuth, requireAuth, async (c) => {
956 const user = c.get("user")!;
957 const rows = await collectSpecsForOwner(user.id);
958
959 const byStatus = (s: SpecStatus) => rows.filter((r) => r.status === s);
960
961 const statusBadgeColors: Record<SpecStatus, string> = {
962 draft: "rgba(148,163,184,0.16)",
963 ready: "rgba(54,197,214,0.18)",
964 building: "rgba(140,109,255,0.20)",
965 shipped: "rgba(34,197,94,0.18)",
966 failed: "rgba(248,113,113,0.18)",
967 };
968
969 return c.html(
970 <Layout title="Specs — Gluecron" user={user}>
971 <div class="specs-wrap">
972 <header class="specs-head">
973 <div class="specs-eyebrow">
974 <span class="specs-eyebrow-dot" aria-hidden="true" />
975 Account · Specs dashboard
976 <span class="specs-pill-experimental">Experimental</span>
977 </div>
978 <h1 class="specs-title">
979 <span class="specs-title-grad">Your spec library.</span>
980 </h1>
981 <p class="specs-sub">
982 Every <code>.gluecron/specs/*.md</code> file across your
983 repositories. Set a spec's front-matter to{" "}
984 <code>status: ready</code> and the autopilot picks it up within
985 two minutes to open an implementation PR tagged{" "}
986 <code>ai:spec-implementation</code>.
987 </p>
988 </header>
989
990 <section class="specs-section">
991 <header class="specs-section-head">
992 <h2 class="specs-section-title">All specs ({rows.length})</h2>
993 <p class="specs-section-sub">
994 {byStatus("ready").length} ready · {byStatus("building").length}{" "}
995 building · {byStatus("shipped").length} shipped ·{" "}
996 {byStatus("failed").length} failed · {byStatus("draft").length}{" "}
997 draft
998 </p>
999 </header>
1000 <div class="specs-section-body">
1001 {rows.length === 0 ? (
1002 <p class="specs-field-hint" style="font-size:13px;">
1003 No specs yet. Create a file like{" "}
1004 <code>.gluecron/specs/my-feature.md</code> in any of your
1005 repos with front-matter{" "}
1006 <code>---\ntitle: Add dark mode\nstatus: ready\n---</code> and
1007 it will land here.
1008 </p>
1009 ) : (
1010 <table style="width:100%;border-collapse:collapse;font-size:13px;">
1011 <thead>
1012 <tr style="text-align:left;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">
1013 <th style="padding:8px 4px;">Repo</th>
1014 <th style="padding:8px 4px;">Title</th>
1015 <th style="padding:8px 4px;">Status</th>
1016 <th style="padding:8px 4px;">PR</th>
1017 <th style="padding:8px 4px;">Path</th>
1018 </tr>
1019 </thead>
1020 <tbody>
1021 {rows.map((r) => (
1022 <tr style="border-top:1px solid var(--border);">
1023 <td style="padding:10px 4px;">
1024 <a href={`/${r.ownerName}/${r.repoName}`}>
1025 {r.ownerName}/{r.repoName}
1026 </a>
1027 </td>
1028 <td style="padding:10px 4px;color:var(--text-strong);">
1029 {r.title}
1030 </td>
1031 <td style="padding:10px 4px;">
1032 <span
1033 style={`display:inline-block;padding:2px 8px;border-radius:9999px;font-family:var(--font-mono);font-size:11px;background:${statusBadgeColors[r.status]};color:var(--text);`}
1034 >
1035 {r.status}
1036 </span>
1037 </td>
1038 <td style="padding:10px 4px;">
1039 {r.prNumber ? (
1040 <a
1041 href={`/${r.ownerName}/${r.repoName}/pulls/${r.prNumber}`}
1042 >
1043 #{r.prNumber}
1044 </a>
1045 ) : (
1046 <span style="color:var(--text-muted);">—</span>
1047 )}
1048 </td>
1049 <td style="padding:10px 4px;font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);">
1050 <a
1051 href={`/${r.ownerName}/${r.repoName}/blob/main/${r.specPath}`}
1052 >
1053 {r.specPath}
1054 </a>
1055 </td>
1056 </tr>
1057 ))}
1058 </tbody>
1059 </table>
1060 )}
1061 </div>
1062 </section>
1063
1064 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
1065 </div>
1066 </Layout>
1067 );
1068});
1069
8281070export default specs;
8291071