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