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.
| 14c3cc8 | 1 | /** |
| 2 | * Spec-to-PR (experimental). | |
| 3 | * | |
| 23d1a81 | 4 | * 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. | |
| 14c3cc8 | 14 | * |
| 23d1a81 | 15 | * Every failure mode is funnelled through `{ok:false, error}`. We never throw |
| 16 | * — the route (`src/routes/specs.tsx`) renders the error inline. | |
| 14c3cc8 | 17 | */ |
| 23d1a81 | 18 | import { join } from "path"; |
| 950ef90 | 19 | import { and, eq, like } from "drizzle-orm"; |
| 14c3cc8 | 20 | import { db } from "../db"; |
| 950ef90 | 21 | import { |
| 22 | repositories, | |
| 23 | users, | |
| 24 | pullRequests, | |
| 25 | labels, | |
| 26 | prComments, | |
| 27 | } from "../db/schema"; | |
| 23d1a81 | 28 | import { buildSpecContext } from "./spec-context"; |
| 29 | import { generateSpecEdits } from "./spec-ai"; | |
| 30 | import { applyEditsToNewBranch } from "./spec-git"; | |
| 950ef90 | 31 | import { |
| 32 | getBlob, | |
| 33 | createOrUpdateFileOnBranch, | |
| 34 | getTreeRecursive, | |
| 35 | } from "../git/repository"; | |
| 0b49751 | 36 | import { assertAiQuota, AiQuotaExceededError } from "./billing"; |
| 14c3cc8 | 37 | |
| 38 | export type SpecPRArgs = { | |
| 23d1a81 | 39 | repoId: string; |
| 14c3cc8 | 40 | spec: string; |
| 41 | baseRef?: string; | |
| 23d1a81 | 42 | userId: string; |
| 14c3cc8 | 43 | }; |
| 44 | ||
| 23d1a81 | 45 | export 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. */ | |
| 55 | function 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 | ||
| 64 | function randomSuffix(): string { | |
| 2c3ba6e | 65 | return crypto.randomUUID().replace(/-/g, '').slice(0, 6); |
| 23d1a81 | 66 | } |
| 67 | ||
| 14c3cc8 | 68 | export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> { |
| 23d1a81 | 69 | // 1. API key gate. Without it the AI step would fail anyway — bail before |
| 70 | // any DB or disk work. | |
| 14c3cc8 | 71 | if (!process.env.ANTHROPIC_API_KEY) { |
| 72 | return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" }; | |
| 73 | } | |
| 74 | ||
| 0b49751 | 75 | // 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 | ||
| 23d1a81 | 91 | 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; | |
| 14c3cc8 | 101 | try { |
| 102 | const rows = await db | |
| 23d1a81 | 103 | .select({ |
| 104 | id: repositories.id, | |
| 105 | name: repositories.name, | |
| 106 | defaultBranch: repositories.defaultBranch, | |
| 107 | ownerName: users.username, | |
| 108 | }) | |
| 14c3cc8 | 109 | .from(repositories) |
| 23d1a81 | 110 | .leftJoin(users, eq(users.id, repositories.ownerId)) |
| 111 | .where(eq(repositories.id, args.repoId)) | |
| 14c3cc8 | 112 | .limit(1); |
| 23d1a81 | 113 | repoRow = rows[0]; |
| 114 | } catch { | |
| 14c3cc8 | 115 | return { ok: false, error: "db lookup failed" }; |
| 116 | } | |
| 23d1a81 | 117 | if (!repoRow || !repoRow.ownerName) { |
| 118 | return { ok: false, error: "repo not found" }; | |
| 119 | } | |
| 14c3cc8 | 120 | |
| 23d1a81 | 121 | // 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}` }; | |
| 14c3cc8 | 180 | |
| 23d1a81 | 181 | // 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 | } | |
| 14c3cc8 | 213 | } |
| 950ef90 | 214 | |
| 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. */ | |
| 233 | export const AI_SPEC_LABEL = "ai:spec-implementation"; | |
| 234 | ||
| 235 | /** Marker baked into the PR body so the autopilot loop can detect "already implemented". */ | |
| 236 | export const AI_SPEC_PR_MARKER = "<!-- gluecron:ai-spec-implementation:v1 -->"; | |
| 237 | ||
| 238 | export type SpecStatus = "draft" | "ready" | "building" | "shipped" | "failed"; | |
| 239 | ||
| 240 | export 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 | ||
| 248 | export 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 | ||
| 256 | export 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 | */ | |
| 264 | export 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 | */ | |
| 296 | export 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 | */ | |
| 320 | async 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 | */ | |
| 344 | async 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 | */ | |
| 382 | async function ensureSpecLabel(repositoryId: string): Promise<void> { | |
| 383 | try { | |
| 384 | await db | |
| 385 | .insert(labels) | |
| 386 | .values({ | |
| 387 | repositoryId, | |
| 388 | name: AI_SPEC_LABEL, | |
| 6fd5915 | 389 | color: "#5f8fa0", |
| 950ef90 | 390 | 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 | */ | |
| 403 | export 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 | */ | |
| 414 | export 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 | ||
| 0b49751 | 471 | // 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 | ||
| 950ef90 | 487 | 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 | */ | |
| 700 | export 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. */ | |
| 731 | export const __specTest = { | |
| 732 | parseFrontMatter, | |
| 733 | serialiseSpec, | |
| 734 | specBasename, | |
| 735 | readSpecFile, | |
| 736 | updateSpecStatus, | |
| 737 | ensureSpecLabel, | |
| 738 | }; |