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

ai-changelog.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.

ai-changelog.tsxBlame944 lines · 1 contributor
3cbe3d6Claude1/**
2 * Block D7 — AI-generated changelog for an arbitrary commit range.
3 *
4 * GET /:owner/:repo/ai/changelog
5 * - No query args: renders a form (from/to selects populated from
6 * branches + recent tags).
7 * - ?from=&to= (&format=markdown|html): runs `git log <from>..<to>`,
8 * feeds commits to `generateChangelog`, and renders the result.
9 * - ?format=markdown returns `text/markdown` for CLI/CI consumers.
10 *
11 * Public repos are readable without auth (softAuth) — matching the
12 * behaviour of `src/routes/compare.tsx`.
e73e288Claude13 *
14 * 2026 polish — every form action, POST target, ?format=markdown branch,
15 * and the `generateChangelog(...)` AI prompt construction are preserved
16 * verbatim. Visual treatment scoped under `.ai-changelog-*` so this
17 * surface can't share CSS with ai-explain or ai-tests.
3cbe3d6Claude18 */
19
20import { Hono } from "hono";
21import { Layout } from "../views/layout";
22import { RepoHeader } from "../views/components";
23import { IssueNav } from "./issues";
24import {
25 listBranches,
26 listTags,
27 resolveRef,
28 repoExists,
29 getRepoPath,
30} from "../git/repository";
31import { generateChangelog } from "../lib/ai-generators";
32import { renderMarkdown } from "../lib/markdown";
33import { softAuth } from "../middleware/auth";
34import type { AuthEnv } from "../middleware/auth";
35
36const aiChangelog = new Hono<AuthEnv>();
37
38aiChangelog.use("*", softAuth);
39
e73e288Claude40/* ─────────────────────────────────────────────────────────────────────────
41 * Scoped CSS — every class prefixed `.ai-changelog-` so this surface can't
42 * bleed into ai-explain or ai-tests. Mirrors the gradient-hairline hero,
43 * focus-rings on inputs (uses :root --border-focus token), white result
44 * panel + copy-to-clipboard pattern from admin-integrations.tsx.
45 * ───────────────────────────────────────────────────────────────────── */
46const styles = `
a6dc91cClaude47 .ai-changelog-wrap { max-width: 1120px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
e73e288Claude48
49 .ai-changelog-hero {
50 position: relative;
51 margin-bottom: var(--space-5);
52 padding: var(--space-5) var(--space-6);
53 background: var(--bg-elevated);
54 border: 1px solid var(--border);
55 border-radius: 16px;
56 overflow: hidden;
57 }
58 .ai-changelog-hero::before {
59 content: '';
60 position: absolute;
61 top: 0; left: 0; right: 0;
62 height: 2px;
63 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
64 opacity: 0.7;
65 pointer-events: none;
66 }
67 .ai-changelog-hero-orb {
68 position: absolute;
69 inset: -20% -10% auto auto;
70 width: 420px; height: 420px;
71 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
72 filter: blur(80px);
73 opacity: 0.75;
74 pointer-events: none;
75 z-index: 0;
76 }
77 .ai-changelog-hero-inner { position: relative; z-index: 1; max-width: 720px; }
78 .ai-changelog-eyebrow {
79 font-size: 12px;
80 color: var(--text-muted);
81 margin-bottom: var(--space-2);
82 letter-spacing: 0.02em;
83 display: inline-flex;
84 align-items: center;
85 gap: 8px;
86 }
87 .ai-changelog-eyebrow .pill {
88 display: inline-flex;
89 align-items: center;
90 justify-content: center;
91 width: 18px; height: 18px;
92 border-radius: 6px;
93 background: rgba(140,109,255,0.14);
94 color: #b69dff;
95 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
96 }
97 .ai-changelog-title {
98 font-size: clamp(28px, 4vw, 42px);
99 font-family: var(--font-display);
100 font-weight: 800;
101 letter-spacing: -0.028em;
102 line-height: 1.05;
103 margin: 0 0 var(--space-2);
104 color: var(--text-strong);
105 }
106 .ai-changelog-title-grad {
107 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
108 -webkit-background-clip: text;
109 background-clip: text;
110 -webkit-text-fill-color: transparent;
111 color: transparent;
112 }
113 .ai-changelog-sub {
114 font-size: 15px;
115 color: var(--text-muted);
116 margin: 0;
117 line-height: 1.55;
118 max-width: 640px;
119 }
120
121 /* Banners (error / notice). Keep the legacy .auth-error class on the
122 error banner because the test asserts against it; just upgrade the
123 visual via the new wrapping class. */
124 .ai-changelog-banner {
125 margin-bottom: var(--space-4);
126 padding: 10px 14px;
127 border-radius: 10px;
128 font-size: 13.5px;
129 border: 1px solid var(--border);
130 background: rgba(255,255,255,0.025);
131 color: var(--text);
132 }
133 .ai-changelog-banner.is-error {
134 border-color: rgba(248,113,113,0.40);
135 background: rgba(248,113,113,0.08);
136 color: #fecaca;
137 }
138 .ai-changelog-banner.is-notice {
139 border-color: rgba(140,109,255,0.30);
140 background: rgba(140,109,255,0.06);
141 color: var(--text);
142 }
143
144 /* Form card */
145 .ai-changelog-form-card {
146 margin-bottom: var(--space-5);
147 background: var(--bg-elevated);
148 border: 1px solid var(--border);
149 border-radius: 14px;
150 padding: var(--space-4) var(--space-5);
151 }
152 .ai-changelog-form-row {
153 display: flex;
154 gap: 12px;
155 align-items: end;
156 flex-wrap: wrap;
157 }
158 .ai-changelog-field {
159 display: flex;
160 flex-direction: column;
161 gap: 6px;
162 min-width: 180px;
163 flex: 1 1 200px;
164 }
165 .ai-changelog-field-label {
166 font-size: 11px;
167 font-weight: 700;
168 text-transform: uppercase;
169 letter-spacing: 0.12em;
170 color: var(--text-muted);
171 }
172 .ai-changelog-input {
173 width: 100%;
174 padding: 9px 12px;
175 font-size: 13.5px;
176 color: var(--text);
177 background: var(--bg);
178 border: 1px solid var(--border-strong);
179 border-radius: 8px;
180 outline: none;
181 font-family: var(--font-mono);
182 transition: border-color 120ms ease, box-shadow 120ms ease;
183 box-sizing: border-box;
184 }
185 .ai-changelog-input:focus {
186 border-color: var(--border-focus);
187 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
188 }
189 .ai-changelog-submit {
190 display: inline-flex;
191 align-items: center;
192 gap: 7px;
193 padding: 9px 16px;
194 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
195 color: #ffffff;
196 border: 1px solid transparent;
197 border-radius: 10px;
198 font-size: 13.5px;
199 font-weight: 600;
200 cursor: pointer;
201 box-shadow: 0 6px 16px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
202 font-family: inherit;
203 transition: transform 120ms ease, box-shadow 120ms ease;
204 line-height: 1;
205 }
206 .ai-changelog-submit:hover {
207 transform: translateY(-1px);
208 box-shadow: 0 10px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
209 }
210 .ai-changelog-submit svg { display: block; }
211
212 .ai-changelog-ghost {
213 display: inline-flex;
214 align-items: center;
215 gap: 6px;
216 padding: 9px 14px;
217 background: transparent;
218 color: var(--text);
219 border: 1px solid var(--border-strong);
220 border-radius: 10px;
221 font-size: 13px;
222 font-weight: 600;
223 text-decoration: none;
224 cursor: pointer;
225 font-family: inherit;
226 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
227 line-height: 1;
228 }
229 .ai-changelog-ghost:hover {
230 background: rgba(140,109,255,0.06);
231 border-color: rgba(140,109,255,0.45);
232 color: var(--text-strong);
233 text-decoration: none;
234 }
235
236 .ai-changelog-knownrefs {
237 margin-top: var(--space-3);
238 font-size: 11.5px;
239 color: var(--text-muted);
240 }
241 .ai-changelog-knownrefs code {
242 font-family: var(--font-mono);
243 background: var(--bg-tertiary);
244 color: var(--text);
245 padding: 1px 5px;
246 border-radius: 4px;
247 font-size: 11px;
248 margin: 0 2px;
249 }
250
251 /* Meta line under the title once a range is loaded. */
252 .ai-changelog-rangeline {
253 display: flex;
254 align-items: center;
255 gap: 10px;
256 flex-wrap: wrap;
257 color: var(--text-muted);
258 font-size: 12.5px;
259 margin: var(--space-2) 0 var(--space-4);
260 }
261 .ai-changelog-rangeline code {
262 font-family: var(--font-mono);
263 background: var(--bg-tertiary);
264 color: var(--text);
265 padding: 1px 6px;
266 border-radius: 4px;
267 font-size: 12px;
268 }
269 .ai-changelog-rangeline .arrow { opacity: 0.55; }
270 .ai-changelog-rangeline .count {
271 display: inline-flex;
272 align-items: center;
273 gap: 5px;
274 padding: 2px 8px;
275 border-radius: 9999px;
276 background: rgba(140,109,255,0.10);
277 color: #c4b5fd;
278 font-size: 11px;
279 font-weight: 600;
280 letter-spacing: 0.04em;
281 text-transform: uppercase;
282 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
283 }
284
285 /* Result panels — split: rendered MD (left) + copyable raw (right) */
286 .ai-changelog-results {
287 display: grid;
288 grid-template-columns: 1fr 1fr;
289 gap: 20px;
290 align-items: start;
291 }
292 @media (max-width: 800px) {
293 .ai-changelog-results { grid-template-columns: 1fr; }
294 }
295
296 .ai-changelog-panel {
297 position: relative;
298 background: #ffffff;
299 color: #0a0a0a;
300 border: 1px solid #e5e7eb;
301 border-radius: 14px;
302 overflow: hidden;
303 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
304 }
305 .ai-changelog-panel-head {
306 display: flex;
307 align-items: center;
308 justify-content: space-between;
309 gap: 12px;
310 padding: 12px 16px;
311 background: #f9fafb;
312 border-bottom: 1px solid #e5e7eb;
313 flex-wrap: wrap;
314 }
315 .ai-changelog-panel-title {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 font-family: var(--font-display, system-ui, sans-serif);
320 font-size: 13.5px;
321 font-weight: 700;
322 color: #111827;
323 letter-spacing: -0.005em;
324 margin: 0;
325 }
326 .ai-changelog-panel-dot {
327 width: 8px; height: 8px;
328 border-radius: 9999px;
329 background: linear-gradient(135deg, #8c6dff, #36c5d6);
330 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
331 }
332 .ai-changelog-copy {
333 display: inline-flex;
334 align-items: center;
335 gap: 6px;
336 padding: 6px 12px;
337 font-size: 12.5px;
338 font-weight: 600;
339 color: #111827;
340 background: #ffffff;
341 border: 1px solid #d1d5db;
342 border-radius: 8px;
343 cursor: pointer;
344 font-family: inherit;
345 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
346 }
347 .ai-changelog-copy:hover { background: #f3f4f6; border-color: #9ca3af; }
348 .ai-changelog-copy.is-copied {
349 background: #ecfdf5;
350 border-color: #6ee7b7;
351 color: #047857;
352 }
353 .ai-changelog-copy svg { display: block; }
354 .ai-changelog-panel-body {
355 padding: 18px 22px;
356 background: #ffffff;
357 color: #0a0a0a;
358 }
359 .ai-changelog-panel-body .markdown-body {
360 color: #0a0a0a;
361 background: #ffffff;
362 font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif);
363 font-size: 14.5px;
364 line-height: 1.65;
365 }
366 .ai-changelog-panel-body .markdown-body h1,
367 .ai-changelog-panel-body .markdown-body h2,
368 .ai-changelog-panel-body .markdown-body h3 {
369 color: #0a0a0a;
370 border-bottom-color: #e5e7eb;
371 }
372 .ai-changelog-panel-body .markdown-body code {
373 background: #eef2ff;
374 color: #4338ca;
375 padding: 1px 5px;
376 border-radius: 4px;
377 font-family: var(--font-mono);
378 font-size: 12.5px;
379 }
380 .ai-changelog-panel-body .markdown-body a { color: #4338ca; }
381
382 .ai-changelog-raw {
383 width: 100%;
384 box-sizing: border-box;
385 min-height: 360px;
386 padding: 14px 16px;
387 font-family: var(--font-mono);
388 font-size: 12.5px;
389 line-height: 1.6;
390 color: #0a0a0a;
391 background: #ffffff;
392 border: 0;
393 outline: 0;
394 resize: vertical;
395 white-space: pre;
396 overflow: auto;
397 }
398
399 /* Empty-state — dashed orb card with "try" prompts. */
400 .ai-changelog-empty {
401 position: relative;
402 margin: var(--space-4) 0;
403 padding: var(--space-5);
404 border: 1px dashed var(--border-strong, var(--border));
405 border-radius: 14px;
406 background: var(--bg-elevated);
407 text-align: center;
408 overflow: hidden;
409 }
410 .ai-changelog-empty-orb {
411 position: absolute;
412 inset: -50% 30% auto 30%;
413 width: 320px; height: 320px;
414 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.06) 45%, transparent 70%);
415 filter: blur(70px);
416 pointer-events: none;
417 z-index: 0;
418 }
419 .ai-changelog-empty > * { position: relative; z-index: 1; }
420 .ai-changelog-empty h3 {
421 margin: 0 0 6px;
422 font-family: var(--font-display);
423 font-size: 16px;
424 color: var(--text-strong);
425 }
426 .ai-changelog-empty p {
427 margin: 0 0 6px;
428 color: var(--text-muted);
429 font-size: 13px;
430 }
431 .ai-changelog-empty .ai-changelog-suggests {
432 display: inline-flex;
433 flex-direction: column;
434 gap: 6px;
435 margin-top: 10px;
436 font-size: 12px;
437 color: var(--text-muted);
438 text-align: left;
439 }
440 .ai-changelog-empty code {
441 font-family: var(--font-mono);
442 background: var(--bg-tertiary);
443 color: var(--text);
444 padding: 2px 6px;
445 border-radius: 4px;
446 font-size: 11.5px;
447 }
448
449 /* Skeleton (kept for future async modes — the route currently runs
450 synchronously, so the placeholder only shows when explicitly wired). */
451 @keyframes ai-changelog-shimmer {
452 0% { background-position: -300px 0; }
453 100% { background-position: 300px 0; }
454 }
455 .ai-changelog-skeleton {
456 height: 12px;
457 border-radius: 4px;
458 background: linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(140,109,255,0.08) 50%, rgba(255,255,255,0.04) 100%);
459 background-size: 600px 100%;
460 animation: ai-changelog-shimmer 1.4s linear infinite;
461 margin-bottom: 8px;
462 }
463
464 /* Powered by Claude */
465 .ai-changelog-poweredby {
466 margin-top: var(--space-5);
467 text-align: center;
468 }
469 .ai-changelog-poweredby-pill {
470 display: inline-flex;
471 align-items: center;
472 gap: 6px;
473 padding: 4px 10px;
474 border-radius: 9999px;
475 background: rgba(140,109,255,0.08);
476 border: 1px solid rgba(140,109,255,0.22);
477 color: var(--text-muted);
478 font-size: 11px;
479 letter-spacing: 0.04em;
480 text-transform: uppercase;
481 font-weight: 600;
482 }
483 .ai-changelog-poweredby-pill .dot {
484 width: 6px; height: 6px;
485 border-radius: 9999px;
486 background: linear-gradient(135deg, #8c6dff, #36c5d6);
487 }
488
489 :root[data-theme='light'] .ai-changelog-panel {
490 box-shadow: 0 1px 0 rgba(0,0,0,0.02), 0 8px 28px rgba(15,16,28,0.08);
491 }
492`;
493
494const COPY_SCRIPT = `
495 (function(){
496 var btn = document.querySelector('[data-ai-changelog-copy]');
497 var src = document.querySelector('[data-ai-changelog-raw]');
498 var label = document.querySelector('[data-ai-changelog-copy-label]');
499 if (!btn || !src || !label) return;
500 btn.addEventListener('click', function(){
501 var text = (src.value !== undefined) ? src.value : (src.innerText || src.textContent || '');
502 var done = function(){
503 btn.classList.add('is-copied');
504 label.textContent = 'Copied';
505 setTimeout(function(){
506 btn.classList.remove('is-copied');
507 label.textContent = 'Copy';
508 }, 1800);
509 };
510 if (navigator.clipboard && navigator.clipboard.writeText) {
511 navigator.clipboard.writeText(text).then(done).catch(function(){
512 try { src.focus && src.focus(); src.select && src.select(); document.execCommand('copy'); done(); } catch(e){}
513 });
514 } else {
515 try { src.focus && src.focus(); src.select && src.select(); document.execCommand('copy'); done(); } catch(e){}
516 }
517 });
518 })();
519`;
520
521function ChangelogHero() {
522 return (
523 <section class="ai-changelog-hero">
524 <div class="ai-changelog-hero-orb" aria-hidden="true" />
525 <div class="ai-changelog-hero-inner">
526 <div class="ai-changelog-eyebrow">
527 <span class="pill" aria-hidden="true">
528 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
529 <path d="M12 8v4l3 3" />
530 <circle cx="12" cy="12" r="9" />
531 </svg>
532 </span>
533 AI · gluecron · changelog
534 </div>
535 <h1 class="ai-changelog-title">
536 <span class="ai-changelog-title-grad">Track.</span>{" "}
537 <span style="color:var(--text-strong)">AI Changelog</span>
538 </h1>
539 <p class="ai-changelog-sub">
540 Generate release notes for any commit range. Pick a base (from) and
541 a head (to) — Claude will group commits into Features / Fixes /
542 Perf / Refactors / Docs / Other.
543 </p>
544 </div>
545 </section>
546 );
547}
548
3cbe3d6Claude549interface RangeCommit {
550 sha: string;
551 message: string;
552 author: string;
553 date: string;
554}
555
556async function commitsInRange(
557 owner: string,
558 repo: string,
559 from: string,
560 to: string
561): Promise<RangeCommit[]> {
562 const repoDir = getRepoPath(owner, repo);
563 const proc = Bun.spawn(
564 [
565 "git",
566 "log",
567 "--format=%H%x00%s%x00%an%x00%aI",
568 `${from}..${to}`,
569 ],
570 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
571 );
572 const out = await new Response(proc.stdout).text();
573 await proc.exited;
574 return out
575 .trim()
576 .split("\n")
577 .filter(Boolean)
578 .slice(0, 500)
579 .map((line) => {
580 const [sha, message, author, date] = line.split("\0");
581 return { sha, message, author, date };
582 });
583}
584
585aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
586 const { owner, repo } = c.req.param();
587 const user = c.get("user");
588 const from = (c.req.query("from") || "").trim();
589 const to = (c.req.query("to") || "").trim();
590 const format = (c.req.query("format") || "").trim().toLowerCase();
591
592 if (!(await repoExists(owner, repo))) {
593 return c.html(
594 <Layout title="Not Found" user={user}>
595 <div class="empty-state">
596 <h2>Repository not found</h2>
597 </div>
598 </Layout>,
599 404
600 );
601 }
602
603 const [branches, tags] = await Promise.all([
604 listBranches(owner, repo).catch(() => [] as string[]),
605 listTags(owner, repo).catch(
606 () => [] as Array<{ name: string; sha: string; date: string }>
607 ),
608 ]);
609 const refChoices = [
610 ...branches,
611 ...tags.slice(0, 25).map((t) => t.name),
612 ];
613
614 const renderForm = (opts: { error?: string; notice?: string } = {}) =>
615 c.html(
616 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
617 <RepoHeader owner={owner} repo={repo} />
618 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude619 <div class="ai-changelog-wrap">
620 <ChangelogHero />
621
622 {opts.error && (
623 <div class="ai-changelog-banner is-error auth-error">
624 {opts.error}
625 </div>
626 )}
627 {opts.notice && (
628 <div class="ai-changelog-banner is-notice">{opts.notice}</div>
629 )}
630
631 <div class="ai-changelog-form-card">
632 <form
633 method="get"
634 action={`/${owner}/${repo}/ai/changelog`}
635 class="ai-changelog-form-row"
636 >
637 <div class="ai-changelog-field">
638 <label class="ai-changelog-field-label" for="ai-changelog-from">
639 From
640 </label>
641 <input
642 id="ai-changelog-from"
643 type="text"
644 name="from"
645 list="ai-changelog-refs"
646 value={from}
647 placeholder="v1.0.0"
648 aria-label="From ref"
649 class="ai-changelog-input"
650 />
651 </div>
652 <div class="ai-changelog-field">
653 <label class="ai-changelog-field-label" for="ai-changelog-to">
654 To
655 </label>
656 <input
657 id="ai-changelog-to"
658 type="text"
659 name="to"
660 list="ai-changelog-refs"
661 value={to}
662 placeholder="main"
663 aria-label="To ref"
664 class="ai-changelog-input"
665 />
666 </div>
667 <datalist id="ai-changelog-refs">
668 {refChoices.map((r) => (
669 <option value={r}></option>
670 ))}
671 </datalist>
672 <button type="submit" class="ai-changelog-submit">
673 Generate
674 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
675 <line x1="5" y1="12" x2="19" y2="12" />
676 <polyline points="12 5 19 12 12 19" />
677 </svg>
678 </button>
679 </form>
680 {refChoices.length > 0 && (
681 <div class="ai-changelog-knownrefs">
682 Known refs:{" "}
683 {refChoices.slice(0, 20).map((r) => (
684 <code>{r}</code>
685 ))}
686 {refChoices.length > 20 ? " …" : ""}
687 </div>
688 )}
3cbe3d6Claude689 </div>
e73e288Claude690
691 {!opts.error && !opts.notice && (
692 <div class="ai-changelog-empty">
693 <div class="ai-changelog-empty-orb" aria-hidden="true" />
694 <h3>Pick a range to start</h3>
695 <p>
696 Two refs (branch / tag / sha) is all Claude needs to write the
697 notes.
698 </p>
699 <div class="ai-changelog-suggests">
700 <div>
701 Try: from <code>v1.0.0</code> to <code>main</code>
702 </div>
703 <div>
704 Or:&nbsp; from <code>HEAD~50</code> to <code>HEAD</code>
705 </div>
706 </div>
707 </div>
708 )}
709
710 <div class="ai-changelog-poweredby">
711 <span class="ai-changelog-poweredby-pill">
712 <span class="dot" aria-hidden="true" />
713 Powered by Claude
714 </span>
3cbe3d6Claude715 </div>
e73e288Claude716 </div>
717 <style dangerouslySetInnerHTML={{ __html: styles }} />
3cbe3d6Claude718 </Layout>
719 );
720
721 // No range supplied — show picker.
722 if (!from || !to) {
723 return renderForm();
724 }
725
726 // Resolve both refs.
727 const [fromSha, toSha] = await Promise.all([
728 resolveRef(owner, repo, from),
729 resolveRef(owner, repo, to),
730 ]);
731 if (!fromSha || !toSha) {
732 const which =
733 !fromSha && !toSha
734 ? `Could not resolve refs "${from}" or "${to}".`
735 : !fromSha
736 ? `Could not resolve "from" ref "${from}".`
737 : `Could not resolve "to" ref "${to}".`;
738 return renderForm({ error: which });
739 }
740
741 // Collect commits in range.
742 let commits: RangeCommit[] = [];
743 try {
744 commits = await commitsInRange(owner, repo, from, to);
745 } catch (err) {
746 return renderForm({
747 error: `Failed to read commit range: ${String(
748 (err as Error).message || err
749 )}`,
750 });
751 }
752
753 if (commits.length === 0) {
754 return renderForm({
755 notice: `No commits between ${from} and ${to}.`,
756 });
757 }
758
759 // Hand off to Claude (or the deterministic fallback).
760 let markdown = "";
761 try {
762 markdown = await generateChangelog(
763 `${owner}/${repo}`,
764 from,
765 to,
766 commits
767 );
768 } catch (err) {
769 // generateChangelog has its own no-key fallback, but network/SDK
770 // failures should still return a useful page rather than a 500.
771 markdown =
772 `## ${to} (since ${from})\n\n` +
773 commits
774 .map(
775 (c2) =>
776 `- ${c2.message.split("\n")[0]} (${c2.sha.slice(0, 7)}) — ${
777 c2.author
778 }`
779 )
780 .join("\n") +
781 `\n\n_AI generation failed: ${String(
782 (err as Error).message || err
783 )}_`;
784 }
785
786 // CLI / CI consumers want raw Markdown.
787 if (format === "markdown") {
788 return c.text(markdown, 200, { "Content-Type": "text/markdown" });
789 }
790
e73e288Claude791 const htmlBody = renderMarkdown(markdown);
3cbe3d6Claude792
793 return c.html(
794 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
795 <RepoHeader owner={owner} repo={repo} />
796 <IssueNav owner={owner} repo={repo} active="code" />
e73e288Claude797 <div class="ai-changelog-wrap">
798 <ChangelogHero />
799
800 <div class="ai-changelog-rangeline">
801 <code>{from}</code>
802 <span class="arrow">..</span>
803 <code>{to}</code>
804 <span class="count">
805 {commits.length} commit{commits.length !== 1 ? "s" : ""}
806 </span>
807 </div>
808
809 <div class="ai-changelog-form-card">
810 <form
811 method="get"
812 action={`/${owner}/${repo}/ai/changelog`}
813 class="ai-changelog-form-row"
3cbe3d6Claude814 >
e73e288Claude815 <div class="ai-changelog-field">
816 <label class="ai-changelog-field-label" for="ai-changelog-from-2">
817 From
818 </label>
819 <input
820 id="ai-changelog-from-2"
821 type="text"
822 name="from"
823 list="ai-changelog-refs"
824 value={from}
825 aria-label="From ref"
826 class="ai-changelog-input"
827 />
828 </div>
829 <div class="ai-changelog-field">
830 <label class="ai-changelog-field-label" for="ai-changelog-to-2">
831 To
832 </label>
833 <input
834 id="ai-changelog-to-2"
835 type="text"
836 name="to"
837 list="ai-changelog-refs"
838 value={to}
839 aria-label="To ref"
840 class="ai-changelog-input"
841 />
842 </div>
843 <datalist id="ai-changelog-refs">
844 {refChoices.map((r) => (
845 <option value={r}></option>
846 ))}
847 </datalist>
848 <button type="submit" class="ai-changelog-submit">
849 Regenerate
850 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
851 <polyline points="23 4 23 10 17 10" />
852 <polyline points="1 20 1 14 7 14" />
853 <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
854 </svg>
855 </button>
856 <a
857 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
858 from
859 )}&to=${encodeURIComponent(to)}&format=markdown`}
860 class="ai-changelog-ghost"
861 >
862 Raw Markdown
863 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
864 <path d="M15 3h6v6" />
865 <path d="M10 14L21 3" />
866 <path d="M19 14v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h6" />
867 </svg>
868 </a>
869 </form>
870 </div>
871
872 <div class="ai-changelog-results">
873 <section
874 class="ai-changelog-panel"
875 aria-labelledby="ai-changelog-rendered-title"
876 >
877 <header class="ai-changelog-panel-head">
878 <p
879 class="ai-changelog-panel-title"
880 id="ai-changelog-rendered-title"
881 >
882 <span class="ai-changelog-panel-dot" aria-hidden="true" />
883 Release notes · {from} → {to}
884 </p>
885 </header>
886 <div class="ai-changelog-panel-body">
887 <div
888 class="markdown-body"
889 dangerouslySetInnerHTML={{ __html: htmlBody }}
890 ></div>
891 </div>
892 </section>
893
894 <section
895 class="ai-changelog-panel"
896 aria-labelledby="ai-changelog-raw-title"
3cbe3d6Claude897 >
e73e288Claude898 <header class="ai-changelog-panel-head">
899 <p
900 class="ai-changelog-panel-title"
901 id="ai-changelog-raw-title"
902 >
903 <span class="ai-changelog-panel-dot" aria-hidden="true" />
904 Copy Markdown
905 </p>
906 <button
907 type="button"
908 class="ai-changelog-copy"
909 data-ai-changelog-copy
910 aria-label="Copy markdown to clipboard"
911 >
912 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
913 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
914 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
915 </svg>
916 <span data-ai-changelog-copy-label>Copy</span>
917 </button>
918 </header>
919 <textarea
920 readonly
921 rows={24}
922 class="ai-changelog-raw"
923 data-ai-changelog-raw
924 onclick="this.select()"
925 >
926 {markdown}
927 </textarea>
928 </section>
929 </div>
930
931 <div class="ai-changelog-poweredby">
932 <span class="ai-changelog-poweredby-pill">
933 <span class="dot" aria-hidden="true" />
934 Powered by Claude
935 </span>
3cbe3d6Claude936 </div>
937 </div>
e73e288Claude938 <style dangerouslySetInnerHTML={{ __html: styles }} />
939 <script dangerouslySetInnerHTML={{ __html: COPY_SCRIPT }} />
3cbe3d6Claude940 </Layout>
941 );
942});
943
944export default aiChangelog;