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

specs.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

specs.tsxBlame466 lines · 1 contributor
14c3cc8Claude1/**
2 * Spec-to-PR — paste a plain-English feature spec, get back a draft PR
3 * generated by the Claude API.
4 *
5 * GET /:owner/:repo/spec — form (requires write access)
6 * POST /:owner/:repo/spec — hands off to lib/spec-to-pr.ts, redirects to
7 * the new PR on success, re-renders the form
8 * with an error banner on failure
9 *
10 * The backend (`createSpecPR` in `src/lib/spec-to-pr.ts`) is being built in
11 * parallel. We import it dynamically so this file compiles and its tests
12 * pass even if the module is not yet on disk — if the import fails we
13 * fall back to a "Backend not available" banner.
14 */
15import { Hono } from "hono";
16import { and, eq } from "drizzle-orm";
17import { db } from "../db";
fbf4aefClaude18import { repositories, users, issues } from "../db/schema";
14c3cc8Claude19import { Layout } from "../views/layout";
20import { RepoHeader } from "../views/components";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { listBranches } from "../git/repository";
24import {
25 Alert,
26 Button,
27 Container,
28 EmptyState,
29 Form,
30 FormGroup,
31 Select,
32 TextArea,
33 Text,
34} from "../views/ui";
35
36const specs = new Hono<AuthEnv>();
37
38// Tiny inline script that disables the submit button + textarea while the
39// request is in-flight so users don't accidentally double-click and trigger
40// two 10-30s Claude calls. Rendered as a plain <script> tag.
41const DISABLE_ON_SUBMIT_JS = `
42(function() {
43 var form = document.getElementById('spec-form');
44 if (!form) return;
45 form.addEventListener('submit', function() {
46 var btn = form.querySelector('button[type="submit"]');
47 var ta = form.querySelector('textarea[name="spec"]');
48 if (btn) {
49 btn.disabled = true;
50 btn.textContent = 'Working... this can take 10-30s';
51 }
52 if (ta) ta.readOnly = true;
53 });
54})();
55`;
56
57interface ResolvedRepo {
58 ownerId: string;
59 ownerUsername: string;
60 repoId: string;
61 repoName: string;
62 defaultBranch: string;
63}
64
65async function resolveRepo(
66 ownerName: string,
67 repoName: string
68): Promise<ResolvedRepo | null> {
69 try {
70 const [ownerRow] = await db
71 .select()
72 .from(users)
73 .where(eq(users.username, ownerName))
74 .limit(1);
75 if (!ownerRow) return null;
76 const [repoRow] = await db
77 .select()
78 .from(repositories)
79 .where(
80 and(
81 eq(repositories.ownerId, ownerRow.id),
82 eq(repositories.name, repoName)
83 )
84 )
85 .limit(1);
86 if (!repoRow) return null;
87 return {
88 ownerId: ownerRow.id,
89 ownerUsername: ownerRow.username,
90 repoId: repoRow.id,
91 repoName: repoRow.name,
92 defaultBranch: repoRow.defaultBranch || "main",
93 };
94 } catch {
95 return null;
96 }
97}
98
99/**
100 * Write access check. Gluecron has no collaborator table yet, so "write
101 * access" == repo owner. Matches the convention used by repo-settings and
102 * ai-explain's regenerate endpoint.
103 */
104function hasWriteAccess(
105 resolved: ResolvedRepo,
106 userId: string | undefined
107): boolean {
108 return !!userId && resolved.ownerId === userId;
109}
110
fbf4aefClaude111/**
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
14c3cc8Claude143function SpecForm({
144 ownerName,
145 repoName,
146 branches,
147 defaultBranch,
148 spec,
149 baseRef,
150 error,
fbf4aefClaude151 fromIssueNumber,
152 fromIssueTitle,
14c3cc8Claude153}: {
154 ownerName: string;
155 repoName: string;
156 branches: string[];
157 defaultBranch: string;
158 spec?: string;
159 baseRef?: string;
160 error?: string;
fbf4aefClaude161 fromIssueNumber?: number;
162 fromIssueTitle?: string;
14c3cc8Claude163}) {
164 const branchList = branches.length > 0 ? branches : [defaultBranch];
165 const selectedBase = baseRef && branchList.includes(baseRef)
166 ? baseRef
167 : defaultBranch;
168 return (
169 <Container maxWidth={820}>
170 <div
171 class="panel"
172 style="padding:14px 16px;margin-bottom:20px;border-left:3px solid var(--accent)"
173 >
174 <strong>Experimental</strong>
175 {" — "}
176 AI-generated PRs are draft by default. Review every line before
177 merging.
178 </div>
179
fbf4aefClaude180 {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
14c3cc8Claude192 <h2 style="margin-bottom:4px">Spec to PR</h2>
193 <Text muted style="display:block;margin-bottom:16px">
194 Describe a feature in plain English. Claude will draft the code
195 changes and open a pull request against the branch you choose.
196 </Text>
197
198 {error && <Alert variant="error">{error}</Alert>}
199
200 <Form
201 method="post"
202 action={`/${ownerName}/${repoName}/spec`}
203 id="spec-form"
204 >
205 <FormGroup label="Feature spec" htmlFor="spec">
206 <TextArea
207 name="spec"
208 id="spec"
209 rows={10}
210 required
211 value={spec || ""}
212 placeholder="add a dark mode toggle to the settings page"
213 />
214 </FormGroup>
215
216 <FormGroup label="Base branch" htmlFor="baseRef">
217 <Select name="baseRef" id="baseRef" value={selectedBase}>
218 {branchList.map((b) => (
219 <option value={b} selected={b === selectedBase}>
220 {b}
221 </option>
222 ))}
223 </Select>
224 </FormGroup>
225
226 <Button type="submit" variant="primary">
227 Generate PR with AI
228 </Button>
229 </Form>
230
231 <div class="panel" style="margin-top:28px">
232 <div
233 class="panel-item"
234 style="flex-direction:column;align-items:flex-start;gap:4px;padding:14px 16px"
235 >
236 <strong>How this works</strong>
237 </div>
238 <div class="panel-item" style="padding:12px 16px">
239 <div>
240 <strong>1. You write a spec.</strong>
241 {" "}
242 <Text muted>
243 A sentence or a paragraph describing the change you want.
244 </Text>
245 </div>
246 </div>
247 <div class="panel-item" style="padding:12px 16px">
248 <div>
249 <strong>2. Claude drafts the diff.</strong>
250 {" "}
251 <Text muted>
252 We fetch the base branch, run Claude against the repo, and
253 commit the proposed changes to a new branch.
254 </Text>
255 </div>
256 </div>
257 <div class="panel-item" style="padding:12px 16px">
258 <div>
259 <strong>3. A draft PR opens.</strong>
260 {" "}
261 <Text muted>
262 You review, edit, and merge on your terms. Nothing lands on
263 {" "}
264 <code>{selectedBase}</code> automatically.
265 </Text>
266 </div>
267 </div>
268 </div>
269
270 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
271 </Container>
272 );
273}
274
275specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
276 const { owner, repo } = c.req.param();
277 const user = c.get("user")!;
278
279 const resolved = await resolveRepo(owner, repo);
280 if (!resolved) {
281 return c.html(
282 <Layout title="Not Found" user={user}>
283 <EmptyState title="Repository not found">
284 <p>No such repository.</p>
285 </EmptyState>
286 </Layout>,
287 404
288 );
289 }
290
291 if (!hasWriteAccess(resolved, user.id)) {
292 return c.html(
293 <Layout title="Forbidden" user={user}>
294 <RepoHeader owner={owner} repo={repo} />
295 <EmptyState title="Write access required">
296 <p>You need write access to generate a spec-to-PR on this repository.</p>
297 </EmptyState>
298 </Layout>,
299 403
300 );
301 }
302
303 let branches: string[] = [];
304 try {
305 branches = await listBranches(owner, repo);
306 } catch {
307 branches = [];
308 }
309
fbf4aefClaude310 // 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
14c3cc8Claude343 return c.html(
344 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
345 <RepoHeader owner={owner} repo={repo} />
346 <SpecForm
347 ownerName={owner}
348 repoName={repo}
349 branches={branches}
350 defaultBranch={resolved.defaultBranch}
fbf4aefClaude351 spec={prefilledSpec}
352 fromIssueNumber={fromIssueNumber}
353 fromIssueTitle={fromIssueTitle}
14c3cc8Claude354 />
355 </Layout>
356 );
357});
358
359specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
360 const { owner, repo } = c.req.param();
361 const user = c.get("user")!;
362
363 const resolved = await resolveRepo(owner, repo);
364 if (!resolved) return c.notFound();
365
366 if (!hasWriteAccess(resolved, user.id)) {
367 return c.html(
368 <Layout title="Forbidden" user={user}>
369 <RepoHeader owner={owner} repo={repo} />
370 <EmptyState title="Write access required">
371 <p>You need write access to generate a spec-to-PR on this repository.</p>
372 </EmptyState>
373 </Layout>,
374 403
375 );
376 }
377
378 const body = await c.req.parseBody();
379 const spec = String(body.spec || "").trim();
380 const baseRef = String(body.baseRef || resolved.defaultBranch).trim()
381 || resolved.defaultBranch;
382
383 let branches: string[] = [];
384 try {
385 branches = await listBranches(owner, repo);
386 } catch {
387 branches = [];
388 }
389
390 function renderWithError(error: string, status: 400 | 500 | 503 = 400) {
391 return c.html(
392 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
393 <RepoHeader owner={owner} repo={repo} />
394 <SpecForm
395 ownerName={owner}
396 repoName={repo}
397 branches={branches}
398 defaultBranch={resolved!.defaultBranch}
399 spec={spec}
400 baseRef={baseRef}
401 error={error}
402 />
403 </Layout>,
404 status
405 );
406 }
407
408 if (!spec) {
409 return renderWithError("Spec is required.");
410 }
411
412 // Dynamically import the backend so this file works even before the
413 // sibling branch landing `src/lib/spec-to-pr.ts` is merged. If the module
414 // is missing or throws we surface a soft error instead of 500-ing.
415 let createSpecPR:
416 | ((args: {
417 repoId: string;
418 spec: string;
419 baseRef: string;
420 userId: string;
421 }) => Promise<
422 | { ok: true; prNumber: number }
423 | { ok: false; error: string }
424 >)
425 | null = null;
426 try {
427 const mod: any = await import("../lib/spec-to-pr");
428 createSpecPR =
429 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
430 null;
431 } catch {
432 createSpecPR = null;
433 }
434
435 if (!createSpecPR) {
436 return renderWithError(
437 "Backend not available — spec-to-PR is not deployed yet. Please try again later.",
438 503
439 );
440 }
441
442 let result:
443 | { ok: true; prNumber: number }
444 | { ok: false; error: string };
445 try {
446 result = await createSpecPR({
447 repoId: resolved.repoId,
448 spec,
449 baseRef,
450 userId: user.id,
451 });
452 } catch (err) {
453 const msg =
454 err instanceof Error ? err.message : "Unexpected error generating PR.";
455 return renderWithError(`Failed to generate PR: ${msg}`, 500);
456 }
457
458 if (!result || !result.ok) {
459 const msg = (result && "error" in result && result.error) || "Unknown error.";
460 return renderWithError(`Failed to generate PR: ${msg}`);
461 }
462
463 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
464});
465
466export default specs;