Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit29c895eunknown_key

Merge pull request #28 from ccantynz-alt/claude/issue-to-pr-and-protections

Merge pull request #28 from ccantynz-alt/claude/issue-to-pr-and-protections

feat(spec): one-click Build with AI from any open issue
ccantynz App committed on April 30, 2026Parents: b4be381 fbf4aef
3 files changed+177129c895ef4e288a8dcd2f83acb75831cdd6557a95
3 changed files+177−1
Modifiedsrc/__tests__/specs.test.ts+82−0View fileUnifiedSplit
101101 });
102102 expect(res.status).toBeLessThan(500);
103103 });
104
105 it("GET /spec?fromIssue=N is not a 500 even when the issue/repo doesn't exist", async () => {
106 const loaded = await tryLoadSpecsRoute();
107 if (!loaded.ok) {
108 expect(loaded.reason).toBe("jsx-dev-runtime");
109 return;
110 }
111 const res = await loaded.mod.default.request(
112 "/alice/demo/spec?fromIssue=123",
113 { redirect: "manual" }
114 );
115 expect(res.status).toBeLessThan(500);
116 });
117
118 it("GET /spec?fromIssue=garbage is not a 500", async () => {
119 const loaded = await tryLoadSpecsRoute();
120 if (!loaded.ok) {
121 expect(loaded.reason).toBe("jsx-dev-runtime");
122 return;
123 }
124 const res = await loaded.mod.default.request(
125 "/alice/demo/spec?fromIssue=not-a-number",
126 { redirect: "manual" }
127 );
128 expect(res.status).toBeLessThan(500);
129 });
130});
131
132describe("routes/specs — buildSpecFromIssue pure helper", () => {
133 it("emits Implement: <title>, body, then Closes #N", async () => {
134 const loaded = await tryLoadSpecsRoute();
135 if (!loaded.ok) {
136 expect(loaded.reason).toBe("jsx-dev-runtime");
137 return;
138 }
139 const fn = loaded.mod.buildSpecFromIssue;
140 expect(typeof fn).toBe("function");
141 const out = fn({
142 number: 42,
143 title: "Add dark mode toggle",
144 body: "It should sit in the navbar and persist via cookie.",
145 });
146 expect(out).toContain("Implement: Add dark mode toggle");
147 expect(out).toContain("It should sit in the navbar and persist via cookie.");
148 expect(out).toContain("Closes #42");
149 // Closes line should be last so close-keywords (J7) parses cleanly.
150 expect(out.trimEnd().endsWith("Closes #42")).toBe(true);
151 });
152
153 it("handles a missing/empty body — still emits the Closes line", async () => {
154 const loaded = await tryLoadSpecsRoute();
155 if (!loaded.ok) {
156 expect(loaded.reason).toBe("jsx-dev-runtime");
157 return;
158 }
159 const fn = loaded.mod.buildSpecFromIssue;
160 const a = fn({ number: 7, title: "Bug: race in upload handler", body: null });
161 const b = fn({ number: 7, title: "Bug: race in upload handler", body: "" });
162 const c = fn({ number: 7, title: "Bug: race in upload handler", body: " " });
163 for (const out of [a, b, c]) {
164 expect(out).toContain("Implement: Bug: race in upload handler");
165 expect(out).toContain("Closes #7");
166 }
167 });
168
169 it("trims surrounding whitespace from the title and body", async () => {
170 const loaded = await tryLoadSpecsRoute();
171 if (!loaded.ok) {
172 expect(loaded.reason).toBe("jsx-dev-runtime");
173 return;
174 }
175 const fn = loaded.mod.buildSpecFromIssue;
176 const out = fn({
177 number: 1,
178 title: " leading + trailing ",
179 body: " body text ",
180 });
181 expect(out).toContain("Implement: leading + trailing");
182 expect(out).toContain("body text");
183 // No leading whitespace on the title line.
184 expect(out.startsWith("Implement: leading + trailing")).toBe(true);
185 });
104186});
Modifiedsrc/routes/issues.tsx+10−0View fileUnifiedSplit
358358 </strong>{" "}
359359 opened this issue {formatRelative(issue.createdAt)}
360360 </span>
361 {issue.state === "open" && user && user.id === resolved.owner.id && (
362 <a
363 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
364 class="btn btn-primary"
365 style="margin-left:auto;font-size:13px;padding:4px 10px"
366 title="Generate a draft pull request from this issue using Claude"
367 >
368 Build with AI
369 </a>
370 )}
361371 </Flex>
362372
363373 {issue.body && (
Modifiedsrc/routes/specs.tsx+85−1View fileUnifiedSplit
1515import { Hono } from "hono";
1616import { and, eq } from "drizzle-orm";
1717import { db } from "../db";
18import { repositories, users } from "../db/schema";
18import { repositories, users, issues } from "../db/schema";
1919import { Layout } from "../views/layout";
2020import { RepoHeader } from "../views/components";
2121import { softAuth, requireAuth } from "../middleware/auth";
108108 return !!userId && resolved.ownerId === userId;
109109}
110110
111/**
112 * Build the default spec text for an issue-driven generation. Format:
113 *
114 * Implement: <title>
115 *
116 * <body>
117 *
118 * Closes #<n>
119 *
120 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
121 * so the issue auto-closes when the AI-generated PR is merged. Body is
122 * trimmed and falls back to an empty string if missing. Pure helper —
123 * exported for tests.
124 */
125export function buildSpecFromIssue(input: {
126 number: number;
127 title: string;
128 body: string | null | undefined;
129}): string {
130 const title = (input.title || "").trim();
131 const body = (input.body || "").trim();
132 const lines: string[] = [];
133 if (title) lines.push(`Implement: ${title}`);
134 if (body) {
135 lines.push("");
136 lines.push(body);
137 }
138 lines.push("");
139 lines.push(`Closes #${input.number}`);
140 return lines.join("\n");
141}
142
111143function SpecForm({
112144 ownerName,
113145 repoName,
116148 spec,
117149 baseRef,
118150 error,
151 fromIssueNumber,
152 fromIssueTitle,
119153}: {
120154 ownerName: string;
121155 repoName: string;
124158 spec?: string;
125159 baseRef?: string;
126160 error?: string;
161 fromIssueNumber?: number;
162 fromIssueTitle?: string;
127163}) {
128164 const branchList = branches.length > 0 ? branches : [defaultBranch];
129165 const selectedBase = baseRef && branchList.includes(baseRef)
141177 merging.
142178 </div>
143179
180 {fromIssueNumber && (
181 <Alert variant="info">
182 Building from issue{" "}
183 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
184 #{fromIssueNumber}
185 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
186 </a>
187 . The spec below has been pre-filled from the issue and will
188 auto-close it on merge.
189 </Alert>
190 )}
191
144192 <h2 style="margin-bottom:4px">Spec to PR</h2>
145193 <Text muted style="display:block;margin-bottom:16px">
146194 Describe a feature in plain English. Claude will draft the code
259307 branches = [];
260308 }
261309
310 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
311 // button on the issue detail page. Silently no-ops on missing/unknown
312 // issue so the form still renders.
313 let prefilledSpec: string | undefined;
314 let fromIssueNumber: number | undefined;
315 let fromIssueTitle: string | undefined;
316 const fromIssueRaw = c.req.query("fromIssue");
317 if (fromIssueRaw) {
318 const n = Number.parseInt(fromIssueRaw, 10);
319 if (Number.isInteger(n) && n > 0) {
320 try {
321 const [issueRow] = await db
322 .select()
323 .from(issues)
324 .where(
325 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
326 )
327 .limit(1);
328 if (issueRow) {
329 fromIssueNumber = issueRow.number;
330 fromIssueTitle = issueRow.title;
331 prefilledSpec = buildSpecFromIssue({
332 number: issueRow.number,
333 title: issueRow.title,
334 body: issueRow.body,
335 });
336 }
337 } catch {
338 // Pre-fill is a convenience, never block form render.
339 }
340 }
341 }
342
262343 return c.html(
263344 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
264345 <RepoHeader owner={owner} repo={repo} />
267348 repoName={repo}
268349 branches={branches}
269350 defaultBranch={resolved.defaultBranch}
351 spec={prefilledSpec}
352 fromIssueNumber={fromIssueNumber}
353 fromIssueTitle={fromIssueTitle}
270354 />
271355 </Layout>
272356 );
273357