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.tsxBlame1642 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 *
eb5963bClaude5 * GET /:owner/:repo/spec — form (requires write access)
6 * POST /:owner/:repo/spec — kicks off background job,
7 * redirects to progress page
8 * GET /:owner/:repo/spec/:jobId/progress — live progress page (SSE)
9 * GET /:owner/:repo/spec/:jobId/progress/events — SSE event stream
10 * GET /:owner/:repo/spec/:jobId/status — JSON status (polling fallback)
14c3cc8Claude11 *
12 * The backend (`createSpecPR` in `src/lib/spec-to-pr.ts`) is being built in
13 * parallel. We import it dynamically so this file compiles and its tests
14 * pass even if the module is not yet on disk — if the import fails we
15 * fall back to a "Backend not available" banner.
93812e4Claude16 *
17 * 2026 polish: scoped `.specs-*` class system. Eyebrow + display headline +
18 * subtitle hero, card sections for the form + the "how this works" steps,
19 * primary CTA on the submit button. All form fields, names, actions, and
20 * the dynamic-import behaviour are unchanged.
14c3cc8Claude21 */
22import { Hono } from "hono";
950ef90Claude23import { and, eq, like } from "drizzle-orm";
14c3cc8Claude24import { db } from "../db";
950ef90Claude25import { repositories, users, issues, pullRequests } from "../db/schema";
14c3cc8Claude26import { Layout } from "../views/layout";
27import { RepoHeader } from "../views/components";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
950ef90Claude30import { listBranches, getBlob, getTreeRecursive } from "../git/repository";
31import {
32 AI_SPEC_PR_MARKER,
33 parseFrontMatter,
34 type SpecStatus,
35} from "../lib/spec-to-pr";
eb5963bClaude36import { publish, subscribe } from "../lib/sse";
37
38// ---------------------------------------------------------------------------
39// In-memory spec job registry
40// ---------------------------------------------------------------------------
41// Each background spec run is tracked here from the moment the POST fires
42// until it completes (or the server restarts). The Map is keyed by a short
43// random job ID that lives in the redirect URL.
44//
45// Eviction: jobs are kept for 10 minutes after completion so late-arriving
46// SSE clients still get a "done" snapshot. The autopilot-ish ticker below
47// sweeps stale entries.
48
49export type SpecStage =
50 | "queued"
51 | "analyzing"
52 | "writing"
53 | "opening_pr"
54 | "done"
55 | "error";
56
57export interface SpecJob {
58 id: string;
59 owner: string;
60 repo: string;
61 stage: SpecStage;
62 label: string; // human-readable stage label
63 prNumber: number | null;
64 error: string | null;
65 startedAt: number; // Date.now()
66 completedAt: number | null;
67}
68
69const specJobs = new Map<string, SpecJob>();
70
71// Sweep completed jobs older than 10 minutes.
72setInterval(() => {
73 const cutoff = Date.now() - 10 * 60 * 1000;
74 for (const [id, job] of specJobs) {
75 if (job.completedAt !== null && job.completedAt < cutoff) {
76 specJobs.delete(id);
77 }
78 }
79}, 60_000);
80
81function specJobTopic(jobId: string): string {
82 return `spec-job:${jobId}`;
83}
84
85/** Advance a job to a new stage and publish the SSE event. */
86function advanceStage(job: SpecJob, stage: SpecStage, label: string): void {
87 job.stage = stage;
88 job.label = label;
89 publish(specJobTopic(job.id), {
90 event: "stage",
91 data: { stage, label, prNumber: job.prNumber, error: job.error },
92 });
93}
94
95const STAGE_LABELS: Record<SpecStage, string> = {
96 queued: "Queued",
97 analyzing: "Analyzing spec",
98 writing: "Writing code",
99 opening_pr: "Opening PR",
100 done: "Done",
101 error: "Error",
102};
103
104/**
105 * Run the spec-to-PR pipeline in the background, publishing SSE events at
106 * each stage transition. Accepts the same arguments as `createSpecPR`.
107 */
108async function runSpecJobInBackground(
109 job: SpecJob,
110 args: { repoId: string; spec: string; baseRef: string; userId: string }
111): Promise<void> {
112 // Stage 1 — analyzing spec / building context
113 advanceStage(job, "analyzing", STAGE_LABELS.analyzing);
114
115 // Dynamically import the backend (same guard as the POST handler).
116 let createSpecPR:
117 | ((args: {
118 repoId: string;
119 spec: string;
120 baseRef: string;
121 userId: string;
122 }) => Promise<
123 | { ok: true; prNumber: number }
124 | { ok: false; error: string }
125 >)
126 | null = null;
127 try {
128 const mod: any = await import("../lib/spec-to-pr");
129 createSpecPR =
130 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
131 null;
132 } catch {
133 createSpecPR = null;
134 }
135
136 if (!createSpecPR) {
137 job.error = "Backend not available — spec-to-PR is not deployed yet.";
138 job.completedAt = Date.now();
139 advanceStage(job, "error", STAGE_LABELS.error);
140 return;
141 }
142
143 // Stage 2 — writing code (AI generating). We publish this immediately
144 // before the Claude call so the UI shows "Writing code" while we wait.
145 advanceStage(job, "writing", STAGE_LABELS.writing);
146
147 let result: { ok: true; prNumber: number } | { ok: false; error: string };
148 try {
149 result = await createSpecPR(args);
150 } catch (err) {
151 result = {
152 ok: false,
153 error: err instanceof Error ? err.message : "Unexpected error",
154 };
155 }
156
157 if (!result.ok) {
158 job.error = result.error;
159 job.completedAt = Date.now();
160 advanceStage(job, "error", STAGE_LABELS.error);
161 return;
162 }
163
164 // Stage 3 — opening PR (the insert already happened inside createSpecPR,
165 // but we surface this stage as a brief acknowledgement).
166 advanceStage(job, "opening_pr", STAGE_LABELS.opening_pr);
167 job.prNumber = result.prNumber;
168
169 // Stage 4 — done.
170 job.completedAt = Date.now();
171 advanceStage(job, "done", STAGE_LABELS.done);
172}
14c3cc8Claude173
174const specs = new Hono<AuthEnv>();
175
176// Tiny inline script that disables the submit button + textarea while the
177// request is in-flight so users don't accidentally double-click and trigger
178// two 10-30s Claude calls. Rendered as a plain <script> tag.
179const DISABLE_ON_SUBMIT_JS = `
180(function() {
181 var form = document.getElementById('spec-form');
182 if (!form) return;
183 form.addEventListener('submit', function() {
184 var btn = form.querySelector('button[type="submit"]');
185 var ta = form.querySelector('textarea[name="spec"]');
186 if (btn) {
187 btn.disabled = true;
93812e4Claude188 btn.textContent = 'Working… this can take 10-30s';
14c3cc8Claude189 }
190 if (ta) ta.readOnly = true;
191 });
192})();
193`;
194
93812e4Claude195// ─── Scoped CSS (.specs-*) ─────────────────────────────────────────────────
196const specsStyles = `
eed4684Claude197 .specs-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
93812e4Claude198
199 /* ─── Header ─── */
200 .specs-head { margin-bottom: var(--space-5); }
201 .specs-eyebrow {
202 display: inline-flex;
203 align-items: center;
204 gap: 8px;
205 text-transform: uppercase;
206 font-family: var(--font-mono);
207 font-size: 11px;
208 letter-spacing: 0.16em;
209 color: var(--text-muted);
210 font-weight: 600;
211 margin-bottom: 10px;
212 }
213 .specs-eyebrow-dot {
214 width: 8px; height: 8px;
215 border-radius: 9999px;
6fd5915Claude216 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
217 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
93812e4Claude218 }
219 .specs-pill-experimental {
220 display: inline-flex;
221 align-items: center;
222 gap: 5px;
223 padding: 2px 8px;
224 margin-left: 4px;
225 border-radius: 9999px;
226 font-size: 10px;
227 font-weight: 700;
228 letter-spacing: 0.06em;
229 background: rgba(251,191,36,0.12);
230 color: #fde68a;
231 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
232 }
233 .specs-title {
234 font-family: var(--font-display);
235 font-size: clamp(26px, 3.6vw, 40px);
236 font-weight: 800;
237 letter-spacing: -0.028em;
238 line-height: 1.05;
239 margin: 0 0 6px;
240 color: var(--text-strong);
241 }
242 .specs-title-grad {
6fd5915Claude243 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
93812e4Claude244 -webkit-background-clip: text;
245 background-clip: text;
246 -webkit-text-fill-color: transparent;
247 color: transparent;
248 }
249 .specs-sub {
250 margin: 0;
251 font-size: 14px;
252 color: var(--text-muted);
253 line-height: 1.5;
254 max-width: 720px;
255 }
256 .specs-sub a { color: var(--accent); text-decoration: none; }
257 .specs-sub a:hover { text-decoration: underline; }
258 .specs-sub code {
259 font-family: var(--font-mono);
260 font-size: 12px;
261 background: rgba(255,255,255,0.04);
262 padding: 1px 6px;
263 border-radius: 4px;
264 }
265
266 /* ─── Banners ─── */
267 .specs-banner {
268 margin-bottom: var(--space-4);
269 padding: 10px 14px;
270 border-radius: 10px;
271 font-size: 13.5px;
272 border: 1px solid var(--border);
273 background: rgba(255,255,255,0.025);
274 color: var(--text);
275 display: flex;
276 align-items: flex-start;
277 gap: 10px;
278 line-height: 1.45;
279 }
280 .specs-banner.is-info {
6fd5915Claude281 border-color: rgba(95,143,160,0.40);
282 background: rgba(95,143,160,0.08);
93812e4Claude283 color: #cffafe;
284 }
285 .specs-banner.is-error {
286 border-color: rgba(248,113,113,0.40);
287 background: rgba(248,113,113,0.08);
288 color: #fecaca;
289 }
290 .specs-banner-dot {
291 width: 8px; height: 8px;
292 border-radius: 9999px;
293 background: currentColor;
294 margin-top: 6px;
295 flex-shrink: 0;
296 }
297 .specs-banner a {
298 color: inherit;
299 text-decoration: underline;
300 font-weight: 600;
301 }
302
303 /* ─── Section card ─── */
304 .specs-section {
305 margin-bottom: var(--space-5);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 14px;
309 overflow: hidden;
310 position: relative;
311 }
312 .specs-section::before {
313 content: '';
314 position: absolute;
315 top: 0; left: 0; right: 0;
316 height: 2px;
6fd5915Claude317 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
93812e4Claude318 opacity: 0.55;
319 pointer-events: none;
320 }
321 .specs-section-head {
322 padding: var(--space-4) var(--space-5) var(--space-3);
323 border-bottom: 1px solid var(--border);
324 }
325 .specs-section-title {
326 margin: 0;
327 font-family: var(--font-display);
328 font-size: 16px;
329 font-weight: 700;
330 letter-spacing: -0.018em;
331 color: var(--text-strong);
332 }
333 .specs-section-sub {
334 margin: 6px 0 0;
335 font-size: 12.5px;
336 color: var(--text-muted);
337 line-height: 1.45;
338 }
339 .specs-section-body {
340 padding: var(--space-4) var(--space-5);
341 }
342
343 /* ─── Form fields ─── */
344 .specs-field { margin-bottom: var(--space-4); }
345 .specs-field:last-child { margin-bottom: 0; }
346 .specs-field-label {
347 display: block;
348 font-size: 11.5px;
349 font-weight: 600;
350 text-transform: uppercase;
351 letter-spacing: 0.06em;
352 color: var(--text-muted);
353 margin-bottom: 6px;
354 }
355 .specs-input,
356 .specs-select,
357 .specs-textarea {
358 width: 100%;
359 box-sizing: border-box;
360 padding: 10px 12px;
361 font: inherit;
362 font-size: 13.5px;
363 color: var(--text);
364 background: rgba(255,255,255,0.03);
365 border: 1px solid var(--border-strong);
366 border-radius: 10px;
367 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
368 }
369 .specs-textarea {
370 font-family: var(--font-mono);
371 font-size: 13px;
372 line-height: 1.55;
373 resize: vertical;
374 min-height: 200px;
375 }
376 .specs-input:focus,
377 .specs-select:focus,
378 .specs-textarea:focus {
379 outline: none;
6fd5915Claude380 border-color: rgba(91,110,232,0.55);
93812e4Claude381 background: rgba(255,255,255,0.05);
6fd5915Claude382 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
93812e4Claude383 }
384 .specs-select {
385 appearance: none;
386 padding-right: 30px;
387 background-image:
388 linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
389 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
390 background-position: right 12px top 50%, right 7px top 50%;
391 background-size: 5px 5px, 5px 5px;
392 background-repeat: no-repeat;
393 }
394 .specs-field-hint {
395 margin-top: 6px;
396 font-size: 11.5px;
397 color: var(--text-muted);
398 line-height: 1.45;
399 }
400
401 /* ─── Buttons ─── */
402 .specs-actions {
403 display: flex;
404 align-items: center;
405 gap: 10px;
406 flex-wrap: wrap;
407 padding-top: 4px;
408 }
409 .specs-btn {
410 display: inline-flex;
411 align-items: center;
412 justify-content: center;
413 gap: 8px;
414 padding: 10px 18px;
415 border-radius: 10px;
416 font-size: 13.5px;
417 font-weight: 600;
418 text-decoration: none;
419 border: 1px solid transparent;
420 cursor: pointer;
421 font: inherit;
422 line-height: 1;
423 white-space: nowrap;
424 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
425 }
426 .specs-btn-primary {
6fd5915Claude427 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
93812e4Claude428 color: #ffffff;
6fd5915Claude429 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
93812e4Claude430 }
431 .specs-btn-primary:hover {
432 transform: translateY(-1px);
6fd5915Claude433 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
93812e4Claude434 color: #ffffff;
435 text-decoration: none;
436 }
437 .specs-btn-primary:disabled {
438 cursor: not-allowed;
439 opacity: 0.6;
440 transform: none;
441 box-shadow: none;
442 }
443 .specs-actions-hint {
444 font-size: 12px;
445 color: var(--text-muted);
446 }
447
448 /* ─── How-this-works step cards ─── */
449 .specs-steps {
450 display: grid;
451 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
452 gap: 10px;
453 }
454 .specs-step {
455 padding: 14px;
456 background: rgba(255,255,255,0.018);
457 border: 1px solid var(--border);
458 border-radius: 11px;
459 transition: border-color 120ms ease, background 120ms ease;
460 }
461 .specs-step:hover {
462 border-color: var(--border-strong);
463 background: rgba(255,255,255,0.03);
464 }
465 .specs-step-num {
466 display: inline-flex;
467 align-items: center;
468 justify-content: center;
469 width: 24px; height: 24px;
470 border-radius: 7px;
6fd5915Claude471 background: rgba(91,110,232,0.16);
93812e4Claude472 color: #c4b5fd;
6fd5915Claude473 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
93812e4Claude474 font-family: var(--font-mono);
475 font-size: 12px;
476 font-weight: 700;
477 margin-bottom: 10px;
478 }
479 .specs-step-title {
480 margin: 0 0 4px;
481 font-family: var(--font-display);
482 font-size: 13.5px;
483 font-weight: 700;
484 color: var(--text-strong);
485 letter-spacing: -0.005em;
486 }
487 .specs-step-body {
488 margin: 0;
489 font-size: 12.5px;
490 color: var(--text-muted);
491 line-height: 1.5;
492 }
493 .specs-step-body code {
494 font-family: var(--font-mono);
495 font-size: 11.5px;
496 background: rgba(255,255,255,0.04);
497 padding: 1px 5px;
498 border-radius: 4px;
499 color: var(--text);
500 }
501
502 /* ─── 403 / not-found ─── */
503 .specs-empty {
504 max-width: 540px;
505 margin: var(--space-8) auto;
506 padding: var(--space-6);
507 text-align: center;
508 background: var(--bg-elevated);
509 border: 1px dashed var(--border-strong);
510 border-radius: 16px;
511 }
512 .specs-empty h2 {
513 font-family: var(--font-display);
514 font-size: 20px;
515 margin: 0 0 8px;
516 color: var(--text-strong);
517 }
518 .specs-empty p {
519 margin: 0;
520 color: var(--text-muted);
521 font-size: 13.5px;
522 }
523`;
524
14c3cc8Claude525interface ResolvedRepo {
526 ownerId: string;
527 ownerUsername: string;
528 repoId: string;
529 repoName: string;
530 defaultBranch: string;
531}
532
533async function resolveRepo(
534 ownerName: string,
535 repoName: string
536): Promise<ResolvedRepo | null> {
537 try {
538 const [ownerRow] = await db
539 .select()
540 .from(users)
541 .where(eq(users.username, ownerName))
542 .limit(1);
543 if (!ownerRow) return null;
544 const [repoRow] = await db
545 .select()
546 .from(repositories)
547 .where(
548 and(
549 eq(repositories.ownerId, ownerRow.id),
550 eq(repositories.name, repoName)
551 )
552 )
553 .limit(1);
554 if (!repoRow) return null;
555 return {
556 ownerId: ownerRow.id,
557 ownerUsername: ownerRow.username,
558 repoId: repoRow.id,
559 repoName: repoRow.name,
560 defaultBranch: repoRow.defaultBranch || "main",
561 };
562 } catch {
563 return null;
564 }
565}
566
567/**
568 * Write access check. Gluecron has no collaborator table yet, so "write
569 * access" == repo owner. Matches the convention used by repo-settings and
570 * ai-explain's regenerate endpoint.
571 */
572function hasWriteAccess(
573 resolved: ResolvedRepo,
574 userId: string | undefined
575): boolean {
576 return !!userId && resolved.ownerId === userId;
577}
578
fbf4aefClaude579/**
580 * Build the default spec text for an issue-driven generation. Format:
581 *
582 * Implement: <title>
583 *
584 * <body>
585 *
586 * Closes #<n>
587 *
588 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
589 * so the issue auto-closes when the AI-generated PR is merged. Body is
590 * trimmed and falls back to an empty string if missing. Pure helper —
591 * exported for tests.
592 */
593export function buildSpecFromIssue(input: {
594 number: number;
595 title: string;
596 body: string | null | undefined;
597}): string {
598 const title = (input.title || "").trim();
599 const body = (input.body || "").trim();
600 const lines: string[] = [];
601 if (title) lines.push(`Implement: ${title}`);
602 if (body) {
603 lines.push("");
604 lines.push(body);
605 }
606 lines.push("");
607 lines.push(`Closes #${input.number}`);
608 return lines.join("\n");
609}
610
14c3cc8Claude611function SpecForm({
612 ownerName,
613 repoName,
614 branches,
615 defaultBranch,
616 spec,
617 baseRef,
618 error,
fbf4aefClaude619 fromIssueNumber,
620 fromIssueTitle,
14c3cc8Claude621}: {
622 ownerName: string;
623 repoName: string;
624 branches: string[];
625 defaultBranch: string;
626 spec?: string;
627 baseRef?: string;
628 error?: string;
fbf4aefClaude629 fromIssueNumber?: number;
630 fromIssueTitle?: string;
14c3cc8Claude631}) {
632 const branchList = branches.length > 0 ? branches : [defaultBranch];
633 const selectedBase = baseRef && branchList.includes(baseRef)
634 ? baseRef
635 : defaultBranch;
636 return (
93812e4Claude637 <div class="specs-wrap">
638 <header class="specs-head">
639 <div class="specs-eyebrow">
640 <span class="specs-eyebrow-dot" aria-hidden="true" />
641 Repository · Spec to PR
642 <span class="specs-pill-experimental">Experimental</span>
643 </div>
644 <h1 class="specs-title">
645 <span class="specs-title-grad">Describe it. Ship a draft.</span>
646 </h1>
647 <p class="specs-sub">
648 Write a feature in plain English. Claude drafts the code changes
649 and opens a pull request against the branch you pick. Every PR is{" "}
650 <strong>draft by default</strong> — review every line before merging.
651 </p>
652 </header>
14c3cc8Claude653
fbf4aefClaude654 {fromIssueNumber && (
93812e4Claude655 <div class="specs-banner is-info" role="status">
656 <span class="specs-banner-dot" aria-hidden="true" />
657 <span>
658 Building from issue{" "}
659 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
660 #{fromIssueNumber}
661 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
662 </a>
663 . The spec below has been pre-filled and will auto-close the
664 issue on merge.
665 </span>
666 </div>
fbf4aefClaude667 )}
668
93812e4Claude669 {error && (
670 <div class="specs-banner is-error" role="alert">
671 <span class="specs-banner-dot" aria-hidden="true" />
672 <span>{error}</span>
14c3cc8Claude673 </div>
93812e4Claude674 )}
675
676 <section class="specs-section">
677 <header class="specs-section-head">
678 <h2 class="specs-section-title">Feature spec</h2>
679 <p class="specs-section-sub">
680 One sentence or a paragraph. Be specific about files, behaviour,
681 or success criteria when you can.
682 </p>
683 </header>
684 <div class="specs-section-body">
685 <form
686 method="post"
687 action={`/${ownerName}/${repoName}/spec`}
688 id="spec-form"
689 >
690 <div class="specs-field">
691 <label class="specs-field-label" for="spec">What do you want built?</label>
692 <textarea
693 class="specs-textarea"
694 name="spec"
695 id="spec"
696 rows={10}
697 required
698 placeholder="add a dark mode toggle to the settings page"
699 >{spec || ""}</textarea>
700 </div>
701
702 <div class="specs-field">
703 <label class="specs-field-label" for="baseRef">Base branch</label>
704 <select
705 class="specs-select"
706 name="baseRef"
707 id="baseRef"
708 value={selectedBase}
709 >
710 {branchList.map((b) => (
711 <option value={b} selected={b === selectedBase}>
712 {b}
713 </option>
714 ))}
715 </select>
716 <p class="specs-field-hint">
717 The draft PR will target this branch. Nothing lands without
718 your approval.
719 </p>
720 </div>
721
722 <div class="specs-actions">
723 <button type="submit" class="specs-btn specs-btn-primary">
724 Generate PR with AI
725 </button>
726 <span class="specs-actions-hint">Typically 10-30 seconds.</span>
727 </div>
728 </form>
14c3cc8Claude729 </div>
93812e4Claude730 </section>
731
732 <section class="specs-section">
733 <header class="specs-section-head">
734 <h2 class="specs-section-title">How this works</h2>
735 <p class="specs-section-sub">
736 Three steps from spec to mergeable diff — all observable, all
737 reversible.
738 </p>
739 </header>
740 <div class="specs-section-body">
741 <div class="specs-steps">
742 <div class="specs-step">
743 <span class="specs-step-num">1</span>
744 <h3 class="specs-step-title">You write a spec.</h3>
745 <p class="specs-step-body">
746 A sentence or a paragraph describing the change you want.
747 </p>
748 </div>
749 <div class="specs-step">
750 <span class="specs-step-num">2</span>
751 <h3 class="specs-step-title">Claude drafts the diff.</h3>
752 <p class="specs-step-body">
753 We fetch the base branch, run Claude against the repo, and
754 commit the proposed changes to a new branch.
755 </p>
756 </div>
757 <div class="specs-step">
758 <span class="specs-step-num">3</span>
759 <h3 class="specs-step-title">A draft PR opens.</h3>
760 <p class="specs-step-body">
761 You review, edit, and merge on your terms. Nothing lands on{" "}
762 <code>{selectedBase}</code> automatically.
763 </p>
764 </div>
14c3cc8Claude765 </div>
766 </div>
93812e4Claude767 </section>
14c3cc8Claude768
769 <script dangerouslySetInnerHTML={{ __html: DISABLE_ON_SUBMIT_JS }} />
93812e4Claude770 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
771 </div>
14c3cc8Claude772 );
773}
774
775specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
776 const { owner, repo } = c.req.param();
777 const user = c.get("user")!;
778
779 const resolved = await resolveRepo(owner, repo);
780 if (!resolved) {
781 return c.html(
782 <Layout title="Not Found" user={user}>
93812e4Claude783 <div class="specs-empty">
784 <h2>Repository not found</h2>
14c3cc8Claude785 <p>No such repository.</p>
93812e4Claude786 </div>
787 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude788 </Layout>,
789 404
790 );
791 }
792
793 if (!hasWriteAccess(resolved, user.id)) {
794 return c.html(
795 <Layout title="Forbidden" user={user}>
796 <RepoHeader owner={owner} repo={repo} />
93812e4Claude797 <div class="specs-empty">
798 <h2>Write access required</h2>
14c3cc8Claude799 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude800 </div>
801 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude802 </Layout>,
803 403
804 );
805 }
806
807 let branches: string[] = [];
808 try {
809 branches = await listBranches(owner, repo);
810 } catch {
811 branches = [];
812 }
813
fbf4aefClaude814 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
815 // button on the issue detail page. Silently no-ops on missing/unknown
816 // issue so the form still renders.
817 let prefilledSpec: string | undefined;
818 let fromIssueNumber: number | undefined;
819 let fromIssueTitle: string | undefined;
820 const fromIssueRaw = c.req.query("fromIssue");
821 if (fromIssueRaw) {
822 const n = Number.parseInt(fromIssueRaw, 10);
823 if (Number.isInteger(n) && n > 0) {
824 try {
825 const [issueRow] = await db
826 .select()
827 .from(issues)
828 .where(
829 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
830 )
831 .limit(1);
832 if (issueRow) {
833 fromIssueNumber = issueRow.number;
834 fromIssueTitle = issueRow.title;
835 prefilledSpec = buildSpecFromIssue({
836 number: issueRow.number,
837 title: issueRow.title,
838 body: issueRow.body,
839 });
840 }
841 } catch {
842 // Pre-fill is a convenience, never block form render.
843 }
844 }
845 }
846
14c3cc8Claude847 return c.html(
848 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
849 <RepoHeader owner={owner} repo={repo} />
850 <SpecForm
851 ownerName={owner}
852 repoName={repo}
853 branches={branches}
854 defaultBranch={resolved.defaultBranch}
fbf4aefClaude855 spec={prefilledSpec}
856 fromIssueNumber={fromIssueNumber}
857 fromIssueTitle={fromIssueTitle}
14c3cc8Claude858 />
859 </Layout>
860 );
861});
862
863specs.post("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
864 const { owner, repo } = c.req.param();
865 const user = c.get("user")!;
866
867 const resolved = await resolveRepo(owner, repo);
868 if (!resolved) return c.notFound();
869
870 if (!hasWriteAccess(resolved, user.id)) {
871 return c.html(
872 <Layout title="Forbidden" user={user}>
873 <RepoHeader owner={owner} repo={repo} />
93812e4Claude874 <div class="specs-empty">
875 <h2>Write access required</h2>
14c3cc8Claude876 <p>You need write access to generate a spec-to-PR on this repository.</p>
93812e4Claude877 </div>
878 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
14c3cc8Claude879 </Layout>,
880 403
881 );
882 }
883
884 const body = await c.req.parseBody();
885 const spec = String(body.spec || "").trim();
886 const baseRef = String(body.baseRef || resolved.defaultBranch).trim()
887 || resolved.defaultBranch;
888
889 let branches: string[] = [];
890 try {
891 branches = await listBranches(owner, repo);
892 } catch {
893 branches = [];
894 }
895
896 function renderWithError(error: string, status: 400 | 500 | 503 = 400) {
897 return c.html(
898 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
899 <RepoHeader owner={owner} repo={repo} />
900 <SpecForm
901 ownerName={owner}
902 repoName={repo}
903 branches={branches}
904 defaultBranch={resolved!.defaultBranch}
905 spec={spec}
906 baseRef={baseRef}
907 error={error}
908 />
909 </Layout>,
910 status
911 );
912 }
913
914 if (!spec) {
915 return renderWithError("Spec is required.");
916 }
917
918 // Dynamically import the backend so this file works even before the
919 // sibling branch landing `src/lib/spec-to-pr.ts` is merged. If the module
920 // is missing or throws we surface a soft error instead of 500-ing.
921 let createSpecPR:
922 | ((args: {
923 repoId: string;
924 spec: string;
925 baseRef: string;
926 userId: string;
927 }) => Promise<
928 | { ok: true; prNumber: number }
929 | { ok: false; error: string }
930 >)
931 | null = null;
932 try {
933 const mod: any = await import("../lib/spec-to-pr");
934 createSpecPR =
935 (mod && (mod.createSpecPR || (mod.default && mod.default.createSpecPR))) ||
936 null;
937 } catch {
938 createSpecPR = null;
939 }
940
941 if (!createSpecPR) {
942 return renderWithError(
943 "Backend not available — spec-to-PR is not deployed yet. Please try again later.",
944 503
945 );
946 }
947
eb5963bClaude948 // Kick off the spec job in the background and redirect to the live
949 // progress page immediately. This means the user sees a live timeline
950 // rather than staring at a browser spinner for 10-30s.
951 const jobId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
952 const job: SpecJob = {
953 id: jobId,
954 owner,
955 repo,
956 stage: "queued",
957 label: STAGE_LABELS.queued,
958 prNumber: null,
959 error: null,
960 startedAt: Date.now(),
961 completedAt: null,
962 };
963 specJobs.set(jobId, job);
964
965 // Fire and forget — never await so the response is immediate.
966 void runSpecJobInBackground(job, {
967 repoId: resolved.repoId,
968 spec,
969 baseRef,
970 userId: user.id,
971 });
972
973 return c.redirect(`/${owner}/${repo}/spec/${jobId}/progress`);
974});
975
976// ─── Spec progress routes ───────────────────────────────────────────────────
977//
978// GET /:owner/:repo/spec/:jobId/progress — HTML progress page
979// GET /:owner/:repo/spec/:jobId/progress/events — SSE stream
980// GET /:owner/:repo/spec/:jobId/status — JSON polling endpoint
981
982const progressStyles = `
983 .sp-wrap {
984 max-width: 640px;
985 margin: 0 auto;
986 padding: var(--space-8) var(--space-4);
987 }
988 .sp-head {
989 margin-bottom: var(--space-6);
990 text-align: center;
991 }
992 .sp-eyebrow {
993 display: inline-flex;
994 align-items: center;
995 gap: 8px;
996 text-transform: uppercase;
997 font-family: var(--font-mono);
998 font-size: 11px;
999 letter-spacing: 0.16em;
1000 color: var(--text-muted);
1001 font-weight: 600;
1002 margin-bottom: 10px;
1003 }
1004 .sp-title {
1005 font-family: var(--font-display);
1006 font-size: clamp(22px, 3vw, 30px);
1007 font-weight: 800;
1008 letter-spacing: -0.024em;
1009 color: var(--text-strong);
1010 margin: 0 0 8px;
1011 }
1012 .sp-sub {
1013 font-size: 13px;
1014 color: var(--text-muted);
1015 margin: 0;
1016 }
1017 /* ── Timeline ── */
1018 .sp-timeline {
1019 position: relative;
1020 padding: 0;
1021 list-style: none;
1022 margin: 0;
1023 }
1024 .sp-timeline::before {
1025 content: '';
1026 position: absolute;
1027 left: 20px;
1028 top: 12px;
1029 bottom: 12px;
1030 width: 1px;
1031 background: var(--border);
1032 }
1033 .sp-step {
1034 position: relative;
1035 display: flex;
1036 align-items: flex-start;
1037 gap: 16px;
1038 padding: 12px 0;
1039 }
1040 .sp-dot {
1041 flex-shrink: 0;
1042 width: 40px;
1043 height: 40px;
1044 border-radius: 50%;
1045 display: flex;
1046 align-items: center;
1047 justify-content: center;
1048 font-size: 16px;
1049 background: var(--bg-elevated);
1050 border: 2px solid var(--border);
1051 position: relative;
1052 z-index: 1;
1053 transition: border-color 300ms ease, background 300ms ease;
1054 }
1055 .sp-dot.is-done {
1056 border-color: #22c55e;
1057 background: rgba(34,197,94,0.12);
1058 }
1059 .sp-dot.is-active {
6fd5915Claude1060 border-color: #5b6ee8;
1061 background: rgba(91,110,232,0.14);
eb5963bClaude1062 animation: sp-pulse 1.6s ease-in-out infinite;
1063 }
1064 .sp-dot.is-error {
1065 border-color: #f87171;
1066 background: rgba(248,113,113,0.12);
1067 }
1068 @keyframes sp-pulse {
6fd5915Claude1069 0%, 100% { box-shadow: 0 0 0 0 rgba(91,110,232,0.35); }
1070 50% { box-shadow: 0 0 0 8px rgba(91,110,232,0); }
eb5963bClaude1071 }
1072 .sp-step-body {
1073 padding-top: 8px;
1074 }
1075 .sp-step-label {
1076 font-size: 14px;
1077 font-weight: 600;
1078 color: var(--text-muted);
1079 transition: color 300ms ease;
1080 }
1081 .sp-step-label.is-done { color: #22c55e; }
1082 .sp-step-label.is-active { color: var(--text-strong); }
1083 .sp-step-label.is-error { color: #f87171; }
1084 .sp-step-detail {
1085 font-size: 12px;
1086 color: var(--text-muted);
1087 margin-top: 2px;
1088 font-family: var(--font-mono);
1089 }
1090 /* ── Done card ── */
1091 .sp-done-card {
1092 margin-top: var(--space-5);
1093 padding: 20px 24px;
1094 background: rgba(34,197,94,0.08);
1095 border: 1px solid rgba(34,197,94,0.30);
1096 border-radius: 14px;
1097 text-align: center;
1098 display: none;
1099 }
1100 .sp-done-card.is-visible { display: block; }
1101 .sp-done-card h2 {
1102 font-family: var(--font-display);
1103 font-size: 18px;
1104 font-weight: 700;
1105 color: #22c55e;
1106 margin: 0 0 8px;
1107 }
1108 .sp-done-card p {
1109 font-size: 13px;
1110 color: var(--text-muted);
1111 margin: 0 0 14px;
1112 }
1113 .sp-done-link {
1114 display: inline-flex;
1115 align-items: center;
1116 gap: 6px;
1117 padding: 10px 20px;
1118 background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
1119 color: #fff;
1120 border-radius: 10px;
1121 font-size: 13.5px;
1122 font-weight: 600;
1123 text-decoration: none;
1124 }
1125 .sp-done-link:hover { opacity: 0.92; text-decoration: none; }
1126 /* ── Error card ── */
1127 .sp-error-card {
1128 margin-top: var(--space-5);
1129 padding: 16px 20px;
1130 background: rgba(248,113,113,0.08);
1131 border: 1px solid rgba(248,113,113,0.30);
1132 border-radius: 14px;
1133 display: none;
1134 }
1135 .sp-error-card.is-visible { display: block; }
1136 .sp-error-card p {
1137 font-size: 13px;
1138 color: #fca5a5;
1139 margin: 0 0 10px;
1140 }
1141 .sp-error-card a {
1142 font-size: 13px;
1143 color: #f87171;
1144 text-decoration: underline;
1145 }
1146`;
1147
1148/** Inline JS for the progress page — polls /status every 2s. ~18 lines. */
1149const PROGRESS_POLL_JS = `
1150(function() {
1151 var stages = ['queued','analyzing','writing','opening_pr','done','error'];
1152 var url = location.pathname.replace(/\\/progress$/, '/status');
1153 function update(d) {
1154 stages.forEach(function(s) {
1155 var dot = document.getElementById('dot-' + s);
1156 var lbl = document.getElementById('lbl-' + s);
1157 if (!dot || !lbl) return;
1158 dot.className = 'sp-dot';
1159 lbl.className = 'sp-step-label';
1160 var si = stages.indexOf(s), di = stages.indexOf(d.stage);
1161 if (si < di) { dot.className += ' is-done'; lbl.className += ' is-done'; }
1162 else if (s === d.stage && s !== 'error') { dot.className += ' is-active'; lbl.className += ' is-active'; }
1163 else if (s === 'error' && d.stage === 'error') { dot.className += ' is-error'; lbl.className += ' is-error'; }
14c3cc8Claude1164 });
eb5963bClaude1165 if (d.stage === 'done' && d.prNumber) {
1166 var c = document.getElementById('sp-done-card');
1167 var lk = document.getElementById('sp-pr-link');
1168 if (c) c.className = 'sp-done-card is-visible';
1169 if (lk) lk.href = lk.dataset.base + '/' + d.prNumber;
1170 }
1171 if (d.stage === 'error') {
1172 var ec = document.getElementById('sp-error-card');
1173 var em = document.getElementById('sp-error-msg');
1174 if (ec) ec.className = 'sp-error-card is-visible';
1175 if (em) em.textContent = d.error || 'Unknown error.';
1176 }
1177 if (d.stage !== 'done' && d.stage !== 'error') { setTimeout(poll, 2000); }
1178 }
1179 function poll() {
1180 fetch(url).then(function(r){ return r.json(); }).then(update).catch(function(){ setTimeout(poll, 3000); });
14c3cc8Claude1181 }
eb5963bClaude1182 poll();
1183})();
1184`;
1185
1186/** The 4 pipeline steps shown on the progress page (excluding queued). */
1187const PIPELINE_STEPS: Array<{ stage: SpecStage; label: string; icon: string }> = [
1188 { stage: "analyzing", label: "Analyzing spec", icon: "🔍" },
1189 { stage: "writing", label: "Writing code", icon: "✍️" },
1190 { stage: "opening_pr", label: "Opening PR", icon: "📬" },
1191 { stage: "done", label: "Done", icon: "✅" },
1192];
1193
1194function ProgressPage({
1195 owner,
1196 repo,
1197 job,
1198}: {
1199 owner: string;
1200 repo: string;
1201 jobId: string;
1202 job: SpecJob;
1203}) {
1204 const stageIdx = (s: SpecStage) => {
1205 const order: SpecStage[] = ["queued", "analyzing", "writing", "opening_pr", "done", "error"];
1206 return order.indexOf(s);
1207 };
1208 const curIdx = stageIdx(job.stage);
14c3cc8Claude1209
eb5963bClaude1210 return (
1211 <div class="sp-wrap">
1212 <header class="sp-head">
1213 <div class="sp-eyebrow">Spec to PR · Live progress</div>
1214 <h1 class="sp-title">Building your PR…</h1>
1215 <p class="sp-sub">Sit tight — Claude is generating the implementation.</p>
1216 </header>
1217
1218 <ul class="sp-timeline">
1219 {PIPELINE_STEPS.map(({ stage, label, icon }) => {
1220 const sIdx = stageIdx(stage);
1221 const isDone = curIdx > sIdx || (job.stage === "done" && stage === "done");
1222 const isActive = job.stage === stage;
1223 const isError = job.stage === "error" && stage === "writing"; // show error on writing step
1224 let dotClass = "sp-dot";
1225 let lblClass = "sp-step-label";
1226 if (isDone) { dotClass += " is-done"; lblClass += " is-done"; }
1227 else if (isActive) { dotClass += " is-active"; lblClass += " is-active"; }
1228 else if (isError) { dotClass += " is-error"; lblClass += " is-error"; }
1229
1230 return (
1231 <li class="sp-step">
1232 <div id={`dot-${stage}`} class={dotClass}>{icon}</div>
1233 <div class="sp-step-body">
1234 <div id={`lbl-${stage}`} class={lblClass}>{label}</div>
1235 </div>
1236 </li>
1237 );
1238 })}
1239 {/* always show the error stage dot so JS can target it */}
1240 <li class="sp-step" id="step-error" style={job.stage === "error" ? "" : "display:none"}>
1241 <div id="dot-error" class={`sp-dot${job.stage === "error" ? " is-error" : ""}`}>❌</div>
1242 <div class="sp-step-body">
1243 <div id="lbl-error" class={`sp-step-label${job.stage === "error" ? " is-error" : ""}`}>Error</div>
1244 </div>
1245 </li>
1246 </ul>
1247
1248 {/* Done card — hidden until JS reveals it */}
1249 <div
1250 id="sp-done-card"
1251 class={`sp-done-card${job.stage === "done" ? " is-visible" : ""}`}
1252 >
1253 <h2>PR opened!</h2>
1254 {job.completedAt && job.startedAt && (
1255 <p>
1256 Merged in {Math.round((job.completedAt - job.startedAt) / 1000)}s
1257 </p>
1258 )}
1259 <a
1260 id="sp-pr-link"
1261 class="sp-done-link"
1262 href={job.prNumber ? `/${owner}/${repo}/pulls/${job.prNumber}` : "#"}
1263 data-base={`/${owner}/${repo}/pulls`}
1264 >
1265 View PR {job.prNumber ? `#${job.prNumber}` : ""}
1266 </a>
1267 </div>
1268
1269 {/* Error card */}
1270 <div
1271 id="sp-error-card"
1272 class={`sp-error-card${job.stage === "error" ? " is-visible" : ""}`}
1273 >
1274 <p id="sp-error-msg">{job.error || ""}</p>
1275 <a href={`/${owner}/${repo}/spec`}>Try again</a>
1276 </div>
1277
1278 <script dangerouslySetInnerHTML={{ __html: PROGRESS_POLL_JS }} />
1279 <style dangerouslySetInnerHTML={{ __html: progressStyles }} />
1280 </div>
1281 );
1282}
1283
1284// GET /:owner/:repo/spec/:jobId/progress — HTML progress page
1285specs.get("/:owner/:repo/spec/:jobId/progress", softAuth, requireAuth, async (c) => {
1286 const { owner, repo, jobId } = c.req.param();
1287 const user = c.get("user")!;
1288
1289 const resolved = await resolveRepo(owner, repo);
1290 if (!resolved) return c.notFound();
1291 if (!hasWriteAccess(resolved, user.id)) return c.text("Forbidden", 403);
1292
1293 const job = specJobs.get(jobId);
1294 if (!job) {
1295 // Job expired or unknown — redirect to spec form.
1296 return c.redirect(`/${owner}/${repo}/spec`);
1297 }
1298
1299 // If already done, redirect straight to the PR.
1300 if (job.stage === "done" && job.prNumber) {
1301 return c.redirect(`/${owner}/${repo}/pulls/${job.prNumber}`);
14c3cc8Claude1302 }
1303
eb5963bClaude1304 return c.html(
1305 <Layout title={`Generating PR — ${owner}/${repo}`} user={user}>
1306 <RepoHeader owner={owner} repo={repo} />
1307 <ProgressPage owner={owner} repo={repo} jobId={jobId} job={job} />
1308 </Layout>
1309 );
1310});
1311
1312// GET /:owner/:repo/spec/:jobId/progress/events — SSE stream
1313specs.get("/:owner/:repo/spec/:jobId/progress/events", softAuth, requireAuth, async (c) => {
1314 const { owner, repo, jobId } = c.req.param();
1315 const user = c.get("user")!;
1316
1317 const resolved = await resolveRepo(owner, repo);
1318 if (!resolved) return c.json({ error: "Not found" }, 404);
1319 if (!hasWriteAccess(resolved, user.id)) return c.json({ error: "Forbidden" }, 403);
1320
1321 const job = specJobs.get(jobId);
1322 if (!job) return c.json({ error: "Job not found" }, 404);
1323
1324 const topic = specJobTopic(jobId);
1325 const encoder = new TextEncoder();
1326
1327 const stream = new ReadableStream<Uint8Array>({
1328 start(controller) {
1329 let closed = false;
1330 const safeEnqueue = (chunk: string) => {
1331 if (closed) return;
1332 try { controller.enqueue(encoder.encode(chunk)); } catch { closed = true; }
1333 };
1334
1335 safeEnqueue(": open\n\n");
1336
1337 // Send current state immediately so late connectors get a snapshot.
1338 const snapJob = specJobs.get(jobId);
1339 if (snapJob) {
1340 const payload = JSON.stringify({ stage: snapJob.stage, label: snapJob.label, prNumber: snapJob.prNumber, error: snapJob.error });
1341 safeEnqueue(`event: stage\ndata: ${payload}\n\n`);
1342 }
1343
1344 const unsubscribe = subscribe(topic, (ev) => {
1345 let out = "";
1346 if (ev.event) out += `event: ${ev.event}\n`;
1347 const data = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
1348 for (const line of data.split("\n")) out += `data: ${line}\n`;
1349 out += "\n";
1350 safeEnqueue(out);
1351 });
1352
1353 const ping = setInterval(() => safeEnqueue(": ping\n\n"), 25_000);
1354
1355 const cleanup = () => {
1356 if (closed) return;
1357 closed = true;
1358 clearInterval(ping);
1359 unsubscribe();
1360 try { controller.close(); } catch { /* already closed */ }
1361 };
1362
1363 const signal = c.req.raw.signal;
1364 if (signal) {
1365 if (signal.aborted) cleanup();
1366 else signal.addEventListener("abort", cleanup, { once: true });
1367 }
1368 },
1369 });
1370
1371 return new Response(stream, {
1372 status: 200,
1373 headers: {
1374 "Content-Type": "text/event-stream; charset=utf-8",
1375 "Cache-Control": "no-cache, no-transform",
1376 "Connection": "keep-alive",
1377 "X-Accel-Buffering": "no",
1378 },
1379 });
1380});
1381
1382// GET /:owner/:repo/spec/:jobId/status — JSON polling endpoint
1383specs.get("/:owner/:repo/spec/:jobId/status", softAuth, requireAuth, async (c) => {
1384 const { owner, repo, jobId } = c.req.param();
1385 const user = c.get("user")!;
1386
1387 const resolved = await resolveRepo(owner, repo);
1388 if (!resolved) return c.json({ error: "Not found" }, 404);
1389 if (!hasWriteAccess(resolved, user.id)) return c.json({ error: "Forbidden" }, 403);
1390
1391 const job = specJobs.get(jobId);
1392 if (!job) return c.json({ error: "Job not found or expired" }, 404);
1393
1394 return c.json({
1395 jobId: job.id,
1396 stage: job.stage,
1397 label: job.label,
1398 prNumber: job.prNumber,
1399 error: job.error,
1400 startedAt: job.startedAt,
1401 completedAt: job.completedAt,
1402 });
14c3cc8Claude1403});
1404
950ef90Claude1405// ─── GET /specs — dashboard listing all specs across the user's repos ──────
1406//
1407// Walks every repo the signed-in user owns, looks at `.gluecron/specs/*.md`
1408// on the default branch, parses the front-matter for `status:` + `title:`,
1409// and surfaces a flat list with linked PR (when one exists). Soft-fails on
1410// any repo that can't be scanned — the dashboard still renders.
1411
1412interface SpecRow {
1413 ownerName: string;
1414 repoName: string;
1415 specPath: string;
1416 title: string;
1417 status: SpecStatus;
1418 prNumber: number | null;
1419}
1420
1421async function collectSpecsForOwner(ownerId: string): Promise<SpecRow[]> {
1422 let owner: { username: string } | undefined;
1423 try {
1424 const [row] = await db
1425 .select({ username: users.username })
1426 .from(users)
1427 .where(eq(users.id, ownerId))
1428 .limit(1);
1429 owner = row;
1430 } catch {
1431 return [];
1432 }
1433 if (!owner) return [];
1434
1435 let repos: Array<{ id: string; name: string; defaultBranch: string }> = [];
1436 try {
1437 repos = await db
1438 .select({
1439 id: repositories.id,
1440 name: repositories.name,
1441 defaultBranch: repositories.defaultBranch,
1442 })
1443 .from(repositories)
1444 .where(
1445 and(eq(repositories.ownerId, ownerId), eq(repositories.isArchived, false))
1446 )
1447 .limit(100);
1448 } catch {
1449 return [];
1450 }
1451
1452 const out: SpecRow[] = [];
1453 for (const repo of repos) {
1454 const branch = repo.defaultBranch || "main";
1455 let paths: string[] = [];
1456 try {
1457 const tree = await getTreeRecursive(owner.username, repo.name, branch, 5000);
1458 if (!tree) continue;
1459 paths = tree.tree
1460 .filter(
1461 (e) =>
1462 e.type === "blob" &&
1463 e.path.startsWith(".gluecron/specs/") &&
1464 e.path.toLowerCase().endsWith(".md")
1465 )
1466 .map((e) => e.path);
1467 } catch {
1468 continue;
1469 }
1470 for (const p of paths) {
1471 let content = "";
1472 try {
1473 const blob = await getBlob(owner.username, repo.name, branch, p);
1474 if (!blob || blob.isBinary) continue;
1475 content = blob.content;
1476 } catch {
1477 continue;
1478 }
1479 const parsed = parseFrontMatter(content);
1480 const title =
1481 (parsed.frontMatter.title && parsed.frontMatter.title.trim()) ||
1482 (p.split("/").pop() || p).replace(/\.md$/i, "");
1483 const statusRaw = (parsed.frontMatter.status || "draft").toLowerCase();
1484 const status: SpecStatus =
1485 statusRaw === "draft" ||
1486 statusRaw === "ready" ||
1487 statusRaw === "building" ||
1488 statusRaw === "shipped" ||
1489 statusRaw === "failed"
1490 ? (statusRaw as SpecStatus)
1491 : "draft";
1492
1493 // Best-effort PR lookup: any PR whose body mentions the spec path
1494 // and carries the spec marker. Newest first.
1495 let prNumber: number | null = null;
1496 try {
1497 const [pr] = await db
1498 .select({ number: pullRequests.number })
1499 .from(pullRequests)
1500 .where(
1501 and(
1502 eq(pullRequests.repositoryId, repo.id),
1503 like(pullRequests.body, `%${AI_SPEC_PR_MARKER}%`),
1504 like(pullRequests.body, `%${p}%`)
1505 )
1506 )
1507 .orderBy(pullRequests.createdAt)
1508 .limit(1);
1509 if (pr) prNumber = pr.number;
1510 } catch {
1511 prNumber = null;
1512 }
1513
1514 out.push({
1515 ownerName: owner.username,
1516 repoName: repo.name,
1517 specPath: p,
1518 title,
1519 status,
1520 prNumber,
1521 });
1522 }
1523 }
1524 return out;
1525}
1526
1527specs.get("/specs", softAuth, requireAuth, async (c) => {
1528 const user = c.get("user")!;
1529 const rows = await collectSpecsForOwner(user.id);
1530
1531 const byStatus = (s: SpecStatus) => rows.filter((r) => r.status === s);
1532
1533 const statusBadgeColors: Record<SpecStatus, string> = {
1534 draft: "rgba(148,163,184,0.16)",
6fd5915Claude1535 ready: "rgba(95,143,160,0.18)",
1536 building: "rgba(91,110,232,0.20)",
950ef90Claude1537 shipped: "rgba(34,197,94,0.18)",
1538 failed: "rgba(248,113,113,0.18)",
1539 };
1540
1541 return c.html(
1542 <Layout title="Specs — Gluecron" user={user}>
1543 <div class="specs-wrap">
1544 <header class="specs-head">
1545 <div class="specs-eyebrow">
1546 <span class="specs-eyebrow-dot" aria-hidden="true" />
1547 Account · Specs dashboard
1548 <span class="specs-pill-experimental">Experimental</span>
1549 </div>
1550 <h1 class="specs-title">
1551 <span class="specs-title-grad">Your spec library.</span>
1552 </h1>
1553 <p class="specs-sub">
1554 Every <code>.gluecron/specs/*.md</code> file across your
1555 repositories. Set a spec's front-matter to{" "}
1556 <code>status: ready</code> and the autopilot picks it up within
1557 two minutes to open an implementation PR tagged{" "}
1558 <code>ai:spec-implementation</code>.
1559 </p>
1560 </header>
1561
1562 <section class="specs-section">
1563 <header class="specs-section-head">
1564 <h2 class="specs-section-title">All specs ({rows.length})</h2>
1565 <p class="specs-section-sub">
1566 {byStatus("ready").length} ready · {byStatus("building").length}{" "}
1567 building · {byStatus("shipped").length} shipped ·{" "}
1568 {byStatus("failed").length} failed · {byStatus("draft").length}{" "}
1569 draft
1570 </p>
1571 </header>
1572 <div class="specs-section-body">
1573 {rows.length === 0 ? (
1574 <p class="specs-field-hint" style="font-size:13px;">
1575 No specs yet. Create a file like{" "}
1576 <code>.gluecron/specs/my-feature.md</code> in any of your
1577 repos with front-matter{" "}
1578 <code>---\ntitle: Add dark mode\nstatus: ready\n---</code> and
1579 it will land here.
1580 </p>
1581 ) : (
1582 <table style="width:100%;border-collapse:collapse;font-size:13px;">
1583 <thead>
1584 <tr style="text-align:left;color:var(--text-muted);font-size:11px;text-transform:uppercase;letter-spacing:0.06em;">
1585 <th style="padding:8px 4px;">Repo</th>
1586 <th style="padding:8px 4px;">Title</th>
1587 <th style="padding:8px 4px;">Status</th>
1588 <th style="padding:8px 4px;">PR</th>
1589 <th style="padding:8px 4px;">Path</th>
1590 </tr>
1591 </thead>
1592 <tbody>
1593 {rows.map((r) => (
1594 <tr style="border-top:1px solid var(--border);">
1595 <td style="padding:10px 4px;">
1596 <a href={`/${r.ownerName}/${r.repoName}`}>
1597 {r.ownerName}/{r.repoName}
1598 </a>
1599 </td>
1600 <td style="padding:10px 4px;color:var(--text-strong);">
1601 {r.title}
1602 </td>
1603 <td style="padding:10px 4px;">
1604 <span
1605 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);`}
1606 >
1607 {r.status}
1608 </span>
1609 </td>
1610 <td style="padding:10px 4px;">
1611 {r.prNumber ? (
1612 <a
1613 href={`/${r.ownerName}/${r.repoName}/pulls/${r.prNumber}`}
1614 >
1615 #{r.prNumber}
1616 </a>
1617 ) : (
1618 <span style="color:var(--text-muted);">—</span>
1619 )}
1620 </td>
1621 <td style="padding:10px 4px;font-family:var(--font-mono);font-size:11.5px;color:var(--text-muted);">
1622 <a
1623 href={`/${r.ownerName}/${r.repoName}/blob/main/${r.specPath}`}
1624 >
1625 {r.specPath}
1626 </a>
1627 </td>
1628 </tr>
1629 ))}
1630 </tbody>
1631 </table>
1632 )}
1633 </div>
1634 </section>
1635
1636 <style dangerouslySetInnerHTML={{ __html: specsStyles }} />
1637 </div>
1638 </Layout>
1639 );
1640});
1641
14c3cc8Claude1642export default specs;