Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame705 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";
14c3cc8Claude36
37export type SpecPRArgs = {
23d1a81Claude38 repoId: string;
14c3cc8Claude39 spec: string;
40 baseRef?: string;
23d1a81Claude41 userId: string;
14c3cc8Claude42};
43
23d1a81Claude44export type SpecPRResult =
45 | {
46 ok: true;
47 prNumber: number;
48 branchName: string;
49 filesChanged: string[];
50 }
51 | { ok: false; error: string };
52
53/** Derive a filesystem-safe branch suffix from the user's spec. */
54function slugify(spec: string): string {
55 const base = spec
56 .toLowerCase()
57 .replace(/[^a-z0-9]+/g, "-")
58 .replace(/^-+|-+$/g, "")
59 .slice(0, 40);
60 return base || "change";
61}
62
63function randomSuffix(): string {
2c3ba6ecopilot-swe-agent[bot]64 return crypto.randomUUID().replace(/-/g, '').slice(0, 6);
23d1a81Claude65}
66
14c3cc8Claude67export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
23d1a81Claude68 // 1. API key gate. Without it the AI step would fail anyway — bail before
69 // any DB or disk work.
14c3cc8Claude70 if (!process.env.ANTHROPIC_API_KEY) {
71 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
72 }
73
23d1a81Claude74 const spec = typeof args.spec === "string" ? args.spec.trim() : "";
75 if (!spec) return { ok: false, error: "spec is empty" };
76
77 // 2. Resolve repo + owner so we can build the on-disk path.
78 let repoRow: {
79 id: string;
80 name: string;
81 defaultBranch: string | null;
82 ownerName: string | null;
83 } | undefined;
14c3cc8Claude84 try {
85 const rows = await db
23d1a81Claude86 .select({
87 id: repositories.id,
88 name: repositories.name,
89 defaultBranch: repositories.defaultBranch,
90 ownerName: users.username,
91 })
14c3cc8Claude92 .from(repositories)
23d1a81Claude93 .leftJoin(users, eq(users.id, repositories.ownerId))
94 .where(eq(repositories.id, args.repoId))
14c3cc8Claude95 .limit(1);
23d1a81Claude96 repoRow = rows[0];
97 } catch {
14c3cc8Claude98 return { ok: false, error: "db lookup failed" };
99 }
23d1a81Claude100 if (!repoRow || !repoRow.ownerName) {
101 return { ok: false, error: "repo not found" };
102 }
14c3cc8Claude103
23d1a81Claude104 // 3. Resolve the author — needed for the commit-tree call and the PR row.
105 let authorRow: { username: string; email: string | null } | undefined;
106 try {
107 const rows = await db
108 .select({ username: users.username, email: users.email })
109 .from(users)
110 .where(eq(users.id, args.userId))
111 .limit(1);
112 authorRow = rows[0];
113 } catch {
114 return { ok: false, error: "db lookup failed" };
115 }
116 if (!authorRow) return { ok: false, error: "author not found" };
117
118 const base = process.env.GIT_REPOS_PATH || "./repos";
119 const repoDiskPath = join(base, repoRow.ownerName, `${repoRow.name}.git`);
120 const defaultBranch = repoRow.defaultBranch || "main";
121 const baseRef = (args.baseRef && args.baseRef.trim()) || defaultBranch;
122
123 // 4. Build context.
124 const ctx = await buildSpecContext({
125 repoDiskPath,
126 spec,
127 defaultBranch: baseRef,
128 });
129 if (!ctx.ok) {
130 return { ok: false, error: `context build failed: ${ctx.error}` };
131 }
132
133 // 5. Ask Claude for edits.
134 const ai = await generateSpecEdits({
135 spec,
136 fileList: ctx.context.fileList,
137 relevantFiles: ctx.context.relevantFiles,
138 defaultBranch: ctx.context.defaultBranch,
139 });
140 if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` };
141 if (ai.edits.length === 0) {
142 return { ok: false, error: "AI proposed no changes" };
143 }
144
145 // 6. Apply edits to a fresh branch via git plumbing.
146 const branchName = `spec/${slugify(spec)}-${randomSuffix()}`;
147 const commitSubject = ai.summary || `spec: ${spec.slice(0, 60)}`;
148 const commitBody = `Generated by spec-to-PR.\n\nSpec:\n${spec}`;
149 const commitMessage = `${commitSubject}\n\n${commitBody}`;
150
151 const authorEmail =
152 authorRow.email || `${authorRow.username}@users.noreply.gluecron`;
153 const applied = await applyEditsToNewBranch({
154 repoDiskPath,
155 baseRef,
156 edits: ai.edits,
157 branchName,
158 commitMessage,
159 authorName: authorRow.username,
160 authorEmail,
161 });
162 if (!applied.ok) return { ok: false, error: `git apply failed: ${applied.error}` };
14c3cc8Claude163
23d1a81Claude164 // 7. Insert the draft PR row.
165 try {
166 const [pr] = await db
167 .insert(pullRequests)
168 .values({
169 repositoryId: repoRow.id,
170 authorId: args.userId,
171 title: commitSubject.slice(0, 200),
172 body: `${commitBody}\n\nFiles changed:\n${applied.filesChanged
173 .map((p) => `- ${p}`)
174 .join("\n")}`,
175 baseBranch: baseRef,
176 headBranch: applied.branchName,
177 isDraft: true,
178 })
179 .returning();
180 const number = pr?.number;
181 if (typeof number !== "number") {
182 return { ok: false, error: "PR insert returned no number" };
183 }
184 return {
185 ok: true,
186 prNumber: number,
187 branchName: applied.branchName,
188 filesChanged: applied.filesChanged,
189 };
190 } catch (err) {
191 return {
192 ok: false,
193 error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`,
194 };
195 }
14c3cc8Claude196}
950ef90Claude197
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};