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.tsxBlame1070 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";
950ef90Claude21import { and, eq, like } from "drizzle-orm";
14c3cc8Claude22import { db } from "../db";
950ef90Claude23import { repositories, users, issues, pullRequests } 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";
950ef90Claude28import { listBranches, getBlob, getTreeRecursive } from "../git/repository";
29import {
30 AI_SPEC_PR_MARKER,
31 parseFrontMatter,
32 type SpecStatus,
33} from "../lib/spec-to-pr";
14c3cc8Claude34
35const specs = new Hono<AuthEnv>();
36
37// Tiny inline script that disables the submit button + textarea while the
38// request is in-flight so users don't accidentally double-click and trigger
39// two 10-30s Claude calls. Rendered as a plain <script> tag.
40const DISABLE_ON_SUBMIT_JS = `
41(function() {
42 var form = document.getElementById('spec-form');
43 if (!form) return;
44 form.addEventListener('submit', function() {
45 var btn = form.querySelector('button[type="submit"]');
46 var ta = form.querySelector('textarea[name="spec"]');
47 if (btn) {
48 btn.disabled = true;
93812e4Claude49 btn.textContent = 'Working… this can take 10-30s';
14c3cc8Claude50 }
51 if (ta) ta.readOnly = true;
52 });
53})();
54`;
55
93812e4Claude56// ─── Scoped CSS (.specs-*) ─────────────────────────────────────────────────
57const specsStyles = `
58 .specs-wrap { max-width: 1100px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
59
60 /* ─── Header ─── */
61 .specs-head { margin-bottom: var(--space-5); }
62 .specs-eyebrow {
63 display: inline-flex;
64 align-items: center;
65 gap: 8px;
66 text-transform: uppercase;
67 font-family: var(--font-mono);
68 font-size: 11px;
69 letter-spacing: 0.16em;
70 color: var(--text-muted);
71 font-weight: 600;
72 margin-bottom: 10px;
73 }
74 .specs-eyebrow-dot {
75 width: 8px; height: 8px;
76 border-radius: 9999px;
77 background: linear-gradient(135deg, #8c6dff, #36c5d6);
78 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
79 }
80 .specs-pill-experimental {
81 display: inline-flex;
82 align-items: center;
83 gap: 5px;
84 padding: 2px 8px;
85 margin-left: 4px;
86 border-radius: 9999px;
87 font-size: 10px;
88 font-weight: 700;
89 letter-spacing: 0.06em;
90 background: rgba(251,191,36,0.12);
91 color: #fde68a;
92 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
93 }
94 .specs-title {
95 font-family: var(--font-display);
96 font-size: clamp(26px, 3.6vw, 40px);
97 font-weight: 800;
98 letter-spacing: -0.028em;
99 line-height: 1.05;
100 margin: 0 0 6px;
101 color: var(--text-strong);
102 }
103 .specs-title-grad {
104 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
105 -webkit-background-clip: text;
106 background-clip: text;
107 -webkit-text-fill-color: transparent;
108 color: transparent;
109 }
110 .specs-sub {
111 margin: 0;
112 font-size: 14px;
113 color: var(--text-muted);
114 line-height: 1.5;
115 max-width: 720px;
116 }
117 .specs-sub a { color: var(--accent); text-decoration: none; }
118 .specs-sub a:hover { text-decoration: underline; }
119 .specs-sub code {
120 font-family: var(--font-mono);
121 font-size: 12px;
122 background: rgba(255,255,255,0.04);
123 padding: 1px 6px;
124 border-radius: 4px;
125 }
126
127 /* ─── Banners ─── */
128 .specs-banner {
129 margin-bottom: var(--space-4);
130 padding: 10px 14px;
131 border-radius: 10px;
132 font-size: 13.5px;
133 border: 1px solid var(--border);
134 background: rgba(255,255,255,0.025);
135 color: var(--text);
136 display: flex;
137 align-items: flex-start;
138 gap: 10px;
139 line-height: 1.45;
140 }
141 .specs-banner.is-info {
142 border-color: rgba(54,197,214,0.40);
143 background: rgba(54,197,214,0.08);
144 color: #cffafe;
145 }
146 .specs-banner.is-error {
147 border-color: rgba(248,113,113,0.40);
148 background: rgba(248,113,113,0.08);
149 color: #fecaca;
150 }
151 .specs-banner-dot {
152 width: 8px; height: 8px;
153 border-radius: 9999px;
154 background: currentColor;
155 margin-top: 6px;
156 flex-shrink: 0;
157 }
158 .specs-banner a {
159 color: inherit;
160 text-decoration: underline;
161 font-weight: 600;
162 }
163
164 /* ─── Section card ─── */
165 .specs-section {
166 margin-bottom: var(--space-5);
167 background: var(--bg-elevated);
168 border: 1px solid var(--border);
169 border-radius: 14px;
170 overflow: hidden;
171 position: relative;
172 }
173 .specs-section::before {
174 content: '';
175 position: absolute;
176 top: 0; left: 0; right: 0;
177 height: 2px;
178 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
179 opacity: 0.55;
180 pointer-events: none;
181 }
182 .specs-section-head {
183 padding: var(--space-4) var(--space-5) var(--space-3);
184 border-bottom: 1px solid var(--border);
185 }
186 .specs-section-title {
187 margin: 0;
188 font-family: var(--font-display);
189 font-size: 16px;
190 font-weight: 700;
191 letter-spacing: -0.018em;
192 color: var(--text-strong);
193 }
194 .specs-section-sub {
195 margin: 6px 0 0;
196 font-size: 12.5px;
197 color: var(--text-muted);
198 line-height: 1.45;
199 }
200 .specs-section-body {
201 padding: var(--space-4) var(--space-5);
202 }
203
204 /* ─── Form fields ─── */
205 .specs-field { margin-bottom: var(--space-4); }
206 .specs-field:last-child { margin-bottom: 0; }
207 .specs-field-label {
208 display: block;
209 font-size: 11.5px;
210 font-weight: 600;
211 text-transform: uppercase;
212 letter-spacing: 0.06em;
213 color: var(--text-muted);
214 margin-bottom: 6px;
215 }
216 .specs-input,
217 .specs-select,
218 .specs-textarea {
219 width: 100%;
220 box-sizing: border-box;
221 padding: 10px 12px;
222 font: inherit;
223 font-size: 13.5px;
224 color: var(--text);
225 background: rgba(255,255,255,0.03);
226 border: 1px solid var(--border-strong);
227 border-radius: 10px;
228 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
229 }
230 .specs-textarea {
231 font-family: var(--font-mono);
232 font-size: 13px;
233 line-height: 1.55;
234 resize: vertical;
235 min-height: 200px;
236 }
237 .specs-input:focus,
238 .specs-select:focus,
239 .specs-textarea:focus {
240 outline: none;
241 border-color: rgba(140,109,255,0.55);
242 background: rgba(255,255,255,0.05);
243 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
244 }
245 .specs-select {
246 appearance: none;
247 padding-right: 30px;
248 background-image:
249 linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
250 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
251 background-position: right 12px top 50%, right 7px top 50%;
252 background-size: 5px 5px, 5px 5px;
253 background-repeat: no-repeat;
254 }
255 .specs-field-hint {
256 margin-top: 6px;
257 font-size: 11.5px;
258 color: var(--text-muted);
259 line-height: 1.45;
260 }
261
262 /* ─── Buttons ─── */
263 .specs-actions {
264 display: flex;
265 align-items: center;
266 gap: 10px;
267 flex-wrap: wrap;
268 padding-top: 4px;
269 }
270 .specs-btn {
271 display: inline-flex;
272 align-items: center;
273 justify-content: center;
274 gap: 8px;
275 padding: 10px 18px;
276 border-radius: 10px;
277 font-size: 13.5px;
278 font-weight: 600;
279 text-decoration: none;
280 border: 1px solid transparent;
281 cursor: pointer;
282 font: inherit;
283 line-height: 1;
284 white-space: nowrap;
285 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
286 }
287 .specs-btn-primary {
288 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
289 color: #ffffff;
290 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
291 }
292 .specs-btn-primary:hover {
293 transform: translateY(-1px);
294 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
295 color: #ffffff;
296 text-decoration: none;
297 }
298 .specs-btn-primary:disabled {
299 cursor: not-allowed;
300 opacity: 0.6;
301 transform: none;
302 box-shadow: none;
303 }
304 .specs-actions-hint {
305 font-size: 12px;
306 color: var(--text-muted);
307 }
308
309 /* ─── How-this-works step cards ─── */
310 .specs-steps {
311 display: grid;
312 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
313 gap: 10px;
314 }
315 .specs-step {
316 padding: 14px;
317 background: rgba(255,255,255,0.018);
318 border: 1px solid var(--border);
319 border-radius: 11px;
320 transition: border-color 120ms ease, background 120ms ease;
321 }
322 .specs-step:hover {
323 border-color: var(--border-strong);
324 background: rgba(255,255,255,0.03);
325 }
326 .specs-step-num {
327 display: inline-flex;
328 align-items: center;
329 justify-content: center;
330 width: 24px; height: 24px;
331 border-radius: 7px;
332 background: rgba(140,109,255,0.16);
333 color: #c4b5fd;
334 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
335 font-family: var(--font-mono);
336 font-size: 12px;
337 font-weight: 700;
338 margin-bottom: 10px;
339 }
340 .specs-step-title {
341 margin: 0 0 4px;
342 font-family: var(--font-display);
343 font-size: 13.5px;
344 font-weight: 700;
345 color: var(--text-strong);
346 letter-spacing: -0.005em;
347 }
348 .specs-step-body {
349 margin: 0;
350 font-size: 12.5px;
351 color: var(--text-muted);
352 line-height: 1.5;
353 }
354 .specs-step-body code {
355 font-family: var(--font-mono);
356 font-size: 11.5px;
357 background: rgba(255,255,255,0.04);
358 padding: 1px 5px;
359 border-radius: 4px;
360 color: var(--text);
361 }
362
363 /* ─── 403 / not-found ─── */
364 .specs-empty {
365 max-width: 540px;
366 margin: var(--space-8) auto;
367 padding: var(--space-6);
368 text-align: center;
369 background: var(--bg-elevated);
370 border: 1px dashed var(--border-strong);
371 border-radius: 16px;
372 }
373 .specs-empty h2 {
374 font-family: var(--font-display);
375 font-size: 20px;
376 margin: 0 0 8px;
377 color: var(--text-strong);
378 }
379 .specs-empty p {
380 margin: 0;
381 color: var(--text-muted);
382 font-size: 13.5px;
383 }
384`;
385
14c3cc8Claude386interface ResolvedRepo {
387 ownerId: string;
388 ownerUsername: string;
389 repoId: string;
390 repoName: string;
391 defaultBranch: string;
392}
393
394async function resolveRepo(
395 ownerName: string,
396 repoName: string
397): Promise<ResolvedRepo | null> {
398 try {
399 const [ownerRow] = await db
400 .select()
401 .from(users)
402 .where(eq(users.username, ownerName))
403 .limit(1);
404 if (!ownerRow) return null;
405 const [repoRow] = await db
406 .select()
407 .from(repositories)
408 .where(
409 and(
410 eq(repositories.ownerId, ownerRow.id),
411 eq(repositories.name, repoName)
412 )
413 )
414 .limit(1);
415 if (!repoRow) return null;
416 return {
417 ownerId: ownerRow.id,
418 ownerUsername: ownerRow.username,
419 repoId: repoRow.id,
420 repoName: repoRow.name,
421 defaultBranch: repoRow.defaultBranch || "main",
422 };
423 } catch {
424 return null;
425 }
426}
427
428/**
429 * Write access check. Gluecron has no collaborator table yet, so "write
430 * access" == repo owner. Matches the convention used by repo-settings and
431 * ai-explain's regenerate endpoint.
432 */
433function hasWriteAccess(
434 resolved: ResolvedRepo,
435 userId: string | undefined
436): boolean {
437 return !!userId && resolved.ownerId === userId;
438}
439
fbf4aefClaude440/**
441 * Build the default spec text for an issue-driven generation. Format:
442 *
443 * Implement: <title>
444 *
445 * <body>
446 *
447 * Closes #<n>
448 *
449 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
450 * so the issue auto-closes when the AI-generated PR is merged. Body is
451 * trimmed and falls back to an empty string if missing. Pure helper —
452 * exported for tests.
453 */
454export function buildSpecFromIssue(input: {
455 number: number;
456 title: string;
457 body: string | null | undefined;
458}): string {
459 const title = (input.title || "").trim();
460 const body = (input.body || "").trim();
461 const lines: string[] = [];
462 if (title) lines.push(`Implement: ${title}`);
463 if (body) {
464 lines.push("");
465 lines.push(body);
466 }
467 lines.push("");
468 lines.push(`Closes #${input.number}`);
469 return lines.join("\n");
470}
471
14c3cc8Claude472function SpecForm({
473 ownerName,
474 repoName,
475 branches,
476 defaultBranch,
477 spec,
478 baseRef,
479 error,
fbf4aefClaude480 fromIssueNumber,
481 fromIssueTitle,
14c3cc8Claude482}: {
483 ownerName: string;
484 repoName: string;
485 branches: string[];
486 defaultBranch: string;
487 spec?: string;
488 baseRef?: string;
489 error?: string;
fbf4aefClaude490 fromIssueNumber?: number;
491 fromIssueTitle?: string;
14c3cc8Claude492}) {
493 const branchList = branches.length > 0 ? branches : [defaultBranch];
494 const selectedBase = baseRef && branchList.includes(baseRef)
495 ? baseRef
496 : defaultBranch;
497 return (
93812e4Claude498 <div class="specs-wrap">
499 <header class="specs-head">
500 <div class="specs-eyebrow">
501 <span class="specs-eyebrow-dot" aria-hidden="true" />
502 Repository · Spec to PR
503 <span class="specs-pill-experimental">Experimental</span>
504 </div>
505 <h1 class="specs-title">
506 <span class="specs-title-grad">Describe it. Ship a draft.</span>
507 </h1>
508 <p class="specs-sub">
509 Write a feature in plain English. Claude drafts the code changes
510 and opens a pull request against the branch you pick. Every PR is{" "}
511 <strong>draft by default</strong> — review every line before merging.
512 </p>
513 </header>
14c3cc8Claude514
fbf4aefClaude515 {fromIssueNumber && (
93812e4Claude516 <div class="specs-banner is-info" role="status">
517 <span class="specs-banner-dot" aria-hidden="true" />
518 <span>
519 Building from issue{" "}
520 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
521 #{fromIssueNumber}
522 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
523 </a>
524 . The spec below has been pre-filled and will auto-close the
525 issue on merge.
526 </span>
527 </div>
fbf4aefClaude528 )}
529
93812e4Claude530 {error && (
531 <div class="specs-banner is-error" role="alert">
532 <span class="specs-banner-dot" aria-hidden="true" />
533 <span>{error}</span>
14c3cc8Claude534 </div>
93812e4Claude535 )}
536
537 <section class="specs-section">
538 <header class="specs-section-head">
539 <h2 class="specs-section-title">Feature spec</h2>
540 <p class="specs-section-sub">
541 One sentence or a paragraph. Be specific about files, behaviour,
542 or success criteria when you can.
543 </p>
544 </header>
545 <div class="specs-section-body">
546 <form
547 method="post"
548 action={`/${ownerName}/${repoName}/spec`}
549 id="spec-form"
550 >
551 <div class="specs-field">
552 <label class="specs-field-label" for="spec">What do you want built?</label>
553 <textarea
554 class="specs-textarea"
555 name="spec"
556 id="spec"
557 rows={10}
558 required
559 placeholder="add a dark mode toggle to the settings page"
560 >{spec || ""}</textarea>
561 </div>
562
563 <div class="specs-field">
564 <label class="specs-field-label" for="baseRef">Base branch</label>
565 <select
566 class="specs-select"
567 name="baseRef"
568 id="baseRef"
569 value={selectedBase}
570 >
571 {branchList.map((b) => (
572 <option value={b} selected={b === selectedBase}>
573 {b}
574 </option>
575 ))}
576 </select>
577 <p class="specs-field-hint">
578 The draft PR will target this branch. Nothing lands without
579 your approval.
580 </p>
581 </div>
582
583 <div class="specs-actions">
584 <button type="submit" class="specs-btn specs-btn-primary">
585 Generate PR with AI
586 </button>
587 <span class="specs-actions-hint">Typically 10-30 seconds.</span>
588 </div>
589 </form>
14c3cc8Claude590 </div>
93812e4Claude591 </section>
592
593 <section class="specs-section">
594 <header class="specs-section-head">
595 <h2 class="specs-section-title">How this works</h2>
596 <p class="specs-section-sub">
597 Three steps from spec to mergeable diff — all observable, all
598 reversible.
599 </p>
600 </header>
601 <div class="specs-section-body">
602 <div class="specs-steps">
603 <div class="specs-step">
604 <span class="specs-step-num">1</span>
605 <h3 class="specs-step-title">You write a spec.</h3>
606 <p class="specs-step-body">
607 A sentence or a paragraph describing the change you want.
608 </p>
609 </div>
610 <div class="specs-step">
611 <span class="specs-step-num">2</span>
612 <h3 class="specs-step-title">Claude drafts the diff.</h3>
613 <p class="specs-step-body">
614 We fetch the base branch, run Claude against the repo, and
615 commit the proposed changes to a new branch.
616 </p>
617 </div>
618 <div class="specs-step">
619 <span class="specs-step-num">3</span>
620 <h3 class="specs-step-title">A draft PR opens.</h3>
621 <p class="specs-step-body">
622 You review, edit, and merge on your terms. Nothing lands on{" "}
623 <code>{selectedBase}</code> automatically.
624 </p>
625 </div>
14c3cc8Claude626 </div>
627 </div>
93812e4Claude628 </section>
14c3cc8Claude629
630 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
93812e4Claude631 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
632 </div>
14c3cc8Claude633 );
634}
635
636specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
637 const { owner, repo } = c.req.param();
638 const user = c.get("user")!;
639
640 const resolved = await resolveRepo(owner, repo);
641 if (!resolved) {
642 return c.html(
643 <Layout title="Not Found" user={user}>
93812e4Claude644 <div class="specs-empty">
645 <h2>Repository not found</h2>
14c3cc8Claude646 <p>No such repository.</p>
93812e4Claude647 </div>
648 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude649 </Layout>,
650 404
651 );
652 }
653
654 if (!hasWriteAccess(resolved, user.id)) {
655 return c.html(
656 <Layout title="Forbidden" user={user}>
657 <RepoHeader owner={owner} repo={repo} />
93812e4Claude658 <div class="specs-empty">
659 <h2>Write access required</h2>
14c3cc8Claude660 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude661 </div>
662 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude663 </Layout>,
664 403
665 );
666 }
667
668 let branches: string[] = [];
669 try {
670 branches = await listBranches(owner, repo);
671 } catch {
672 branches = [];
673 }
674
fbf4aefClaude675 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
676 // button on the issue detail page. Silently no-ops on missing/unknown
677 // issue so the form still renders.
678 let prefilledSpec: string | undefined;
679 let fromIssueNumber: number | undefined;
680 let fromIssueTitle: string | undefined;
681 const fromIssueRaw = c.req.query("fromIssue");
682 if (fromIssueRaw) {
683 const n = Number.parseInt(fromIssueRaw, 10);
684 if (Number.isInteger(n) && n > 0) {
685 try {
686 const [issueRow] = await db
687 .select()
688 .from(issues)
689 .where(
690 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
691 )
692 .limit(1);
693 if (issueRow) {
694 fromIssueNumber = issueRow.number;
695 fromIssueTitle = issueRow.title;
696 prefilledSpec = buildSpecFromIssue({
697 number: issueRow.number,
698 title: issueRow.title,
699 body: issueRow.body,
700 });
701 }
702 } catch {
703 // Pre-fill is a convenience, never block form render.
704 }
705 }
706 }
707
14c3cc8Claude708 return c.html(
709 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
710 <RepoHeader owner={owner} repo={repo} />
711 <SpecForm
712 ownerName={owner}
713 repoName={repo}
714 branches={branches}
715 defaultBranch={resolved.defaultBranch}
fbf4aefClaude716 spec={prefilledSpec}
717 fromIssueNumber={fromIssueNumber}
718 fromIssueTitle={fromIssueTitle}
14c3cc8Claude719 />
720 </Layout>
721 );
722});
723
724specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
725 const { owner, repo } = c.req.param();
726 const user = c.get("user")!;
727
728 const resolved = await resolveRepo(owner, repo);
729 if (!resolved) return c.notFound();
730
731 if (!hasWriteAccess(resolved, user.id)) {
732 return c.html(
733 <Layout title="Forbidden" user={user}>
734 <RepoHeader owner={owner} repo={repo} />
93812e4Claude735 <div class="specs-empty">
736 <h2>Write access required</h2>
14c3cc8Claude737 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude738 </div>
739 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude740 </Layout>,
741 403
742 );
743 }
744
745 const body = await c.req.parseBody();
746 const spec = String(body.spec || "").trim();
747 const baseRef = String(body.baseRef || resolved.defaultBranch).trim()
748 || resolved.defaultBranch;
749
750 let branches: string[] = [];
751 try {
752 branches = await listBranches(owner, repo);
753 } catch {
754 branches = [];
755 }
756
757 function renderWithError(error: string, status: 400 | 500 | 503 = 400) {
758 return c.html(
759 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
760 <RepoHeader owner={owner} repo={repo} />
761 <SpecForm
762 ownerName={owner}
763 repoName={repo}
764 branches={branches}
765 defaultBranch={resolved!.defaultBranch}
766 spec={spec}
767 baseRef={baseRef}
768 error={error}
769 />
770 </Layout>,
771 status
772 );
773 }
774
775 if (!spec) {
776 return renderWithError("Spec is required.");
777 }
778
779 // Dynamically import the backend so this file works even before the
780 // sibling branch landing `src/lib/spec-to-pr.ts` is merged. If the module
781 // is missing or throws we surface a soft error instead of 500-ing.
782 let createSpecPR:
783 | ((args: {
784 repoId: string;
785 spec: string;
786 baseRef: string;
787 userId: string;
788 }) => Promise<
789 | { ok: true; prNumber: number }
790 | { ok: false; error: string }
791 >)
792 | null = null;
793 try {
794 const mod: any = await import("../lib/spec-to-pr");
795 createSpecPR =
796 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
797 null;
798 } catch {
799 createSpecPR = null;
800 }
801
802 if (!createSpecPR) {
803 return renderWithError(
804 "Backend not available — spec-to-PR is not deployed yet. Please try again later.",
805 503
806 );
807 }
808
809 let result:
810 | { ok: true; prNumber: number }
811 | { ok: false; error: string };
812 try {
813 result = await createSpecPR({
814 repoId: resolved.repoId,
815 spec,
816 baseRef,
817 userId: user.id,
818 });
819 } catch (err) {
820 const msg =
821 err instanceof Error ? err.message : "Unexpected error generating PR.";
822 return renderWithError(`Failed to generate PR: ${msg}`, 500);
823 }
824
825 if (!result || !result.ok) {
826 const msg = (result && "error" in result && result.error) || "Unknown error.";
827 return renderWithError(`Failed to generate PR: ${msg}`);
828 }
829
830 return c.redirect(`/${owner}/${repo}/pulls/${result.prNumber}`);
831});
832
950ef90Claude833// ─── GET /specs — dashboard listing all specs across the user's repos ──────
834//
835// Walks every repo the signed-in user owns, looks at `.gluecron/specs/*.md`
836// on the default branch, parses the front-matter for `status:` + `title:`,
837// and surfaces a flat list with linked PR (when one exists). Soft-fails on
838// any repo that can't be scanned — the dashboard still renders.
839
840interface SpecRow {
841 ownerName: string;
842 repoName: string;
843 specPath: string;
844 title: string;
845 status: SpecStatus;
846 prNumber: number | null;
847}
848
849async function collectSpecsForOwner(ownerId: string): Promise<SpecRow[]> {
850 let owner: { username: string } | undefined;
851 try {
852 const [row] = await db
853 .select({ username: users.username })
854 .from(users)
855 .where(eq(users.id, ownerId))
856 .limit(1);
857 owner = row;
858 } catch {
859 return [];
860 }
861 if (!owner) return [];
862
863 let repos: Array<{ id: string; name: string; defaultBranch: string }> = [];
864 try {
865 repos = await db
866 .select({
867 id: repositories.id,
868 name: repositories.name,
869 defaultBranch: repositories.defaultBranch,
870 })
871 .from(repositories)
872 .where(
873 and(eq(repositories.ownerId, ownerId), eq(repositories.isArchived, false))
874 )
875 .limit(100);
876 } catch {
877 return [];
878 }
879
880 const out: SpecRow[] = [];
881 for (const repo of repos) {
882 const branch = repo.defaultBranch || "main";
883 let paths: string[] = [];
884 try {
885 const tree = await getTreeRecursive(owner.username, repo.name, branch, 5000);
886 if (!tree) continue;
887 paths = tree.tree
888 .filter(
889 (e) =>
890 e.type === "blob" &&
891 e.path.startsWith(".gluecron/specs/") &&
892 e.path.toLowerCase().endsWith(".md")
893 )
894 .map((e) => e.path);
895 } catch {
896 continue;
897 }
898 for (const p of paths) {
899 let content = "";
900 try {
901 const blob = await getBlob(owner.username, repo.name, branch, p);
902 if (!blob || blob.isBinary) continue;
903 content = blob.content;
904 } catch {
905 continue;
906 }
907 const parsed = parseFrontMatter(content);
908 const title =
909 (parsed.frontMatter.title && parsed.frontMatter.title.trim()) ||
910 (p.split("/").pop() || p).replace(/\.md$/i, "");
911 const statusRaw = (parsed.frontMatter.status || "draft").toLowerCase();
912 const status: SpecStatus =
913 statusRaw === "draft" ||
914 statusRaw === "ready" ||
915 statusRaw === "building" ||
916 statusRaw === "shipped" ||
917 statusRaw === "failed"
918 ? (statusRaw as SpecStatus)
919 : "draft";
920
921 // Best-effort PR lookup: any PR whose body mentions the spec path
922 // and carries the spec marker. Newest first.
923 let prNumber: number | null = null;
924 try {
925 const [pr] = await db
926 .select({ number: pullRequests.number })
927 .from(pullRequests)
928 .where(
929 and(
930 eq(pullRequests.repositoryId, repo.id),
931 like(pullRequests.body, `%${AI_SPEC_PR_MARKER}%`),
932 like(pullRequests.body, `%${p}%`)
933 )
934 )
935 .orderBy(pullRequests.createdAt)
936 .limit(1);
937 if (pr) prNumber = pr.number;
938 } catch {
939 prNumber = null;
940 }
941
942 out.push({
943 ownerName: owner.username,
944 repoName: repo.name,
945 specPath: p,
946 title,
947 status,
948 prNumber,
949 });
950 }
951 }
952 return out;
953}
954
955specs.get("/specs", softAuth, requireAuth, async (c) => {
956 const user = c.get("user")!;
957 const rows = await collectSpecsForOwner(user.id);
958
959 const byStatus = (s: SpecStatus) => rows.filter((r) => r.status === s);
960
961 const statusBadgeColors: Record<SpecStatus, string> = {
962 draft: "rgba(148,163,184,0.16)",
963 ready: "rgba(54,197,214,0.18)",
964 building: "rgba(140,109,255,0.20)",
965 shipped: "rgba(34,197,94,0.18)",
966 failed: "rgba(248,113,113,0.18)",
967 };
968
969 return c.html(
970 <Layout title="Specs — Gluecron" user={user}>
971 <div class="specs-wrap">
972 <header class="specs-head">
973 <div class="specs-eyebrow">
974 <span class="specs-eyebrow-dot" aria-hidden="true" />
975 Account · Specs dashboard
976 <span class="specs-pill-experimental">Experimental</span>
977 </div>
978 <h1 class="specs-title">
979 <span class="specs-title-grad">Your spec library.</span>
980 </h1>
981 <p class="specs-sub">
982 Every <code>.gluecron/specs/*.md</code> file across your
983 repositories. Set a spec's front-matter to{" "}
984 <code>status: ready</code> and the autopilot picks it up within
985 two minutes to open an implementation PR tagged{" "}
986 <code>ai:spec-implementation</code>.
987 </p>
988 </header>
989
990 <section class="specs-section">
991 <header class="specs-section-head">
992 <h2 class="specs-section-title">All specs ({rows.length})</h2>
993 <p class="specs-section-sub">
994 {byStatus("ready").length} ready · {byStatus("building").length}{" "}
995 building · {byStatus("shipped").length} shipped ·{" "}
996 {byStatus("failed").length} failed · {byStatus("draft").length}{" "}
997 draft
998 </p>
999 </header>
1000 <div class="specs-section-body">
1001 {rows.length === 0 ? (
1002 <p class="specs-field-hint" style="font-size:13px;">
1003 No specs yet. Create a file like{" "}
1004 <code>.gluecron/specs/my-feature.md</code> in any of your
1005 repos with front-matter{" "}
1006 <code>---\ntitle: Add dark mode\nstatus: ready\n---</code> and
1007 it will land here.
1008 </p>
1009 ) : (
1010 <table style="width:100%;border-collapse:collapse;font-size:13px;">
1011 <thead>
1012 <tr style="text-align:left;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">
1013 <th style="padding:8px 4px;">Repo</th>
1014 <th style="padding:8px 4px;">Title</th>
1015 <th style="padding:8px 4px;">Status</th>
1016 <th style="padding:8px 4px;">PR</th>
1017 <th style="padding:8px 4px;">Path</th>
1018 </tr>
1019 </thead>
1020 <tbody>
1021 {rows.map((r) => (
1022 <tr style="border-top:1px solid var(--border);">
1023 <td style="padding:10px 4px;">
1024 <a href={`/${r.ownerName}/${r.repoName}`}>
1025 {r.ownerName}/{r.repoName}
1026 </a>
1027 </td>
1028 <td style="padding:10px 4px;color:var(--text-strong);">
1029 {r.title}
1030 </td>
1031 <td style="padding:10px 4px;">
1032 <span
1033 style={`display:inline-block;padding:2px 8px;border-radius:9999px;font-family:var(--font-mono);font-size:11px;background:${statusBadgeColors[r.status]};color:var(--text);`}
1034 >
1035 {r.status}
1036 </span>
1037 </td>
1038 <td style="padding:10px 4px;">
1039 {r.prNumber ? (
1040 <a
1041 href={`/${r.ownerName}/${r.repoName}/pulls/${r.prNumber}`}
1042 >
1043 #{r.prNumber}
1044 </a>
1045 ) : (
1046 <span style="color:var(--text-muted);">—</span>
1047 )}
1048 </td>
1049 <td style="padding:10px 4px;font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);">
1050 <a
1051 href={`/${r.ownerName}/${r.repoName}/blob/main/${r.specPath}`}
1052 >
1053 {r.specPath}
1054 </a>
1055 </td>
1056 </tr>
1057 ))}
1058 </tbody>
1059 </table>
1060 )}
1061 </div>
1062 </section>
1063
1064 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
1065 </div>
1066 </Layout>
1067 );
1068});
1069
14c3cc8Claude1070export default specs;