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.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.tsBlame738 lines · 2 contributors
14c3cc8Claude1/**
2 * Spec-to-PR (experimental).
3 *
23d1a81Claude4 * Pipeline:
5 * 1. Validate prerequisites: API key present, repo exists, user can be
6 * resolved for author metadata.
7 * 2. `buildSpecContext` — read the bare repo, score paths against the spec,
8 * collect a bounded file list + top-N relevant file contents.
9 * 3. `generateSpecEdits` — send that context to Claude; parse + validate the
10 * proposed edits (forbidden paths filtered defence-in-depth).
11 * 4. `applyEditsToNewBranch` — write the edits to a fresh branch via git
12 * plumbing (bare repo, no working tree).
13 * 5. Insert a draft `pullRequests` row pointing base→head.
14c3cc8Claude14 *
23d1a81Claude15 * Every failure mode is funnelled through `{ok:false, error}`. We never throw
16 * — the route (`src/routes/specs.tsx`) renders the error inline.
14c3cc8Claude17 */
23d1a81Claude18import { join } from "path";
950ef90Claude19import { and, eq, like } from "drizzle-orm";
14c3cc8Claude20import { db } from "../db";
950ef90Claude21import {
22 repositories,
23 users,
24 pullRequests,
25 labels,
26 prComments,
27} from "../db/schema";
23d1a81Claude28import { buildSpecContext } from "./spec-context";
29import { generateSpecEdits } from "./spec-ai";
30import { applyEditsToNewBranch } from "./spec-git";
950ef90Claude31import {
32 getBlob,
33 createOrUpdateFileOnBranch,
34 getTreeRecursive,
35} from "../git/repository";
0b49751Claude36import { assertAiQuota, AiQuotaExceededError } from "./billing";
14c3cc8Claude37
38export type SpecPRArgs = {
23d1a81Claude39 repoId: string;
14c3cc8Claude40 spec: string;
41 baseRef?: string;
23d1a81Claude42 userId: string;
14c3cc8Claude43};
44
23d1a81Claude45export type SpecPRResult =
46 | {
47 ok: true;
48 prNumber: number;
49 branchName: string;
50 filesChanged: string[];
51 }
52 | { ok: false; error: string };
53
54/** Derive a filesystem-safe branch suffix from the user's spec. */
55function slugify(spec: string): string {
56 const base = spec
57 .toLowerCase()
58 .replace(/[^a-z0-9]+/g, "-")
59 .replace(/^-+|-+$/g, "")
60 .slice(0, 40);
61 return base || "change";
62}
63
64function randomSuffix(): string {
2c3ba6ecopilot-swe-agent[bot]65 return crypto.randomUUID().replace(/-/g, '').slice(0, 6);
23d1a81Claude66}
67
14c3cc8Claude68export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
23d1a81Claude69 // 1. API key gate. Without it the AI step would fail anyway — bail before
70 // any DB or disk work.
14c3cc8Claude71 if (!process.env.ANTHROPIC_API_KEY) {
72 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
73 }
74
0b49751Claude75 // 1b. AI quota hard gate. Returns a user-visible error when the budget is
76 // exhausted so the UI can surface an upgrade prompt.
77 try {
78 await assertAiQuota(args.userId);
79 } catch (err) {
80 if (err instanceof AiQuotaExceededError) {
81 return {
82 ok: false,
83 error:
84 "Monthly AI token budget reached. Upgrade at /settings/billing to continue using spec-to-PR.",
85 };
86 }
87 // Unexpected billing error — fail open so a DB glitch doesn't block users.
88 console.warn("[spec-to-pr] assertAiQuota failed unexpectedly:", err);
89 }
90
23d1a81Claude91 const spec = typeof args.spec === "string" ? args.spec.trim() : "";
92 if (!spec) return { ok: false, error: "spec is empty" };
93
94 // 2. Resolve repo + owner so we can build the on-disk path.
95 let repoRow: {
96 id: string;
97 name: string;
98 defaultBranch: string | null;
99 ownerName: string | null;
100 } | undefined;
14c3cc8Claude101 try {
102 const rows = await db
23d1a81Claude103 .select({
104 id: repositories.id,
105 name: repositories.name,
106 defaultBranch: repositories.defaultBranch,
107 ownerName: users.username,
108 })
14c3cc8Claude109 .from(repositories)
23d1a81Claude110 .leftJoin(users, eq(users.id, repositories.ownerId))
111 .where(eq(repositories.id, args.repoId))
14c3cc8Claude112 .limit(1);
23d1a81Claude113 repoRow = rows[0];
114 } catch {
14c3cc8Claude115 return { ok: false, error: "db lookup failed" };
116 }
23d1a81Claude117 if (!repoRow || !repoRow.ownerName) {
118 return { ok: false, error: "repo not found" };
119 }
14c3cc8Claude120
23d1a81Claude121 // 3. Resolve the author — needed for the commit-tree call and the PR row.
122 let authorRow: { username: string; email: string | null } | undefined;
123 try {
124 const rows = await db
125 .select({ username: users.username, email: users.email })
126 .from(users)
127 .where(eq(users.id, args.userId))
128 .limit(1);
129 authorRow = rows[0];
130 } catch {
131 return { ok: false, error: "db lookup failed" };
132 }
133 if (!authorRow) return { ok: false, error: "author not found" };
134
135 const base = process.env.GIT_REPOS_PATH || "./repos";
136 const repoDiskPath = join(base, repoRow.ownerName, `${repoRow.name}.git`);
137 const defaultBranch = repoRow.defaultBranch || "main";
138 const baseRef = (args.baseRef && args.baseRef.trim()) || defaultBranch;
139
140 // 4. Build context.
141 const ctx = await buildSpecContext({
142 repoDiskPath,
143 spec,
144 defaultBranch: baseRef,
145 });
146 if (!ctx.ok) {
147 return { ok: false, error: `context build failed: ${ctx.error}` };
148 }
149
150 // 5. Ask Claude for edits.
151 const ai = await generateSpecEdits({
152 spec,
153 fileList: ctx.context.fileList,
154 relevantFiles: ctx.context.relevantFiles,
155 defaultBranch: ctx.context.defaultBranch,
156 });
157 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
158 if (ai.edits.length === 0) {
159 return { ok: false, error: "AI proposed no changes" };
160 }
161
162 // 6. Apply edits to a fresh branch via git plumbing.
163 const branchName = `spec/${slugify(spec)}-${randomSuffix()}`;
164 const commitSubject = ai.summary || `spec: ${spec.slice(0, 60)}`;
165 const commitBody = `Generated by spec-to-PR.\n\nSpec:\n${spec}`;
166 const commitMessage = `${commitSubject}\n\n${commitBody}`;
167
168 const authorEmail =
169 authorRow.email || `${authorRow.username}@users.noreply.gluecron`;
170 const applied = await applyEditsToNewBranch({
171 repoDiskPath,
172 baseRef,
173 edits: ai.edits,
174 branchName,
175 commitMessage,
176 authorName: authorRow.username,
177 authorEmail,
178 });
179 if (!applied.ok) return { ok: false, error: `git apply failed: ${applied.error}` };
14c3cc8Claude180
23d1a81Claude181 // 7. Insert the draft PR row.
182 try {
183 const [pr] = await db
184 .insert(pullRequests)
185 .values({
186 repositoryId: repoRow.id,
187 authorId: args.userId,
188 title: commitSubject.slice(0, 200),
189 body: `${commitBody}\n\nFiles changed:\n${applied.filesChanged
190 .map((p) => `- ${p}`)
191 .join("\n")}`,
192 baseBranch: baseRef,
193 headBranch: applied.branchName,
194 isDraft: true,
195 })
196 .returning();
197 const number = pr?.number;
198 if (typeof number !== "number") {
199 return { ok: false, error: "PR insert returned no number" };
200 }
201 return {
202 ok: true,
203 prNumber: number,
204 branchName: applied.branchName,
205 filesChanged: applied.filesChanged,
206 };
207 } catch (err) {
208 return {
209 ok: false,
210 error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`,
211 };
212 }
14c3cc8Claude213}
950ef90Claude214
215// ---------------------------------------------------------------------------
216// runSpecToPr — autopilot-driven spec-file flow.
217//
218// Reads a spec file living at `.gluecron/specs/<name>.md` inside the repo,
219// parses its YAML-style front-matter, and (when `status: ready`) generates a
220// PR off the same context/AI/git pipeline as `createSpecPR`. After the PR is
221// opened the spec file is rewritten with `status: building`. After successful
222// completion the file is rewritten to `status: shipped` (with the new PR
223// number recorded). Failures rewrite to `status: failed` with the error.
224//
225// Distinct from `createSpecPR` in two ways:
226// 1. Spec text lives in the repo, not a route body — supplied via `specPath`.
227// 2. PRs are tagged with the `ai:spec-implementation` label (created on
228// demand on the repo for parity with the existing `ai:proposed-patch`
229// label flow in `ai-patch-generator.ts`).
230// ---------------------------------------------------------------------------
231
232/** Label name surfaced (and ensured present) for spec-driven PRs. */
233export const AI_SPEC_LABEL = "ai:spec-implementation";
234
235/** Marker baked into the PR body so the autopilot loop can detect "already implemented". */
236export const AI_SPEC_PR_MARKER = "<!-- gluecron:ai-spec-implementation:v1 -->";
237
238export type SpecStatus = "draft" | "ready" | "building" | "shipped" | "failed";
239
240export interface ParsedSpec {
241 frontMatter: Record<string, string>;
242 body: string;
243 raw: string;
244 /** True when the document opened with a `---` fence. */
245 hasFrontMatter: boolean;
246}
247
248export interface RunSpecToPrArgs {
249 repositoryId: string;
250 /** Path to the spec markdown inside the repo, e.g. `.gluecron/specs/foo.md`. */
251 specPath: string;
252 /** Optional base sha override. Falls back to repo default branch HEAD. */
253 baseSha?: string;
254}
255
256export type RunSpecToPrResult =
257 | { ok: true; branch: string; prNumber: number; status: SpecStatus }
258 | { ok: false; error: string; status?: SpecStatus };
259
260/**
261 * Parse YAML-style front-matter. Intentionally tiny — just `key: value` pairs.
262 * Anything we can't parse is treated as an absent front-matter block.
263 */
264export function parseFrontMatter(raw: string): ParsedSpec {
265 if (typeof raw !== "string" || !raw) {
266 return { frontMatter: {}, body: "", raw: raw || "", hasFrontMatter: false };
267 }
268 const fenced = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
269 if (!fenced) {
270 return { frontMatter: {}, body: raw, raw, hasFrontMatter: false };
271 }
272 const block = fenced[1];
273 const body = fenced[2] || "";
274 const fm: Record<string, string> = {};
275 for (const line of block.split(/\r?\n/)) {
276 const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
277 if (!m) continue;
278 let value = m[2].trim();
279 // Strip matched quotes around the value.
280 if (
281 (value.startsWith('"') && value.endsWith('"')) ||
282 (value.startsWith("'") && value.endsWith("'"))
283 ) {
284 value = value.slice(1, -1);
285 }
286 fm[m[1]] = value;
287 }
288 return { frontMatter: fm, body, raw, hasFrontMatter: true };
289}
290
291/**
292 * Serialise the spec back to a markdown document with the supplied
293 * front-matter map. Keys are emitted in the order returned by `Object.keys`
294 * which preserves insertion order in JS.
295 */
296export function serialiseSpec(
297 frontMatter: Record<string, string>,
298 body: string
299): string {
300 const lines: string[] = ["---"];
301 for (const [k, v] of Object.entries(frontMatter)) {
302 const val = String(v ?? "");
303 // Quote values that contain `:` or leading/trailing whitespace so the
304 // round-trip stays parseable.
305 const needsQuote = /[:#]/.test(val) || /^\s|\s$/.test(val);
306 lines.push(`${k}: ${needsQuote ? JSON.stringify(val) : val}`);
307 }
308 lines.push("---");
309 // Always end the front-matter with a single newline before the body, and
310 // preserve the body verbatim (callers pass body that already starts with
311 // a newline or content).
312 const trimmedBody = body.startsWith("\n") ? body.slice(1) : body;
313 return `${lines.join("\n")}\n${trimmedBody}`;
314}
315
316/**
317 * Read a spec file from the repo via git plumbing. Returns null when the
318 * file is missing or binary. Failures are funnelled into `ok:false`.
319 */
320async function readSpecFile(
321 ownerName: string,
322 repoName: string,
323 ref: string,
324 specPath: string
325): Promise<{ ok: true; content: string } | { ok: false; error: string }> {
326 try {
327 const blob = await getBlob(ownerName, repoName, ref, specPath);
328 if (!blob) return { ok: false, error: "spec file not found" };
329 if (blob.isBinary) return { ok: false, error: "spec file is binary" };
330 return { ok: true, content: blob.content };
331 } catch (err) {
332 return {
333 ok: false,
334 error: `getBlob failed: ${err instanceof Error ? err.message : String(err)}`,
335 };
336 }
337}
338
339/**
340 * Rewrite the spec file on the repo's default branch with an updated
341 * front-matter status. Best-effort — failures are logged but do not block
342 * the PR from being opened.
343 */
344async function updateSpecStatus(args: {
345 ownerName: string;
346 repoName: string;
347 branch: string;
348 specPath: string;
349 current: ParsedSpec;
350 status: SpecStatus;
351 extra?: Record<string, string>;
352 authorName: string;
353 authorEmail: string;
354}): Promise<{ ok: true; commitSha: string } | { ok: false; error: string }> {
355 const fm: Record<string, string> = { ...args.current.frontMatter };
356 fm.status = args.status;
357 if (args.extra) {
358 for (const [k, v] of Object.entries(args.extra)) fm[k] = v;
359 }
360 const newRaw = args.current.hasFrontMatter
361 ? serialiseSpec(fm, args.current.body)
362 : serialiseSpec(fm, args.current.raw);
363
364 const res = await createOrUpdateFileOnBranch({
365 owner: args.ownerName,
366 name: args.repoName,
367 branch: args.branch,
368 filePath: args.specPath,
369 bytes: new TextEncoder().encode(newRaw),
370 message: `chore(spec): mark ${args.specPath} as ${args.status}`,
371 authorName: args.authorName,
372 authorEmail: args.authorEmail,
373 });
374 if ("error" in res) return { ok: false, error: res.error };
375 return { ok: true, commitSha: res.commitSha };
376}
377
378/**
379 * Ensure the `ai:spec-implementation` label row exists on the repo.
380 * Best-effort — failures are swallowed.
381 */
382async function ensureSpecLabel(repositoryId: string): Promise<void> {
383 try {
384 await db
385 .insert(labels)
386 .values({
387 repositoryId,
388 name: AI_SPEC_LABEL,
6fd5915Claude389 color: "#5f8fa0",
950ef90Claude390 description:
391 "PR auto-opened by spec-to-PR autopilot from a .gluecron/specs/*.md file",
392 })
393 .onConflictDoNothing?.();
394 } catch {
395 /* ignore — label is a UX nicety, not load-bearing */
396 }
397}
398
399/**
400 * Derive a slug from the spec path's basename. `.gluecron/specs/foo-bar.md`
401 * → `foo-bar`.
402 */
403export function specBasename(specPath: string): string {
404 const last = specPath.split("/").pop() || specPath;
405 const noExt = last.replace(/\.md$/i, "");
406 return noExt.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "") || "spec";
407}
408
409/**
410 * Driver entry point used by both the autopilot loop and direct callers.
411 * Returns the new branch/PR on success, or a structured error on failure.
412 * Never throws.
413 */
414export async function runSpecToPr(
415 args: RunSpecToPrArgs
416): Promise<RunSpecToPrResult> {
417 // 1. Hard gates. We short-circuit fast when the platform is configured
418 // to skip AI work, so callers (autopilot) don't fan out unnecessary
419 // DB / git reads.
420 if (process.env.AUTOPILOT_DISABLED === "1") {
421 return { ok: false, error: "AUTOPILOT_DISABLED" };
422 }
423 if (!process.env.ANTHROPIC_API_KEY) {
424 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
425 }
426 if (!args.specPath || !args.specPath.endsWith(".md")) {
427 return { ok: false, error: "specPath must be a .md file" };
428 }
429 if (
430 args.specPath.includes("..") ||
431 args.specPath.startsWith("/") ||
432 !args.specPath.startsWith(".gluecron/specs/")
433 ) {
434 return {
435 ok: false,
436 error: "specPath must live under .gluecron/specs/",
437 };
438 }
439
440 // 2. Resolve repo + owner.
441 let repoRow:
442 | {
443 id: string;
444 name: string;
445 defaultBranch: string;
446 ownerId: string;
447 ownerName: string | null;
448 }
449 | undefined;
450 try {
451 const rows = await db
452 .select({
453 id: repositories.id,
454 name: repositories.name,
455 defaultBranch: repositories.defaultBranch,
456 ownerId: repositories.ownerId,
457 ownerName: users.username,
458 })
459 .from(repositories)
460 .leftJoin(users, eq(users.id, repositories.ownerId))
461 .where(eq(repositories.id, args.repositoryId))
462 .limit(1);
463 repoRow = rows[0];
464 } catch {
465 return { ok: false, error: "db lookup failed" };
466 }
467 if (!repoRow || !repoRow.ownerName) {
468 return { ok: false, error: "repo not found" };
469 }
470
0b49751Claude471 // 2b. AI quota hard gate — check against the repo owner's budget.
472 // runSpecToPr is called from the autopilot so we skip silently (return
473 // ok:false) rather than throwing; the autopilot loop logs the error.
474 try {
475 await assertAiQuota(repoRow.ownerId);
476 } catch (err) {
477 if (err instanceof AiQuotaExceededError) {
478 return {
479 ok: false,
480 error:
481 "Monthly AI token budget reached. Upgrade at /settings/billing to continue using spec-to-PR.",
482 };
483 }
484 console.warn("[spec-to-pr] assertAiQuota failed unexpectedly:", err);
485 }
486
950ef90Claude487 const ownerName = repoRow.ownerName;
488 const repoName = repoRow.name;
489 const defaultBranch = repoRow.defaultBranch || "main";
490 const baseRef = args.baseSha && args.baseSha.trim() ? args.baseSha : defaultBranch;
491
492 // 3. Read the spec file at the base ref.
493 const specRead = await readSpecFile(ownerName, repoName, baseRef, args.specPath);
494 if (!specRead.ok) return { ok: false, error: specRead.error };
495 const parsed = parseFrontMatter(specRead.content);
496
497 const status = (parsed.frontMatter.status || "draft").toLowerCase() as SpecStatus;
498 if (status !== "ready") {
499 return {
500 ok: false,
501 error: `spec status is ${status}, not ready`,
502 status,
503 };
504 }
505
506 const title =
507 (parsed.frontMatter.title && parsed.frontMatter.title.trim()) ||
508 specBasename(args.specPath);
509 const specBody = parsed.body.trim() || parsed.raw.trim();
510 if (!specBody) return { ok: false, error: "spec body is empty" };
511
512 // 4. Idempotency: skip if a PR already exists referencing this spec path.
513 try {
514 const existing = await db
515 .select({ number: pullRequests.number })
516 .from(pullRequests)
517 .where(
518 and(
519 eq(pullRequests.repositoryId, repoRow.id),
520 like(pullRequests.body, `%${args.specPath}%`)
521 )
522 )
523 .limit(1);
524 if (existing.length > 0) {
525 return {
526 ok: false,
527 error: `PR already exists for this spec (#${existing[0].number})`,
528 status: "building",
529 };
530 }
531 } catch {
532 // Non-fatal — if the dedup query failed, fall through.
533 }
534
535 // 5. Build context + ask Claude for edits. Reuse the existing helpers so
536 // we don't fork the prompt surface.
537 const base = process.env.GIT_REPOS_PATH || "./repos";
538 const repoDiskPath = join(base, ownerName, `${repoName}.git`);
539
540 const ctx = await buildSpecContext({
541 repoDiskPath,
542 spec: specBody,
543 defaultBranch: baseRef,
544 });
545 if (!ctx.ok) {
546 return { ok: false, error: `context build failed: ${ctx.error}` };
547 }
548
549 // Augment the context's file list with a recursive listing so Claude
550 // sees the whole tree (capped). This makes spec-driven runs more
551 // robust against monorepo layouts than the default 500-line list.
552 try {
553 const recursive = await getTreeRecursive(ownerName, repoName, baseRef, 1500);
554 if (recursive && recursive.tree.length > 0) {
555 const blobPaths = recursive.tree
556 .filter((e) => e.type === "blob")
557 .map((e) => e.path);
558 // Replace the file list with the recursive view, but keep it bounded.
559 ctx.context.fileList = blobPaths.slice(0, 500);
560 }
561 } catch {
562 /* keep the original list — recursive scan is a nice-to-have */
563 }
564
565 const ai = await generateSpecEdits({
566 spec: specBody,
567 fileList: ctx.context.fileList,
568 relevantFiles: ctx.context.relevantFiles,
569 defaultBranch: ctx.context.defaultBranch,
570 });
571 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
572 if (ai.edits.length === 0) {
573 return { ok: false, error: "AI proposed no changes" };
574 }
575
576 // 6. Apply edits to a new branch.
577 const branchName = `ai-spec/${specBasename(args.specPath)}-${Date.now()}`;
578 const authorName = "Gluecron Autopilot";
579 const authorEmail = "autopilot@gluecron.com";
580 const commitSubject = ai.summary || `spec: ${title}`.slice(0, 80);
581 const commitBody = `Generated by spec-to-PR autopilot.\n\nSpec: ${args.specPath}\n\n${specBody}`;
582 const commitMessage = `${commitSubject}\n\n${commitBody}`;
583
584 const applied = await applyEditsToNewBranch({
585 repoDiskPath,
586 baseRef,
587 edits: ai.edits,
588 branchName,
589 commitMessage,
590 authorName,
591 authorEmail,
592 });
593 if (!applied.ok) {
594 return { ok: false, error: `git apply failed: ${applied.error}` };
595 }
596
597 // 7. Ensure the spec-implementation label exists on the repo.
598 await ensureSpecLabel(repoRow.id);
599
600 // 8. Insert the PR row.
601 let prNumber: number | null = null;
602 let prId: string | null = null;
603 try {
604 const quotedSpec = specBody
605 .split("\n")
606 .map((l) => `> ${l}`)
607 .join("\n");
608 const filesList = applied.filesChanged.map((p) => `- \`${p}\``).join("\n");
609 const body = [
610 AI_SPEC_PR_MARKER,
611 `## Spec-to-PR — \`${args.specPath}\``,
612 "",
613 "### Spec",
614 quotedSpec,
615 "",
616 "### Summary of changes",
617 ai.summary || "_(no summary provided)_",
618 "",
619 "### Files changed",
620 filesList || "_(none)_",
621 "",
622 "---",
623 "",
624 `Spec path: \`${args.specPath}\``,
625 `Label: \`${AI_SPEC_LABEL}\``,
626 "",
627 "_Auto-generated by Gluecron spec-to-PR autopilot. Review every line before merging._",
628 ].join("\n");
629
630 const [pr] = await db
631 .insert(pullRequests)
632 .values({
633 repositoryId: repoRow.id,
634 authorId: repoRow.ownerId,
635 title: `[spec] ${title}`.slice(0, 200),
636 body,
637 baseBranch: defaultBranch,
638 headBranch: applied.branchName,
639 isDraft: true,
640 })
641 .returning({ number: pullRequests.number, id: pullRequests.id });
642 if (pr) {
643 prNumber = pr.number;
644 prId = pr.id;
645 }
646 } catch (err) {
647 return {
648 ok: false,
649 error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`,
650 };
651 }
652
653 if (prNumber == null || prId == null) {
654 return { ok: false, error: "PR insert returned no row" };
655 }
656
657 // 9. Drop a marker comment surfacing the label (mirrors ai-patch-generator).
658 try {
659 await db.insert(prComments).values({
660 pullRequestId: prId,
661 authorId: repoRow.ownerId,
662 isAiReview: true,
663 body: `${AI_SPEC_PR_MARKER}\nApplied label: \`${AI_SPEC_LABEL}\``,
664 });
665 } catch {
666 /* best-effort */
667 }
668
669 // 10. Rewrite the spec file to `status: building` so the autopilot loop
670 // doesn't re-pick it on the next tick. Best-effort.
671 try {
672 await updateSpecStatus({
673 ownerName,
674 repoName,
675 branch: defaultBranch,
676 specPath: args.specPath,
677 current: parsed,
678 status: "building",
679 extra: { pr: String(prNumber) },
680 authorName,
681 authorEmail,
682 });
683 } catch {
684 /* status update is non-fatal */
685 }
686
687 return {
688 ok: true,
689 branch: applied.branchName,
690 prNumber,
691 status: "building",
692 };
693}
694
695/**
696 * Mark a spec file as `shipped` once its PR is merged. Called from the PR
697 * merge webhook / merge handler in a follow-up wiring (kept here so the
698 * autopilot loop and the merge path use the same writer).
699 */
700export async function markSpecShipped(args: {
701 ownerName: string;
702 repoName: string;
703 defaultBranch: string;
704 specPath: string;
705 prNumber: number;
706}): Promise<{ ok: true } | { ok: false; error: string }> {
707 const read = await readSpecFile(
708 args.ownerName,
709 args.repoName,
710 args.defaultBranch,
711 args.specPath
712 );
713 if (!read.ok) return { ok: false, error: read.error };
714 const parsed = parseFrontMatter(read.content);
715 const res = await updateSpecStatus({
716 ownerName: args.ownerName,
717 repoName: args.repoName,
718 branch: args.defaultBranch,
719 specPath: args.specPath,
720 current: parsed,
721 status: "shipped",
722 extra: { pr: String(args.prNumber) },
723 authorName: "Gluecron Autopilot",
724 authorEmail: "autopilot@gluecron.com",
725 });
726 if (!res.ok) return { ok: false, error: res.error };
727 return { ok: true };
728}
729
730/** Test-only exports of internal helpers. */
731export const __specTest = {
732 parseFrontMatter,
733 serialiseSpec,
734 specBasename,
735 readSpecFile,
736 updateSpecStatus,
737 ensureSpecLabel,
738};