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.tsxBlame382 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";
18import { repositories, users } from "../db/schema";
19import { 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
111function SpecForm({
112 ownerName,
113 repoName,
114 branches,
115 defaultBranch,
116 spec,
117 baseRef,
118 error,
119}: {
120 ownerName: string;
121 repoName: string;
122 branches: string[];
123 defaultBranch: string;
124 spec?: string;
125 baseRef?: string;
126 error?: string;
127}) {
128 const branchList = branches.length > 0 ? branches : [defaultBranch];
129 const selectedBase = baseRef && branchList.includes(baseRef)
130 ? baseRef
131 : defaultBranch;
132 return (
133 <Container maxWidth={820}>
134 <div
135 class="panel"
136 style="padding:14px 16px;margin-bottom:20px;border-left:3px solid var(--accent)"
137 >
138 <strong>Experimental</strong>
139 {" — "}
140 AI-generated PRs are draft by default. Review every line before
141 merging.
142 </div>
143
144 <h2 style="margin-bottom:4px">Spec to PR</h2>
145 <Text muted style="display:block;margin-bottom:16px">
146 Describe a feature in plain English. Claude will draft the code
147 changes and open a pull request against the branch you choose.
148 </Text>
149
150 {error && <Alert variant="error">{error}</Alert>}
151
152 <Form
153 method="post"
154 action={`/${ownerName}/${repoName}/spec`}
155 id="spec-form"
156 >
157 <FormGroup label="Feature spec" htmlFor="spec">
158 <TextArea
159 name="spec"
160 id="spec"
161 rows={10}
162 required
163 value={spec || ""}
164 placeholder="add a dark mode toggle to the settings page"
165 />
166 </FormGroup>
167
168 <FormGroup label="Base branch" htmlFor="baseRef">
169 <Select name="baseRef" id="baseRef" value={selectedBase}>
170 {branchList.map((b) => (
171 <option value={b} selected={b === selectedBase}>
172 {b}
173 </option>
174 ))}
175 </Select>
176 </FormGroup>
177
178 <Button type="submit" variant="primary">
179 Generate PR with AI
180 </Button>
181 </Form>
182
183 <div class="panel" style="margin-top:28px">
184 <div
185 class="panel-item"
186 style="flex-direction:column;align-items:flex-start;gap:4px;padding:14px 16px"
187 >
188 <strong>How this works</strong>
189 </div>
190 <div class="panel-item" style="padding:12px 16px">
191 <div>
192 <strong>1. You write a spec.</strong>
193 {" "}
194 <Text muted>
195 A sentence or a paragraph describing the change you want.
196 </Text>
197 </div>
198 </div>
199 <div class="panel-item" style="padding:12px 16px">
200 <div>
201 <strong>2. Claude drafts the diff.</strong>
202 {" "}
203 <Text muted>
204 We fetch the base branch, run Claude against the repo, and
205 commit the proposed changes to a new branch.
206 </Text>
207 </div>
208 </div>
209 <div class="panel-item" style="padding:12px 16px">
210 <div>
211 <strong>3. A draft PR opens.</strong>
212 {" "}
213 <Text muted>
214 You review, edit, and merge on your terms. Nothing lands on
215 {" "}
216 <code>{selectedBase}</code> automatically.
217 </Text>
218 </div>
219 </div>
220 </div>
221
222 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
223 </Container>
224 );
225}
226
227specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
228 const { owner, repo } = c.req.param();
229 const user = c.get("user")!;
230
231 const resolved = await resolveRepo(owner, repo);
232 if (!resolved) {
233 return c.html(
234 <Layout title="Not Found" user={user}>
235 <EmptyState title="Repository not found">
236 <p>No such repository.</p>
237 </EmptyState>
238 </Layout>,
239 404
240 );
241 }
242
243 if (!hasWriteAccess(resolved, user.id)) {
244 return c.html(
245 <Layout title="Forbidden" user={user}>
246 <RepoHeader owner={owner} repo={repo} />
247 <EmptyState title="Write access required">
248 <p>You need write access to generate a spec-to-PR on this repository.</p>
249 </EmptyState>
250 </Layout>,
251 403
252 );
253 }
254
255 let branches: string[] = [];
256 try {
257 branches = await listBranches(owner, repo);
258 } catch {
259 branches = [];
260 }
261
262 return c.html(
263 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
264 <RepoHeader owner={owner} repo={repo} />
265 <SpecForm
266 ownerName={owner}
267 repoName={repo}
268 branches={branches}
269 defaultBranch={resolved.defaultBranch}
270 />
271 </Layout>
272 );
273});
274
275specs.post("/: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) return c.notFound();
281
282 if (!hasWriteAccess(resolved, user.id)) {
283 return c.html(
284 <Layout title="Forbidden" user={user}>
285 <RepoHeader owner={owner} repo={repo} />
286 <EmptyState title="Write access required">
287 <p>You need write access to generate a spec-to-PR on this repository.</p>
288 </EmptyState>
289 </Layout>,
290 403
291 );
292 }
293
294 const body = await c.req.parseBody();
295 const spec = String(body.spec || "").trim();
296 const baseRef = String(body.baseRef || resolved.defaultBranch).trim()
297 || resolved.defaultBranch;
298
299 let branches: string[] = [];
300 try {
301 branches = await listBranches(owner, repo);
302 } catch {
303 branches = [];
304 }
305
306 function renderWithError(error: string, status: 400 | 500 | 503 = 400) {
307 return c.html(
308 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
309 <RepoHeader owner={owner} repo={repo} />
310 <SpecForm
311 ownerName={owner}
312 repoName={repo}
313 branches={branches}
314 defaultBranch={resolved!.defaultBranch}
315 spec={spec}
316 baseRef={baseRef}
317 error={error}
318 />
319 </Layout>,
320 status
321 );
322 }
323
324 if (!spec) {
325 return renderWithError("Spec is required.");
326 }
327
328 // Dynamically import the backend so this file works even before the
329 // sibling branch landing `src/lib/spec-to-pr.ts` is merged. If the module
330 // is missing or throws we surface a soft error instead of 500-ing.
331 let createSpecPR:
332 | ((args: {
333 repoId: string;
334 spec: string;
335 baseRef: string;
336 userId: string;
337 }) => Promise<
338 | { ok: true; prNumber: number }
339 | { ok: false; error: string }
340 >)
341 | null = null;
342 try {
343 const mod: any = await import("../lib/spec-to-pr");
344 createSpecPR =
345 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
346 null;
347 } catch {
348 createSpecPR = null;
349 }
350
351 if (!createSpecPR) {
352 return renderWithError(
353 "Backend not available — spec-to-PR is not deployed yet. Please try again later.",
354 503
355 );
356 }
357
358 let result:
359 | { ok: true; prNumber: number }
360 | { ok: false; error: string };
361 try {
362 result = await createSpecPR({
363 repoId: resolved.repoId,
364 spec,
365 baseRef,
366 userId: user.id,
367 });
368 } catch (err) {
369 const msg =
370 err instanceof Error ? err.message : "Unexpected error generating PR.";
371 return renderWithError(`Failed to generate PR: ${msg}`, 500);
372 }
373
374 if (!result || !result.ok) {
375 const msg = (result && "error" in result && result.error) || "Unknown error.";
376 return renderWithError(`Failed to generate PR: ${msg}`);
377 }
378
379 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
380});
381
382export default specs;