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.tsxBlame314 lines · 2 contributors
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`.
13 */
14
15import { Hono } from "hono";
16import { Layout } from "../views/layout";
17import { RepoHeader } from "../views/components";
18import { IssueNav } from "./issues";
19import {
20 listBranches,
21 listTags,
22 resolveRef,
23 repoExists,
24 getRepoPath,
25} from "../git/repository";
26import { generateChangelog } from "../lib/ai-generators";
27import { renderMarkdown } from "../lib/markdown";
28import { softAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30
31const aiChangelog = new Hono<AuthEnv>();
32
33aiChangelog.use("*", softAuth);
34
35interface RangeCommit {
36 sha: string;
37 message: string;
38 author: string;
39 date: string;
40}
41
42async function commitsInRange(
43 owner: string,
44 repo: string,
45 from: string,
46 to: string
47): Promise<RangeCommit[]> {
48 const repoDir = getRepoPath(owner, repo);
49 const proc = Bun.spawn(
50 [
51 "git",
52 "log",
53 "--format=%H%x00%s%x00%an%x00%aI",
54 `${from}..${to}`,
55 ],
56 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
57 );
58 const out = await new Response(proc.stdout).text();
59 await proc.exited;
60 return out
61 .trim()
62 .split("\n")
63 .filter(Boolean)
64 .slice(0, 500)
65 .map((line) => {
66 const [sha, message, author, date] = line.split("\0");
67 return { sha, message, author, date };
68 });
69}
70
71aiChangelog.get("/:owner/:repo/ai/changelog", async (c) => {
72 const { owner, repo } = c.req.param();
73 const user = c.get("user");
74 const from = (c.req.query("from") || "").trim();
75 const to = (c.req.query("to") || "").trim();
76 const format = (c.req.query("format") || "").trim().toLowerCase();
77
78 if (!(await repoExists(owner, repo))) {
79 return c.html(
80 <Layout title="Not Found" user={user}>
81 <div class="empty-state">
82 <h2>Repository not found</h2>
83 </div>
84 </Layout>,
85 404
86 );
87 }
88
89 const [branches, tags] = await Promise.all([
90 listBranches(owner, repo).catch(() => [] as string[]),
91 listTags(owner, repo).catch(
92 () => [] as Array<{ name: string; sha: string; date: string }>
93 ),
94 ]);
95 const refChoices = [
96 ...branches,
97 ...tags.slice(0, 25).map((t) => t.name),
98 ];
99
100 const renderForm = (opts: { error?: string; notice?: string } = {}) =>
101 c.html(
102 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
103 <RepoHeader owner={owner} repo={repo} />
104 <IssueNav owner={owner} repo={repo} active="code" />
105 <h2 style="margin-bottom: 8px">AI Changelog</h2>
106 <p style="color: var(--text-muted); margin-bottom: 20px; font-size: 14px">
107 Generate release notes for any commit range. Pick a base (from) and
108 a head (to) — Claude will group commits into Features / Fixes /
109 Perf / Refactors / Docs / Other.
110 </p>
111 {opts.error && (
112 <div class="auth-error" style="margin-bottom: 16px">
113 {opts.error}
114 </div>
115 )}
116 {opts.notice && (
117 <div
118 class="empty-state"
119 style="margin-bottom: 16px; padding: 12px; text-align: left"
120 >
121 {opts.notice}
122 </div>
123 )}
124 <form
b9968e3Claude125 method="get"
3cbe3d6Claude126 action={`/${owner}/${repo}/ai/changelog`}
127 style="display: flex; gap: 12px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
128 >
129 <label style="font-size: 13px; color: var(--text-muted)">
130 From
131 </label>
132 <input
133 type="text"
134 name="from"
135 list="ai-changelog-refs"
136 value={from}
137 placeholder="v1.0.0"
2c3ba6ecopilot-swe-agent[bot]138 aria-label="From ref"
3cbe3d6Claude139 style="padding: 6px 10px"
140 />
141 <label style="font-size: 13px; color: var(--text-muted)">To</label>
142 <input
143 type="text"
144 name="to"
145 list="ai-changelog-refs"
146 value={to}
147 placeholder="main"
2c3ba6ecopilot-swe-agent[bot]148 aria-label="To ref"
3cbe3d6Claude149 style="padding: 6px 10px"
150 />
151 <datalist id="ai-changelog-refs">
152 {refChoices.map((r) => (
153 <option value={r}></option>
154 ))}
155 </datalist>
156 <button type="submit" class="btn btn-primary">
157 Generate
158 </button>
159 </form>
160 {refChoices.length > 0 && (
161 <div style="font-size: 12px; color: var(--text-muted)">
162 Known refs: {refChoices.slice(0, 20).join(", ")}
163 {refChoices.length > 20 ? ", …" : ""}
164 </div>
165 )}
166 </Layout>
167 );
168
169 // No range supplied — show picker.
170 if (!from || !to) {
171 return renderForm();
172 }
173
174 // Resolve both refs.
175 const [fromSha, toSha] = await Promise.all([
176 resolveRef(owner, repo, from),
177 resolveRef(owner, repo, to),
178 ]);
179 if (!fromSha || !toSha) {
180 const which =
181 !fromSha && !toSha
182 ? `Could not resolve refs "${from}" or "${to}".`
183 : !fromSha
184 ? `Could not resolve "from" ref "${from}".`
185 : `Could not resolve "to" ref "${to}".`;
186 return renderForm({ error: which });
187 }
188
189 // Collect commits in range.
190 let commits: RangeCommit[] = [];
191 try {
192 commits = await commitsInRange(owner, repo, from, to);
193 } catch (err) {
194 return renderForm({
195 error: `Failed to read commit range: ${String(
196 (err as Error).message || err
197 )}`,
198 });
199 }
200
201 if (commits.length === 0) {
202 return renderForm({
203 notice: `No commits between ${from} and ${to}.`,
204 });
205 }
206
207 // Hand off to Claude (or the deterministic fallback).
208 let markdown = "";
209 try {
210 markdown = await generateChangelog(
211 `${owner}/${repo}`,
212 from,
213 to,
214 commits
215 );
216 } catch (err) {
217 // generateChangelog has its own no-key fallback, but network/SDK
218 // failures should still return a useful page rather than a 500.
219 markdown =
220 `## ${to} (since ${from})\n\n` +
221 commits
222 .map(
223 (c2) =>
224 `- ${c2.message.split("\n")[0]} (${c2.sha.slice(0, 7)}) — ${
225 c2.author
226 }`
227 )
228 .join("\n") +
229 `\n\n_AI generation failed: ${String(
230 (err as Error).message || err
231 )}_`;
232 }
233
234 // CLI / CI consumers want raw Markdown.
235 if (format === "markdown") {
236 return c.text(markdown, 200, { "Content-Type": "text/markdown" });
237 }
238
239 const html = renderMarkdown(markdown);
240
241 return c.html(
242 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
243 <RepoHeader owner={owner} repo={repo} />
244 <IssueNav owner={owner} repo={repo} active="code" />
245 <h2 style="margin-bottom: 4px">AI Changelog</h2>
246 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
247 {from} <span style="opacity: 0.6">..</span> {to} —{" "}
248 {commits.length} commit{commits.length !== 1 ? "s" : ""}
249 </div>
250 <form
b9968e3Claude251 method="get"
3cbe3d6Claude252 action={`/${owner}/${repo}/ai/changelog`}
253 style="display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
254 >
255 <input
256 type="text"
257 name="from"
258 list="ai-changelog-refs"
259 value={from}
2c3ba6ecopilot-swe-agent[bot]260 aria-label="From ref"
3cbe3d6Claude261 style="padding: 6px 10px"
262 />
263 <span style="color: var(--text-muted)">..</span>
264 <input
265 type="text"
266 name="to"
267 list="ai-changelog-refs"
268 value={to}
2c3ba6ecopilot-swe-agent[bot]269 aria-label="To ref"
3cbe3d6Claude270 style="padding: 6px 10px"
271 />
272 <datalist id="ai-changelog-refs">
273 {refChoices.map((r) => (
274 <option value={r}></option>
275 ))}
276 </datalist>
277 <button type="submit" class="btn">
278 Regenerate
279 </button>
280 <a
281 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
282 from
283 )}&to=${encodeURIComponent(to)}&format=markdown`}
284 class="btn"
285 >
286 Raw Markdown
287 </a>
288 </form>
289 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start">
290 <div
291 class="markdown-body"
292 dangerouslySetInnerHTML={{ __html: html }}
293 ></div>
294 <div>
295 <div
296 style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px"
297 >
298 Copy Markdown
299 </div>
300 <textarea
301 readonly
302 rows={24}
303 style="width: 100%; font-family: var(--font-mono, monospace); font-size: 12px; padding: 10px; background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); border-radius: 6px"
304 onclick="this.select()"
305 >
306 {markdown}
307 </textarea>
308 </div>
309 </div>
310 </Layout>
311 );
312});
313
314export default aiChangelog;