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

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.tsxBlame310 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`.
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"
138 style="padding: 6px 10px"
139 />
140 <label style="font-size: 13px; color: var(--text-muted)">To</label>
141 <input
142 type="text"
143 name="to"
144 list="ai-changelog-refs"
145 value={to}
146 placeholder="main"
147 style="padding: 6px 10px"
148 />
149 <datalist id="ai-changelog-refs">
150 {refChoices.map((r) => (
151 <option value={r}></option>
152 ))}
153 </datalist>
154 <button type="submit" class="btn btn-primary">
155 Generate
156 </button>
157 </form>
158 {refChoices.length > 0 && (
159 <div style="font-size: 12px; color: var(--text-muted)">
160 Known refs: {refChoices.slice(0, 20).join(", ")}
161 {refChoices.length > 20 ? ", …" : ""}
162 </div>
163 )}
164 </Layout>
165 );
166
167 // No range supplied — show picker.
168 if (!from || !to) {
169 return renderForm();
170 }
171
172 // Resolve both refs.
173 const [fromSha, toSha] = await Promise.all([
174 resolveRef(owner, repo, from),
175 resolveRef(owner, repo, to),
176 ]);
177 if (!fromSha || !toSha) {
178 const which =
179 !fromSha && !toSha
180 ? `Could not resolve refs "${from}" or "${to}".`
181 : !fromSha
182 ? `Could not resolve "from" ref "${from}".`
183 : `Could not resolve "to" ref "${to}".`;
184 return renderForm({ error: which });
185 }
186
187 // Collect commits in range.
188 let commits: RangeCommit[] = [];
189 try {
190 commits = await commitsInRange(owner, repo, from, to);
191 } catch (err) {
192 return renderForm({
193 error: `Failed to read commit range: ${String(
194 (err as Error).message || err
195 )}`,
196 });
197 }
198
199 if (commits.length === 0) {
200 return renderForm({
201 notice: `No commits between ${from} and ${to}.`,
202 });
203 }
204
205 // Hand off to Claude (or the deterministic fallback).
206 let markdown = "";
207 try {
208 markdown = await generateChangelog(
209 `${owner}/${repo}`,
210 from,
211 to,
212 commits
213 );
214 } catch (err) {
215 // generateChangelog has its own no-key fallback, but network/SDK
216 // failures should still return a useful page rather than a 500.
217 markdown =
218 `## ${to} (since ${from})\n\n` +
219 commits
220 .map(
221 (c2) =>
222 `- ${c2.message.split("\n")[0]} (${c2.sha.slice(0, 7)}) — ${
223 c2.author
224 }`
225 )
226 .join("\n") +
227 `\n\n_AI generation failed: ${String(
228 (err as Error).message || err
229 )}_`;
230 }
231
232 // CLI / CI consumers want raw Markdown.
233 if (format === "markdown") {
234 return c.text(markdown, 200, { "Content-Type": "text/markdown" });
235 }
236
237 const html = renderMarkdown(markdown);
238
239 return c.html(
240 <Layout title={`AI Changelog — ${owner}/${repo}`} user={user}>
241 <RepoHeader owner={owner} repo={repo} />
242 <IssueNav owner={owner} repo={repo} active="code" />
243 <h2 style="margin-bottom: 4px">AI Changelog</h2>
244 <div style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
245 {from} <span style="opacity: 0.6">..</span> {to} —{" "}
246 {commits.length} commit{commits.length !== 1 ? "s" : ""}
247 </div>
248 <form
b9968e3Claude249 method="get"
3cbe3d6Claude250 action={`/${owner}/${repo}/ai/changelog`}
251 style="display: flex; gap: 8px; align-items: center; margin-bottom: 20px; flex-wrap: wrap"
252 >
253 <input
254 type="text"
255 name="from"
256 list="ai-changelog-refs"
257 value={from}
258 style="padding: 6px 10px"
259 />
260 <span style="color: var(--text-muted)">..</span>
261 <input
262 type="text"
263 name="to"
264 list="ai-changelog-refs"
265 value={to}
266 style="padding: 6px 10px"
267 />
268 <datalist id="ai-changelog-refs">
269 {refChoices.map((r) => (
270 <option value={r}></option>
271 ))}
272 </datalist>
273 <button type="submit" class="btn">
274 Regenerate
275 </button>
276 <a
277 href={`/${owner}/${repo}/ai/changelog?from=${encodeURIComponent(
278 from
279 )}&to=${encodeURIComponent(to)}&format=markdown`}
280 class="btn"
281 >
282 Raw Markdown
283 </a>
284 </form>
285 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; align-items: start">
286 <div
287 class="markdown-body"
288 dangerouslySetInnerHTML={{ __html: html }}
289 ></div>
290 <div>
291 <div
292 style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px"
293 >
294 Copy Markdown
295 </div>
296 <textarea
297 readonly
298 rows={24}
299 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"
300 onclick="this.select()"
301 >
302 {markdown}
303 </textarea>
304 </div>
305 </div>
306 </Layout>
307 );
308});
309
310export default aiChangelog;