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.tsBlame185 lines · 1 contributor
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";
14c3cc8Claude19import { eq } from "drizzle-orm";
20import { db } from "../db";
21import { repositories, users, pullRequests } from "../db/schema";
23d1a81Claude22import { buildSpecContext } from "./spec-context";
23import { generateSpecEdits } from "./spec-ai";
24import { applyEditsToNewBranch } from "./spec-git";
14c3cc8Claude25
26export type SpecPRArgs = {
23d1a81Claude27 repoId: string;
14c3cc8Claude28 spec: string;
29 baseRef?: string;
23d1a81Claude30 userId: string;
14c3cc8Claude31};
32
23d1a81Claude33export 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. */
43function 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
52function randomSuffix(): string {
53 return Math.random().toString(16).slice(2, 8);
54}
55
14c3cc8Claude56export async function createSpecPR(args: SpecPRArgs): Promise<SpecPRResult> {
23d1a81Claude57 // 1. API key gate. Without it the AI step would fail anyway — bail before
58 // any DB or disk work.
14c3cc8Claude59 if (!process.env.ANTHROPIC_API_KEY) {
60 return { ok: false, error: "ANTHROPIC_API_KEY required for spec-to-PR" };
61 }
62
23d1a81Claude63 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;
14c3cc8Claude73 try {
74 const rows = await db
23d1a81Claude75 .select({
76 id: repositories.id,
77 name: repositories.name,
78 defaultBranch: repositories.defaultBranch,
79 ownerName: users.username,
80 })
14c3cc8Claude81 .from(repositories)
23d1a81Claude82 .leftJoin(users, eq(users.id, repositories.ownerId))
83 .where(eq(repositories.id, args.repoId))
14c3cc8Claude84 .limit(1);
23d1a81Claude85 repoRow = rows[0];
86 } catch {
14c3cc8Claude87 return { ok: false, error: "db lookup failed" };
88 }
23d1a81Claude89 if (!repoRow || !repoRow.ownerName) {
90 return { ok: false, error: "repo not found" };
91 }
14c3cc8Claude92
23d1a81Claude93 // 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}` };
14c3cc8Claude152
23d1a81Claude153 // 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 }
14c3cc8Claude185}