Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame828 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.
93812e4Claude14 *
15 * 2026 polish: scoped `.specs-*` class system. Eyebrow + display headline +
16 * subtitle hero, card sections for the form + the "how this works" steps,
17 * primary CTA on the submit button. All form fields, names, actions, and
18 * the dynamic-import behaviour are unchanged.
14c3cc8Claude19 */
20import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
fbf4aefClaude23import { repositories, users, issues } from "../db/schema";
14c3cc8Claude24import { Layout } from "../views/layout";
25import { RepoHeader } from "../views/components";
26import { softAuth, requireAuth } from "../middleware/auth";
27import type { AuthEnv } from "../middleware/auth";
28import { listBranches } from "../git/repository";
29
30const specs = new Hono<AuthEnv>();
31
32// Tiny inline script that disables the submit button + textarea while the
33// request is in-flight so users don't accidentally double-click and trigger
34// two 10-30s Claude calls. Rendered as a plain <script> tag.
35const DISABLE_ON_SUBMIT_JS = `
36(function() {
37 var form = document.getElementById('spec-form');
38 if (!form) return;
39 form.addEventListener('submit', function() {
40 var btn = form.querySelector('button[type="submit"]');
41 var ta = form.querySelector('textarea[name="spec"]');
42 if (btn) {
43 btn.disabled = true;
93812e4Claude44 btn.textContent = 'Working… this can take 10-30s';
14c3cc8Claude45 }
46 if (ta) ta.readOnly = true;
47 });
48})();
49`;
50
93812e4Claude51// ─── Scoped CSS (.specs-*) ─────────────────────────────────────────────────
52const specsStyles = `
53 .specs-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
54
55 /* ─── Header ─── */
56 .specs-head { margin-bottom: var(--space-5); }
57 .specs-eyebrow {
58 display: inline-flex;
59 align-items: center;
60 gap: 8px;
61 text-transform: uppercase;
62 font-family: var(--font-mono);
63 font-size: 11px;
64 letter-spacing: 0.16em;
65 color: var(--text-muted);
66 font-weight: 600;
67 margin-bottom: 10px;
68 }
69 .specs-eyebrow-dot {
70 width: 8px; height: 8px;
71 border-radius: 9999px;
72 background: linear-gradient(135deg, #8c6dff, #36c5d6);
73 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
74 }
75 .specs-pill-experimental {
76 display: inline-flex;
77 align-items: center;
78 gap: 5px;
79 padding: 2px 8px;
80 margin-left: 4px;
81 border-radius: 9999px;
82 font-size: 10px;
83 font-weight: 700;
84 letter-spacing: 0.06em;
85 background: rgba(251,191,36,0.12);
86 color: #fde68a;
87 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
88 }
89 .specs-title {
90 font-family: var(--font-display);
91 font-size: clamp(26px, 3.6vw, 40px);
92 font-weight: 800;
93 letter-spacing: -0.028em;
94 line-height: 1.05;
95 margin: 0 0 6px;
96 color: var(--text-strong);
97 }
98 .specs-title-grad {
99 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
100 -webkit-background-clip: text;
101 background-clip: text;
102 -webkit-text-fill-color: transparent;
103 color: transparent;
104 }
105 .specs-sub {
106 margin: 0;
107 font-size: 14px;
108 color: var(--text-muted);
109 line-height: 1.5;
110 max-width: 720px;
111 }
112 .specs-sub a { color: var(--accent); text-decoration: none; }
113 .specs-sub a:hover { text-decoration: underline; }
114 .specs-sub code {
115 font-family: var(--font-mono);
116 font-size: 12px;
117 background: rgba(255,255,255,0.04);
118 padding: 1px 6px;
119 border-radius: 4px;
120 }
121
122 /* ─── Banners ─── */
123 .specs-banner {
124 margin-bottom: var(--space-4);
125 padding: 10px 14px;
126 border-radius: 10px;
127 font-size: 13.5px;
128 border: 1px solid var(--border);
129 background: rgba(255,255,255,0.025);
130 color: var(--text);
131 display: flex;
132 align-items: flex-start;
133 gap: 10px;
134 line-height: 1.45;
135 }
136 .specs-banner.is-info {
137 border-color: rgba(54,197,214,0.40);
138 background: rgba(54,197,214,0.08);
139 color: #cffafe;
140 }
141 .specs-banner.is-error {
142 border-color: rgba(248,113,113,0.40);
143 background: rgba(248,113,113,0.08);
144 color: #fecaca;
145 }
146 .specs-banner-dot {
147 width: 8px; height: 8px;
148 border-radius: 9999px;
149 background: currentColor;
150 margin-top: 6px;
151 flex-shrink: 0;
152 }
153 .specs-banner a {
154 color: inherit;
155 text-decoration: underline;
156 font-weight: 600;
157 }
158
159 /* ─── Section card ─── */
160 .specs-section {
161 margin-bottom: var(--space-5);
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 14px;
165 overflow: hidden;
166 position: relative;
167 }
168 .specs-section::before {
169 content: '';
170 position: absolute;
171 top: 0; left: 0; right: 0;
172 height: 2px;
173 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
174 opacity: 0.55;
175 pointer-events: none;
176 }
177 .specs-section-head {
178 padding: var(--space-4) var(--space-5) var(--space-3);
179 border-bottom: 1px solid var(--border);
180 }
181 .specs-section-title {
182 margin: 0;
183 font-family: var(--font-display);
184 font-size: 16px;
185 font-weight: 700;
186 letter-spacing: -0.018em;
187 color: var(--text-strong);
188 }
189 .specs-section-sub {
190 margin: 6px 0 0;
191 font-size: 12.5px;
192 color: var(--text-muted);
193 line-height: 1.45;
194 }
195 .specs-section-body {
196 padding: var(--space-4) var(--space-5);
197 }
198
199 /* ─── Form fields ─── */
200 .specs-field { margin-bottom: var(--space-4); }
201 .specs-field:last-child { margin-bottom: 0; }
202 .specs-field-label {
203 display: block;
204 font-size: 11.5px;
205 font-weight: 600;
206 text-transform: uppercase;
207 letter-spacing: 0.06em;
208 color: var(--text-muted);
209 margin-bottom: 6px;
210 }
211 .specs-input,
212 .specs-select,
213 .specs-textarea {
214 width: 100%;
215 box-sizing: border-box;
216 padding: 10px 12px;
217 font: inherit;
218 font-size: 13.5px;
219 color: var(--text);
220 background: rgba(255,255,255,0.03);
221 border: 1px solid var(--border-strong);
222 border-radius: 10px;
223 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
224 }
225 .specs-textarea {
226 font-family: var(--font-mono);
227 font-size: 13px;
228 line-height: 1.55;
229 resize: vertical;
230 min-height: 200px;
231 }
232 .specs-input:focus,
233 .specs-select:focus,
234 .specs-textarea:focus {
235 outline: none;
236 border-color: rgba(140,109,255,0.55);
237 background: rgba(255,255,255,0.05);
238 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
239 }
240 .specs-select {
241 appearance: none;
242 padding-right: 30px;
243 background-image:
244 linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
245 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
246 background-position: right 12px top 50%, right 7px top 50%;
247 background-size: 5px 5px, 5px 5px;
248 background-repeat: no-repeat;
249 }
250 .specs-field-hint {
251 margin-top: 6px;
252 font-size: 11.5px;
253 color: var(--text-muted);
254 line-height: 1.45;
255 }
256
257 /* ─── Buttons ─── */
258 .specs-actions {
259 display: flex;
260 align-items: center;
261 gap: 10px;
262 flex-wrap: wrap;
263 padding-top: 4px;
264 }
265 .specs-btn {
266 display: inline-flex;
267 align-items: center;
268 justify-content: center;
269 gap: 8px;
270 padding: 10px 18px;
271 border-radius: 10px;
272 font-size: 13.5px;
273 font-weight: 600;
274 text-decoration: none;
275 border: 1px solid transparent;
276 cursor: pointer;
277 font: inherit;
278 line-height: 1;
279 white-space: nowrap;
280 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
281 }
282 .specs-btn-primary {
283 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
284 color: #ffffff;
285 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
286 }
287 .specs-btn-primary:hover {
288 transform: translateY(-1px);
289 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
290 color: #ffffff;
291 text-decoration: none;
292 }
293 .specs-btn-primary:disabled {
294 cursor: not-allowed;
295 opacity: 0.6;
296 transform: none;
297 box-shadow: none;
298 }
299 .specs-actions-hint {
300 font-size: 12px;
301 color: var(--text-muted);
302 }
303
304 /* ─── How-this-works step cards ─── */
305 .specs-steps {
306 display: grid;
307 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
308 gap: 10px;
309 }
310 .specs-step {
311 padding: 14px;
312 background: rgba(255,255,255,0.018);
313 border: 1px solid var(--border);
314 border-radius: 11px;
315 transition: border-color 120ms ease, background 120ms ease;
316 }
317 .specs-step:hover {
318 border-color: var(--border-strong);
319 background: rgba(255,255,255,0.03);
320 }
321 .specs-step-num {
322 display: inline-flex;
323 align-items: center;
324 justify-content: center;
325 width: 24px; height: 24px;
326 border-radius: 7px;
327 background: rgba(140,109,255,0.16);
328 color: #c4b5fd;
329 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
330 font-family: var(--font-mono);
331 font-size: 12px;
332 font-weight: 700;
333 margin-bottom: 10px;
334 }
335 .specs-step-title {
336 margin: 0 0 4px;
337 font-family: var(--font-display);
338 font-size: 13.5px;
339 font-weight: 700;
340 color: var(--text-strong);
341 letter-spacing: -0.005em;
342 }
343 .specs-step-body {
344 margin: 0;
345 font-size: 12.5px;
346 color: var(--text-muted);
347 line-height: 1.5;
348 }
349 .specs-step-body code {
350 font-family: var(--font-mono);
351 font-size: 11.5px;
352 background: rgba(255,255,255,0.04);
353 padding: 1px 5px;
354 border-radius: 4px;
355 color: var(--text);
356 }
357
358 /* ─── 403 / not-found ─── */
359 .specs-empty {
360 max-width: 540px;
361 margin: var(--space-8) auto;
362 padding: var(--space-6);
363 text-align: center;
364 background: var(--bg-elevated);
365 border: 1px dashed var(--border-strong);
366 border-radius: 16px;
367 }
368 .specs-empty h2 {
369 font-family: var(--font-display);
370 font-size: 20px;
371 margin: 0 0 8px;
372 color: var(--text-strong);
373 }
374 .specs-empty p {
375 margin: 0;
376 color: var(--text-muted);
377 font-size: 13.5px;
378 }
379`;
380
14c3cc8Claude381interface ResolvedRepo {
382 ownerId: string;
383 ownerUsername: string;
384 repoId: string;
385 repoName: string;
386 defaultBranch: string;
387}
388
389async function resolveRepo(
390 ownerName: string,
391 repoName: string
392): Promise<ResolvedRepo | null> {
393 try {
394 const [ownerRow] = await db
395 .select()
396 .from(users)
397 .where(eq(users.username, ownerName))
398 .limit(1);
399 if (!ownerRow) return null;
400 const [repoRow] = await db
401 .select()
402 .from(repositories)
403 .where(
404 and(
405 eq(repositories.ownerId, ownerRow.id),
406 eq(repositories.name, repoName)
407 )
408 )
409 .limit(1);
410 if (!repoRow) return null;
411 return {
412 ownerId: ownerRow.id,
413 ownerUsername: ownerRow.username,
414 repoId: repoRow.id,
415 repoName: repoRow.name,
416 defaultBranch: repoRow.defaultBranch || "main",
417 };
418 } catch {
419 return null;
420 }
421}
422
423/**
424 * Write access check. Gluecron has no collaborator table yet, so "write
425 * access" == repo owner. Matches the convention used by repo-settings and
426 * ai-explain's regenerate endpoint.
427 */
428function hasWriteAccess(
429 resolved: ResolvedRepo,
430 userId: string | undefined
431): boolean {
432 return !!userId && resolved.ownerId === userId;
433}
434
fbf4aefClaude435/**
436 * Build the default spec text for an issue-driven generation. Format:
437 *
438 * Implement: <title>
439 *
440 * <body>
441 *
442 * Closes #<n>
443 *
444 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
445 * so the issue auto-closes when the AI-generated PR is merged. Body is
446 * trimmed and falls back to an empty string if missing. Pure helper —
447 * exported for tests.
448 */
449export function buildSpecFromIssue(input: {
450 number: number;
451 title: string;
452 body: string | null | undefined;
453}): string {
454 const title = (input.title || "").trim();
455 const body = (input.body || "").trim();
456 const lines: string[] = [];
457 if (title) lines.push(`Implement: ${title}`);
458 if (body) {
459 lines.push("");
460 lines.push(body);
461 }
462 lines.push("");
463 lines.push(`Closes #${input.number}`);
464 return lines.join("\n");
465}
466
14c3cc8Claude467function SpecForm({
468 ownerName,
469 repoName,
470 branches,
471 defaultBranch,
472 spec,
473 baseRef,
474 error,
fbf4aefClaude475 fromIssueNumber,
476 fromIssueTitle,
14c3cc8Claude477}: {
478 ownerName: string;
479 repoName: string;
480 branches: string[];
481 defaultBranch: string;
482 spec?: string;
483 baseRef?: string;
484 error?: string;
fbf4aefClaude485 fromIssueNumber?: number;
486 fromIssueTitle?: string;
14c3cc8Claude487}) {
488 const branchList = branches.length > 0 ? branches : [defaultBranch];
489 const selectedBase = baseRef && branchList.includes(baseRef)
490 ? baseRef
491 : defaultBranch;
492 return (
93812e4Claude493 <div class="specs-wrap">
494 <header class="specs-head">
495 <div class="specs-eyebrow">
496 <span class="specs-eyebrow-dot" aria-hidden="true" />
497 Repository · Spec to PR
498 <span class="specs-pill-experimental">Experimental</span>
499 </div>
500 <h1 class="specs-title">
501 <span class="specs-title-grad">Describe it. Ship a draft.</span>
502 </h1>
503 <p class="specs-sub">
504 Write a feature in plain English. Claude drafts the code changes
505 and opens a pull request against the branch you pick. Every PR is{" "}
506 <strong>draft by default</strong> — review every line before merging.
507 </p>
508 </header>
14c3cc8Claude509
fbf4aefClaude510 {fromIssueNumber && (
93812e4Claude511 <div class="specs-banner is-info" role="status">
512 <span class="specs-banner-dot" aria-hidden="true" />
513 <span>
514 Building from issue{" "}
515 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
516 #{fromIssueNumber}
517 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
518 </a>
519 . The spec below has been pre-filled and will auto-close the
520 issue on merge.
521 </span>
522 </div>
fbf4aefClaude523 )}
524
93812e4Claude525 {error && (
526 <div class="specs-banner is-error" role="alert">
527 <span class="specs-banner-dot" aria-hidden="true" />
528 <span>{error}</span>
14c3cc8Claude529 </div>
93812e4Claude530 )}
531
532 <section class="specs-section">
533 <header class="specs-section-head">
534 <h2 class="specs-section-title">Feature spec</h2>
535 <p class="specs-section-sub">
536 One sentence or a paragraph. Be specific about files, behaviour,
537 or success criteria when you can.
538 </p>
539 </header>
540 <div class="specs-section-body">
541 <form
542 method="post"
543 action={`/${ownerName}/${repoName}/spec`}
544 id="spec-form"
545 >
546 <div class="specs-field">
547 <label class="specs-field-label" for="spec">What do you want built?</label>
548 <textarea
549 class="specs-textarea"
550 name="spec"
551 id="spec"
552 rows={10}
553 required
554 placeholder="add a dark mode toggle to the settings page"
555 >{spec || ""}</textarea>
556 </div>
557
558 <div class="specs-field">
559 <label class="specs-field-label" for="baseRef">Base branch</label>
560 <select
561 class="specs-select"
562 name="baseRef"
563 id="baseRef"
564 value={selectedBase}
565 >
566 {branchList.map((b) => (
567 <option value={b} selected={b === selectedBase}>
568 {b}
569 </option>
570 ))}
571 </select>
572 <p class="specs-field-hint">
573 The draft PR will target this branch. Nothing lands without
574 your approval.
575 </p>
576 </div>
577
578 <div class="specs-actions">
579 <button type="submit" class="specs-btn specs-btn-primary">
580 Generate PR with AI
581 </button>
582 <span class="specs-actions-hint">Typically 10-30 seconds.</span>
583 </div>
584 </form>
14c3cc8Claude585 </div>
93812e4Claude586 </section>
587
588 <section class="specs-section">
589 <header class="specs-section-head">
590 <h2 class="specs-section-title">How this works</h2>
591 <p class="specs-section-sub">
592 Three steps from spec to mergeable diff — all observable, all
593 reversible.
594 </p>
595 </header>
596 <div class="specs-section-body">
597 <div class="specs-steps">
598 <div class="specs-step">
599 <span class="specs-step-num">1</span>
600 <h3 class="specs-step-title">You write a spec.</h3>
601 <p class="specs-step-body">
602 A sentence or a paragraph describing the change you want.
603 </p>
604 </div>
605 <div class="specs-step">
606 <span class="specs-step-num">2</span>
607 <h3 class="specs-step-title">Claude drafts the diff.</h3>
608 <p class="specs-step-body">
609 We fetch the base branch, run Claude against the repo, and
610 commit the proposed changes to a new branch.
611 </p>
612 </div>
613 <div class="specs-step">
614 <span class="specs-step-num">3</span>
615 <h3 class="specs-step-title">A draft PR opens.</h3>
616 <p class="specs-step-body">
617 You review, edit, and merge on your terms. Nothing lands on{" "}
618 <code>{selectedBase}</code> automatically.
619 </p>
620 </div>
14c3cc8Claude621 </div>
622 </div>
93812e4Claude623 </section>
14c3cc8Claude624
625 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
93812e4Claude626 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
627 </div>
14c3cc8Claude628 );
629}
630
631specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
632 const { owner, repo } = c.req.param();
633 const user = c.get("user")!;
634
635 const resolved = await resolveRepo(owner, repo);
636 if (!resolved) {
637 return c.html(
638 <Layout title="Not Found" user={user}>
93812e4Claude639 <div class="specs-empty">
640 <h2>Repository not found</h2>
14c3cc8Claude641 <p>No such repository.</p>
93812e4Claude642 </div>
643 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude644 </Layout>,
645 404
646 );
647 }
648
649 if (!hasWriteAccess(resolved, user.id)) {
650 return c.html(
651 <Layout title="Forbidden" user={user}>
652 <RepoHeader owner={owner} repo={repo} />
93812e4Claude653 <div class="specs-empty">
654 <h2>Write access required</h2>
14c3cc8Claude655 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude656 </div>
657 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude658 </Layout>,
659 403
660 );
661 }
662
663 let branches: string[] = [];
664 try {
665 branches = await listBranches(owner, repo);
666 } catch {
667 branches = [];
668 }
669
fbf4aefClaude670 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
671 // button on the issue detail page. Silently no-ops on missing/unknown
672 // issue so the form still renders.
673 let prefilledSpec: string | undefined;
674 let fromIssueNumber: number | undefined;
675 let fromIssueTitle: string | undefined;
676 const fromIssueRaw = c.req.query("fromIssue");
677 if (fromIssueRaw) {
678 const n = Number.parseInt(fromIssueRaw, 10);
679 if (Number.isInteger(n) && n > 0) {
680 try {
681 const [issueRow] = await db
682 .select()
683 .from(issues)
684 .where(
685 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
686 )
687 .limit(1);
688 if (issueRow) {
689 fromIssueNumber = issueRow.number;
690 fromIssueTitle = issueRow.title;
691 prefilledSpec = buildSpecFromIssue({
692 number: issueRow.number,
693 title: issueRow.title,
694 body: issueRow.body,
695 });
696 }
697 } catch {
698 // Pre-fill is a convenience, never block form render.
699 }
700 }
701 }
702
14c3cc8Claude703 return c.html(
704 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
705 <RepoHeader owner={owner} repo={repo} />
706 <SpecForm
707 ownerName={owner}
708 repoName={repo}
709 branches={branches}
710 defaultBranch={resolved.defaultBranch}
fbf4aefClaude711 spec={prefilledSpec}
712 fromIssueNumber={fromIssueNumber}
713 fromIssueTitle={fromIssueTitle}
14c3cc8Claude714 />
715 </Layout>
716 );
717});
718
719specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
720 const { owner, repo } = c.req.param();
721 const user = c.get("user")!;
722
723 const resolved = await resolveRepo(owner, repo);
724 if (!resolved) return c.notFound();
725
726 if (!hasWriteAccess(resolved, user.id)) {
727 return c.html(
728 <Layout title="Forbidden" user={user}>
729 <RepoHeader owner={owner} repo={repo} />
93812e4Claude730 <div class="specs-empty">
731 <h2>Write access required</h2>
14c3cc8Claude732 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude733 </div>
734 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude735 </Layout>,
736 403
737 );
738 }
739
740 const body = await c.req.parseBody();
741 const spec = String(body.spec || "").trim();
742 const baseRef = String(body.baseRef || resolved.defaultBranch).trim()
743 || resolved.defaultBranch;
744
745 let branches: string[] = [];
746 try {
747 branches = await listBranches(owner, repo);
748 } catch {
749 branches = [];
750 }
751
752 function renderWithError(error: string, status: 400 | 500 | 503 = 400) {
753 return c.html(
754 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
755 <RepoHeader owner={owner} repo={repo} />
756 <SpecForm
757 ownerName={owner}
758 repoName={repo}
759 branches={branches}
760 defaultBranch={resolved!.defaultBranch}
761 spec={spec}
762 baseRef={baseRef}
763 error={error}
764 />
765 </Layout>,
766 status
767 );
768 }
769
770 if (!spec) {
771 return renderWithError("Spec is required.");
772 }
773
774 // Dynamically import the backend so this file works even before the
775 // sibling branch landing `src/lib/spec-to-pr.ts` is merged. If the module
776 // is missing or throws we surface a soft error instead of 500-ing.
777 let createSpecPR:
778 | ((args: {
779 repoId: string;
780 spec: string;
781 baseRef: string;
782 userId: string;
783 }) => Promise<
784 | { ok: true; prNumber: number }
785 | { ok: false; error: string }
786 >)
787 | null = null;
788 try {
789 const mod: any = await import("../lib/spec-to-pr");
790 createSpecPR =
791 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
792 null;
793 } catch {
794 createSpecPR = null;
795 }
796
797 if (!createSpecPR) {
798 return renderWithError(
799 "Backend not available — spec-to-PR is not deployed yet. Please try again later.",
800 503
801 );
802 }
803
804 let result:
805 | { ok: true; prNumber: number }
806 | { ok: false; error: string };
807 try {
808 result = await createSpecPR({
809 repoId: resolved.repoId,
810 spec,
811 baseRef,
812 userId: user.id,
813 });
814 } catch (err) {
815 const msg =
816 err instanceof Error ? err.message : "Unexpected error generating PR.";
817 return renderWithError(`Failed to generate PR: ${msg}`, 500);
818 }
819
820 if (!result || !result.ok) {
821 const msg = (result && "error" in result && result.error) || "Unknown error.";
822 return renderWithError(`Failed to generate PR: ${msg}`);
823 }
824
825 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
826});
827
828export default specs;