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"; |
| 14c3cc8 | 19 | import { eq } from "drizzle-orm"; |
| 20 | import { db } from "../db"; | |
| 21 | import { repositories, users, pullRequests } from "../db/schema"; | |
| 23d1a81 | 22 | import { buildSpecContext } from "./spec-context"; |
| 23 | import { generateSpecEdits } from "./spec-ai"; | |
| 24 | import { applyEditsToNewBranch } from "./spec-git"; | |
| 14c3cc8 | 25 | |
| 26 | export type SpecPRArgs = { | |
| 23d1a81 | 27 | repoId: string; |
| 14c3cc8 | 28 | spec: string; |
| 29 | baseRef?: string; | |
| 23d1a81 | 30 | userId: string; |
| 14c3cc8 | 31 | }; |
| 32 | ||
| 23d1a81 | 33 | export type SpecPRResult = |
| 34 | | { | |
| 35 | ok: true; | |
| 36 | prNumber: number; | |
| 37 | branchName: string; | |
| 38 | filesChanged: string[]; | |
| 39 | } | |
| 40 | | { ok: false; error: string }; | |
| 41 | ||
| 42 | /** Derive a filesystem-safe branch suffix from the user's spec. */ | |
| 43 | function slugify(spec: string): string { | |
| 44 | const base = spec | |
| 45 | .toLowerCase() | |
| 46 | .replace(/[^a-z0-9]+/g, "-") | |
| 47 | .replace(/^-+|-+$/g, "") | |
| 48 | .slice(0, 40); | |
| 49 | return base || "change"; | |
| 50 | } | |
| 51 | ||
| 52 | function randomSuffix(): string { | |
| 53 | return Math.random().toString(16).slice(2, 8); | |
| 54 | } | |
| 55 | ||
| 14c3cc8 | 56 | export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> { |
| 23d1a81 | 57 | // 1. API key gate. Without it the AI step would fail anyway — bail before |
| 58 | // any DB or disk work. | |
| 14c3cc8 | 59 | if (!process.env.ANTHROPIC_API_KEY) { |
| 60 | return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" }; | |
| 61 | } | |
| 62 | ||
| 23d1a81 | 63 | const spec = typeof args.spec === "string" ? args.spec.trim() : ""; |
| 64 | if (!spec) return { ok: false, error: "spec is empty" }; | |
| 65 | ||
| 66 | // 2. Resolve repo + owner so we can build the on-disk path. | |
| 67 | let repoRow: { | |
| 68 | id: string; | |
| 69 | name: string; | |
| 70 | defaultBranch: string | null; | |
| 71 | ownerName: string | null; | |
| 72 | } | undefined; | |
| 14c3cc8 | 73 | try { |
| 74 | const rows = await db | |
| 23d1a81 | 75 | .select({ |
| 76 | id: repositories.id, | |
| 77 | name: repositories.name, | |
| 78 | defaultBranch: repositories.defaultBranch, | |
| 79 | ownerName: users.username, | |
| 80 | }) | |
| 14c3cc8 | 81 | .from(repositories) |
| 23d1a81 | 82 | .leftJoin(users, eq(users.id, repositories.ownerId)) |
| 83 | .where(eq(repositories.id, args.repoId)) | |
| 14c3cc8 | 84 | .limit(1); |
| 23d1a81 | 85 | repoRow = rows[0]; |
| 86 | } catch { | |
| 14c3cc8 | 87 | return { ok: false, error: "db lookup failed" }; |
| 88 | } | |
| 23d1a81 | 89 | if (!repoRow || !repoRow.ownerName) { |
| 90 | return { ok: false, error: "repo not found" }; | |
| 91 | } | |
| 14c3cc8 | 92 | |
| 23d1a81 | 93 | // 3. Resolve the author — needed for the commit-tree call and the PR row. |
| 94 | let authorRow: { username: string; email: string | null } | undefined; | |
| 95 | try { | |
| 96 | const rows = await db | |
| 97 | .select({ username: users.username, email: users.email }) | |
| 98 | .from(users) | |
| 99 | .where(eq(users.id, args.userId)) | |
| 100 | .limit(1); | |
| 101 | authorRow = rows[0]; | |
| 102 | } catch { | |
| 103 | return { ok: false, error: "db lookup failed" }; | |
| 104 | } | |
| 105 | if (!authorRow) return { ok: false, error: "author not found" }; | |
| 106 | ||
| 107 | const base = process.env.GIT_REPOS_PATH || "./repos"; | |
| 108 | const repoDiskPath = join(base, repoRow.ownerName, `${repoRow.name}.git`); | |
| 109 | const defaultBranch = repoRow.defaultBranch || "main"; | |
| 110 | const baseRef = (args.baseRef && args.baseRef.trim()) || defaultBranch; | |
| 111 | ||
| 112 | // 4. Build context. | |
| 113 | const ctx = await buildSpecContext({ | |
| 114 | repoDiskPath, | |
| 115 | spec, | |
| 116 | defaultBranch: baseRef, | |
| 117 | }); | |
| 118 | if (!ctx.ok) { | |
| 119 | return { ok: false, error: `context build failed: ${ctx.error}` }; | |
| 120 | } | |
| 121 | ||
| 122 | // 5. Ask Claude for edits. | |
| 123 | const ai = await generateSpecEdits({ | |
| 124 | spec, | |
| 125 | fileList: ctx.context.fileList, | |
| 126 | relevantFiles: ctx.context.relevantFiles, | |
| 127 | defaultBranch: ctx.context.defaultBranch, | |
| 128 | }); | |
| 129 | if (!ai.ok) return { ok: false, error: `AI failed: ${ai.error}` }; | |
| 130 | if (ai.edits.length === 0) { | |
| 131 | return { ok: false, error: "AI proposed no changes" }; | |
| 132 | } | |
| 133 | ||
| 134 | // 6. Apply edits to a fresh branch via git plumbing. | |
| 135 | const branchName = `spec/${slugify(spec)}-${randomSuffix()}`; | |
| 136 | const commitSubject = ai.summary || `spec: ${spec.slice(0, 60)}`; | |
| 137 | const commitBody = `Generated by spec-to-PR.\n\nSpec:\n${spec}`; | |
| 138 | const commitMessage = `${commitSubject}\n\n${commitBody}`; | |
| 139 | ||
| 140 | const authorEmail = | |
| 141 | authorRow.email || `${authorRow.username}@users.noreply.gluecron`; | |
| 142 | const applied = await applyEditsToNewBranch({ | |
| 143 | repoDiskPath, | |
| 144 | baseRef, | |
| 145 | edits: ai.edits, | |
| 146 | branchName, | |
| 147 | commitMessage, | |
| 148 | authorName: authorRow.username, | |
| 149 | authorEmail, | |
| 150 | }); | |
| 151 | if (!applied.ok) return { ok: false, error: `git apply failed: ${applied.error}` }; | |
| 14c3cc8 | 152 | |
| 23d1a81 | 153 | // 7. Insert the draft PR row. |
| 154 | try { | |
| 155 | const [pr] = await db | |
| 156 | .insert(pullRequests) | |
| 157 | .values({ | |
| 158 | repositoryId: repoRow.id, | |
| 159 | authorId: args.userId, | |
| 160 | title: commitSubject.slice(0, 200), | |
| 161 | body: `${commitBody}\n\nFiles changed:\n${applied.filesChanged | |
| 162 | .map((p) => `- ${p}`) | |
| 163 | .join("\n")}`, | |
| 164 | baseBranch: baseRef, | |
| 165 | headBranch: applied.branchName, | |
| 166 | isDraft: true, | |
| 167 | }) | |
| 168 | .returning(); | |
| 169 | const number = pr?.number; | |
| 170 | if (typeof number !== "number") { | |
| 171 | return { ok: false, error: "PR insert returned no number" }; | |
| 172 | } | |
| 173 | return { | |
| 174 | ok: true, | |
| 175 | prNumber: number, | |
| 176 | branchName: applied.branchName, | |
| 177 | filesChanged: applied.filesChanged, | |
| 178 | }; | |
| 179 | } catch (err) { | |
| 180 | return { | |
| 181 | ok: false, | |
| 182 | error: `PR insert failed: ${err instanceof Error ? err.message : String(err)}`, | |
| 183 | }; | |
| 184 | } | |
| 14c3cc8 | 185 | } |