Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit424eb72unknown_key

feat(release-notes): Claude reads merged PRs since last tag → polished release notes

Claude committed on May 25, 2026Parent: 15db0e0
5 files changed+14181424eb72c84d98ac5a126701f6dc648177b9dd137
5 changed files+1418−1
Addedsrc/__tests__/ai-release-notes.test.ts+519−0View fileUnifiedSplit
1/**
2 * Tests for src/lib/ai-release-notes.ts.
3 *
4 * Two layers:
5 *
6 * 1. Pure helpers — bucketing, prompt assembly, markdown rendering.
7 * No DB / no Claude. Always run.
8 *
9 * 2. End-to-end through `generateReleaseNotes` with a fake Claude
10 * client + a real bare repo on disk. DB-touching assertions are
11 * gated on DATABASE_URL via the HAS_DB skipIf pattern used across
12 * the rest of the suite.
13 */
14
15import { describe, it, expect, beforeAll, afterAll } from "bun:test";
16import { join } from "path";
17import { rm, mkdir } from "fs/promises";
18import {
19 bucketPrs,
20 buildReleaseNotesPrompt,
21 classifyPr,
22 generateReleaseNotes,
23 isSemverTag,
24 mergeClaudeSections,
25 prNumberFromCommitMessage,
26 renderPrBullet,
27 renderSectionsToMarkdown,
28 resolvePrsForCommits,
29 type ResolvedPullRequest,
30 type ReleaseSections,
31} from "../lib/ai-release-notes";
32import {
33 initBareRepo,
34 createOrUpdateFileOnBranch,
35 createTag,
36 resolveRef,
37} from "../git/repository";
38import { db } from "../db";
39import { eq } from "drizzle-orm";
40import {
41 pullRequests,
42 repositories,
43 users,
44} from "../db/schema";
45
46const HAS_DB = Boolean(process.env.DATABASE_URL);
47
48const TEST_REPOS = join(
49 import.meta.dir,
50 "../../.test-repos-ai-release-notes-" + Date.now()
51);
52
53beforeAll(async () => {
54 process.env.GIT_REPOS_PATH = TEST_REPOS;
55 await rm(TEST_REPOS, { recursive: true, force: true });
56 await mkdir(TEST_REPOS, { recursive: true });
57});
58
59afterAll(async () => {
60 await rm(TEST_REPOS, { recursive: true, force: true });
61});
62
63// ---------------------------------------------------------------------------
64// Pure helpers
65// ---------------------------------------------------------------------------
66
67function pr(overrides: Partial<ResolvedPullRequest>): ResolvedPullRequest {
68 return {
69 number: 1,
70 title: "feat: thing",
71 body: null,
72 authorUsername: "alice",
73 headBranch: "feature/x",
74 mergedAt: new Date("2026-01-01T00:00:00Z"),
75 labels: [],
76 autoMergedByAi: false,
77 ...overrides,
78 };
79}
80
81describe("classifyPr", () => {
82 it("routes auto-merged PRs to ai_changes", () => {
83 expect(classifyPr(pr({ autoMergedByAi: true }))).toBe("ai_changes");
84 });
85
86 it("routes by label first", () => {
87 expect(classifyPr(pr({ title: "anything", labels: ["security"] }))).toBe(
88 "security"
89 );
90 expect(classifyPr(pr({ title: "anything", labels: ["bug"] }))).toBe(
91 "fixes"
92 );
93 expect(classifyPr(pr({ title: "anything", labels: ["ai:feature"] }))).toBe(
94 "features"
95 );
96 expect(classifyPr(pr({ title: "anything", labels: ["ai:auto-merge"] }))).toBe(
97 "ai_changes"
98 );
99 });
100
101 it("falls back to conventional-commit prefix on the title", () => {
102 expect(classifyPr(pr({ title: "feat: new x" }))).toBe("features");
103 expect(classifyPr(pr({ title: "feat(api): scoped" }))).toBe("features");
104 expect(classifyPr(pr({ title: "fix: bug" }))).toBe("fixes");
105 expect(classifyPr(pr({ title: "perf: faster" }))).toBe("perf");
106 expect(classifyPr(pr({ title: "docs: readme" }))).toBe("docs");
107 expect(classifyPr(pr({ title: "security: sanitise" }))).toBe("security");
108 });
109
110 it("dumps anything unclassified into 'other'", () => {
111 expect(classifyPr(pr({ title: "tidy up" }))).toBe("other");
112 expect(classifyPr(pr({ title: "refactor: split file" }))).toBe("other");
113 });
114});
115
116describe("renderPrBullet", () => {
117 it("strips the conventional-commit prefix and adds attribution", () => {
118 const out = renderPrBullet(pr({ number: 42, title: "feat(api): scoped thing", authorUsername: "bob" }));
119 expect(out).toBe("scoped thing (#42) — @bob");
120 });
121});
122
123describe("bucketPrs", () => {
124 it("groups PRs into the right sections", () => {
125 const sections = bucketPrs([
126 pr({ number: 1, title: "feat: A" }),
127 pr({ number: 2, title: "fix: B" }),
128 pr({ number: 3, title: "perf: C" }),
129 pr({ number: 4, title: "anything", autoMergedByAi: true }),
130 pr({ number: 5, title: "refactor: D" }),
131 ]);
132 expect(sections.features.bullets.length).toBe(1);
133 expect(sections.fixes.bullets.length).toBe(1);
134 expect(sections.perf.bullets.length).toBe(1);
135 expect(sections.ai_changes.bullets.length).toBe(1);
136 expect(sections.other.bullets.length).toBe(1);
137 expect(sections.security.bullets.length).toBe(0);
138 expect(sections.docs.bullets.length).toBe(0);
139 });
140});
141
142describe("prNumberFromCommitMessage", () => {
143 it("recognises GitHub-style merge commits", () => {
144 expect(prNumberFromCommitMessage("Merge pull request #42 from foo/bar")).toBe(42);
145 });
146 it("recognises squash subjects", () => {
147 expect(prNumberFromCommitMessage("feat: stuff (#7)")).toBe(7);
148 });
149 it("returns null on missing", () => {
150 expect(prNumberFromCommitMessage("plain commit")).toBeNull();
151 });
152});
153
154describe("buildReleaseNotesPrompt", () => {
155 it("embeds repo, range, grouped sections, and raw PRs", () => {
156 const sections: ReleaseSections = {
157 features: { bullets: ["A (#1) — @alice"] },
158 fixes: { bullets: [] },
159 perf: { bullets: [] },
160 docs: { bullets: [] },
161 security: { bullets: [] },
162 ai_changes: { bullets: [] },
163 other: { bullets: [] },
164 };
165 const prompt = buildReleaseNotesPrompt({
166 repoFullName: "alice/demo",
167 fromTag: "v1.0.0",
168 toTag: "v1.1.0",
169 sections,
170 prs: [pr({ number: 1, title: "feat: A" })],
171 });
172 expect(prompt).toContain("alice/demo");
173 expect(prompt).toContain("v1.0.0");
174 expect(prompt).toContain("v1.1.0");
175 expect(prompt).toContain("features:");
176 expect(prompt).toContain("A (#1) — @alice");
177 expect(prompt).toContain('"sections"');
178 expect(prompt).toContain('"headline"');
179 });
180
181 it("notes the initial release case when fromTag is null", () => {
182 const prompt = buildReleaseNotesPrompt({
183 repoFullName: "alice/demo",
184 fromTag: null,
185 toTag: "v0.1.0",
186 sections: {
187 features: { bullets: [] },
188 fixes: { bullets: [] },
189 perf: { bullets: [] },
190 docs: { bullets: [] },
191 security: { bullets: [] },
192 ai_changes: { bullets: [] },
193 other: { bullets: [] },
194 },
195 prs: [],
196 });
197 expect(prompt).toContain("(initial)");
198 });
199});
200
201describe("mergeClaudeSections", () => {
202 const deterministic: ReleaseSections = {
203 features: { bullets: ["thing (#1) — @alice"] },
204 fixes: { bullets: ["bug (#2) — @bob"] },
205 perf: { bullets: [] },
206 docs: { bullets: [] },
207 security: { bullets: [] },
208 ai_changes: { bullets: [] },
209 other: { bullets: [] },
210 };
211
212 it("returns deterministic when Claude is null", () => {
213 expect(mergeClaudeSections(deterministic, null)).toEqual(deterministic);
214 });
215
216 it("prefers Claude wording when it covers all bullets", () => {
217 const merged = mergeClaudeSections(deterministic, {
218 sections: {
219 features: { bullets: ["Add the thing (#1) — @alice"] },
220 },
221 });
222 expect(merged.features.bullets).toEqual(["Add the thing (#1) — @alice"]);
223 // Untouched bucket stays deterministic.
224 expect(merged.fixes.bullets).toEqual(["bug (#2) — @bob"]);
225 });
226
227 it("keeps deterministic when Claude drops bullets", () => {
228 const merged = mergeClaudeSections(deterministic, {
229 sections: {
230 // Claude returned fewer items than we know about — distrust.
231 fixes: { bullets: [] },
232 },
233 });
234 expect(merged.fixes.bullets).toEqual(["bug (#2) — @bob"]);
235 });
236});
237
238describe("renderSectionsToMarkdown", () => {
239 it("emits a header, headline, summary, and ordered sections", () => {
240 const md = renderSectionsToMarkdown({
241 repoFullName: "alice/demo",
242 fromTag: "v1.0.0",
243 toTag: "v1.1.0",
244 headline: "Stability + speed.",
245 summary: "This release tightens the merge queue.",
246 sections: {
247 features: { bullets: ["A (#1) — @alice"] },
248 fixes: { bullets: ["B (#2) — @bob"] },
249 perf: { bullets: [] },
250 docs: { bullets: [] },
251 security: { bullets: ["sanitise (#3) — @carol"] },
252 ai_changes: { bullets: [] },
253 other: { bullets: [] },
254 },
255 });
256 expect(md).toContain("## v1.1.0 (since v1.0.0)");
257 expect(md).toContain("**Stability + speed.**");
258 expect(md).toContain("This release tightens the merge queue.");
259 // Features comes before fixes, which comes before security.
260 const feat = md.indexOf("### Features");
261 const fixIdx = md.indexOf("### Bug fixes");
262 const sec = md.indexOf("### Security");
263 expect(feat).toBeGreaterThan(0);
264 expect(fixIdx).toBeGreaterThan(feat);
265 expect(sec).toBeGreaterThan(fixIdx);
266 expect(md).toContain("- A (#1) — @alice");
267 expect(md).toContain("- B (#2) — @bob");
268 // Empty sections are skipped.
269 expect(md).not.toContain("### Performance");
270 expect(md).not.toContain("### Documentation");
271 });
272
273 it("skips the headline + summary lines when both are empty", () => {
274 const md = renderSectionsToMarkdown({
275 repoFullName: "alice/demo",
276 fromTag: null,
277 toTag: "v0.1.0",
278 headline: "",
279 summary: "",
280 sections: {
281 features: { bullets: [] },
282 fixes: { bullets: [] },
283 perf: { bullets: [] },
284 docs: { bullets: [] },
285 security: { bullets: [] },
286 ai_changes: { bullets: [] },
287 other: { bullets: [] },
288 },
289 });
290 expect(md).toContain("## v0.1.0");
291 expect(md).not.toContain("**");
292 expect(md).toContain("(initial)...v0.1.0");
293 });
294});
295
296describe("isSemverTag", () => {
297 it("accepts vX.Y.Z and X.Y.Z", () => {
298 expect(isSemverTag("v1.2.3")).toBe(true);
299 expect(isSemverTag("1.2.3")).toBe(true);
300 expect(isSemverTag("v0.1.0-beta.1")).toBe(true);
301 });
302 it("rejects garbage", () => {
303 expect(isSemverTag("release-2024-01")).toBe(false);
304 expect(isSemverTag("v1")).toBe(false);
305 expect(isSemverTag("nightly")).toBe(false);
306 });
307});
308
309// ---------------------------------------------------------------------------
310// End-to-end with fake Claude + real bare repo. DB-backed.
311// ---------------------------------------------------------------------------
312
313/**
314 * Fake Anthropic client returning a canned JSON envelope. Matches the
315 * shape ai-release-notes's parser expects.
316 */
317function fakeClient(responseText: string) {
318 return {
319 messages: {
320 create: async () => ({
321 content: [{ type: "text" as const, text: responseText }],
322 }),
323 },
324 } as any;
325}
326
327describe.skipIf(!HAS_DB)("generateReleaseNotes — end-to-end with fake Claude", () => {
328 const OWNER = `relnotes_${Date.now().toString(36)}`;
329 const REPO = "subject";
330 let repositoryId = "";
331 let userId = "";
332 let toSha = "";
333
334 beforeAll(async () => {
335 // Seed user + repo + bare git repo on disk with two commits and two tags.
336 const [u] = await db
337 .insert(users)
338 .values({
339 username: OWNER,
340 email: `${OWNER}@example.com`,
341 passwordHash: "x",
342 })
343 .returning({ id: users.id });
344 userId = u.id;
345
346 const [r] = await db
347 .insert(repositories)
348 .values({
349 ownerId: userId,
350 name: REPO,
351 diskPath: `/tmp/${OWNER}/${REPO}`,
352 defaultBranch: "main",
353 })
354 .returning({ id: repositories.id });
355 repositoryId = r.id;
356
357 await initBareRepo(OWNER, REPO);
358
359 const seed = await createOrUpdateFileOnBranch({
360 owner: OWNER,
361 name: REPO,
362 branch: "main",
363 filePath: "README.md",
364 bytes: new TextEncoder().encode("# v1\n"),
365 message: "initial commit",
366 authorName: "Seeder",
367 authorEmail: "seed@example.com",
368 });
369 if ("error" in seed) throw new Error(`seed failed: ${seed.error}`);
370 await createTag(OWNER, REPO, "v1.0.0", seed.commitSha, "v1.0.0");
371
372 // Commit referencing PR #1 — landed as a squash subject.
373 const c1 = await createOrUpdateFileOnBranch({
374 owner: OWNER,
375 name: REPO,
376 branch: "main",
377 filePath: "src/feature.ts",
378 bytes: new TextEncoder().encode("export const f = 1;\n"),
379 message: "feat: add feature (#1)",
380 authorName: "Alice",
381 authorEmail: "alice@example.com",
382 });
383 if ("error" in c1) throw new Error(`c1 failed: ${c1.error}`);
384
385 // Commit referencing PR #2 — a fix.
386 const c2 = await createOrUpdateFileOnBranch({
387 owner: OWNER,
388 name: REPO,
389 branch: "main",
390 filePath: "src/fix.ts",
391 bytes: new TextEncoder().encode("export const fixed = true;\n"),
392 message: "fix: handle null case (#2)",
393 authorName: "Bob",
394 authorEmail: "bob@example.com",
395 });
396 if ("error" in c2) throw new Error(`c2 failed: ${c2.error}`);
397
398 await createTag(OWNER, REPO, "v1.1.0", c2.commitSha, "v1.1.0");
399 toSha = c2.commitSha;
400
401 // Insert two merged PR rows so cross-referencing succeeds.
402 await db.insert(pullRequests).values({
403 repositoryId,
404 authorId: userId,
405 title: "feat: add feature",
406 body: "Adds the feature.",
407 state: "merged",
408 baseBranch: "main",
409 headBranch: "feature/x",
410 mergedAt: new Date(),
411 number: 1,
412 });
413 await db.insert(pullRequests).values({
414 repositoryId,
415 authorId: userId,
416 title: "fix: handle null case",
417 body: "Crash fix.",
418 state: "merged",
419 baseBranch: "main",
420 headBranch: "fix/y",
421 mergedAt: new Date(),
422 number: 2,
423 });
424 });
425
426 afterAll(async () => {
427 // Cleanup — cascades handle PRs / releases.
428 if (repositoryId) {
429 await db.delete(repositories).where(eq(repositories.id, repositoryId));
430 }
431 if (userId) {
432 await db.delete(users).where(eq(users.id, userId));
433 }
434 });
435
436 it("cross-references PRs from the commit range", async () => {
437 const fromSha = await resolveRef(OWNER, REPO, "v1.0.0");
438 expect(fromSha).toBeTruthy();
439 expect(toSha).toBeTruthy();
440 const { commitsBetween } = await import("../git/repository");
441 const commits = await commitsBetween(OWNER, REPO, fromSha, toSha);
442 expect(commits.length).toBe(2);
443
444 const prs = await resolvePrsForCommits(repositoryId, commits);
445 expect(prs.length).toBe(2);
446 const nums = prs.map((p) => p.number).sort();
447 expect(nums).toEqual([1, 2]);
448 });
449
450 it("renders structured output into markdown with section grouping", async () => {
451 const claudeOut = JSON.stringify({
452 headline: "Feature + fix.",
453 summary: "Adds the new feature and patches the null crash.",
454 sections: {
455 features: { bullets: ["Add feature (#1) — @" + OWNER] },
456 fixes: { bullets: ["Handle null (#2) — @" + OWNER] },
457 },
458 });
459
460 const result = await generateReleaseNotes({
461 repositoryId,
462 fromTag: "v1.0.0",
463 toTag: "v1.1.0",
464 client: fakeClient(claudeOut),
465 });
466
467 expect(result.aiUsed).toBe(true);
468 expect(result.prCount).toBe(2);
469 expect(result.headline).toBe("Feature + fix.");
470 expect(result.summary).toContain("Adds the new feature");
471 expect(result.sections.features.bullets.length).toBe(1);
472 expect(result.sections.fixes.bullets.length).toBe(1);
473 expect(result.markdown).toContain("## v1.1.0 (since v1.0.0)");
474 expect(result.markdown).toContain("**Feature + fix.**");
475 expect(result.markdown).toContain("### Features");
476 expect(result.markdown).toContain("### Bug fixes");
477 expect(result.markdown).toContain("Add feature (#1)");
478 });
479
480 it("falls back to deterministic markdown when Claude returns garbage", async () => {
481 const result = await generateReleaseNotes({
482 repositoryId,
483 fromTag: "v1.0.0",
484 toTag: "v1.1.0",
485 client: fakeClient("not json at all"),
486 });
487 expect(result.aiUsed).toBe(false);
488 // Even without Claude, sections must be populated from the PR cross-ref.
489 expect(result.sections.features.bullets.length).toBeGreaterThan(0);
490 expect(result.sections.fixes.bullets.length).toBeGreaterThan(0);
491 expect(result.markdown).toContain("### Features");
492 expect(result.markdown).toContain("### Bug fixes");
493 });
494
495 it("returns a non-empty markdown even when no PRs match", async () => {
496 // Generate notes for the initial-tag → initial-tag range (no commits).
497 const result = await generateReleaseNotes({
498 repositoryId,
499 fromTag: "v1.0.0",
500 toTag: "v1.0.0",
501 client: fakeClient("{}"),
502 });
503 expect(result.markdown).toContain("v1.0.0");
504 expect(result.prCount).toBe(0);
505 });
506});
507
508describe("generateReleaseNotes — repo not found", () => {
509 it("returns a graceful fallback for an unknown repository id", async () => {
510 const result = await generateReleaseNotes({
511 repositoryId: "00000000-0000-0000-0000-000000000000",
512 fromTag: null,
513 toTag: "v1.0.0",
514 });
515 expect(result.prCount).toBe(0);
516 expect(result.aiUsed).toBe(false);
517 expect(result.markdown).toContain("v1.0.0");
518 });
519});
Addedsrc/lib/ai-release-notes.ts+754−0View fileUnifiedSplit
1/**
2 * AI-powered release notes generator.
3 *
4 * Pipeline:
5 *
6 * 1. Walk `git log fromTag..toTag` for the repo on disk.
7 * 2. Cross-reference each commit with the `pullRequests` table — when
8 * a commit lands the merge SHA (post-merge), the PR's `mergedAt`
9 * stamps it; if the commit is a merge commit we also look it up
10 * by branch name in the message.
11 * 3. Bucket each PR into one of:
12 * features — `ai:feature`, `feat:` prefix, "feat" in label/title
13 * fixes — `fix:` prefix, "fix" / "bug" in label/title
14 * perf — `perf:` prefix
15 * docs — `docs:` prefix
16 * security — `security:` prefix / `security` label
17 * ai_changes — auto-merged by Claude (label `ai:auto-merge` or
18 * auto-merge audit comment marker)
19 * Anything else falls through to `other`.
20 * 4. Ask Claude for a structured JSON envelope with a polished
21 * summary + grouped bullets and render it to Markdown.
22 *
23 * Returns `{ markdown, sections }`. When `ANTHROPIC_API_KEY` is unset,
24 * falls back to a deterministic Markdown render of the buckets so the
25 * release form still shows something useful. Never throws.
26 *
27 * Wire-in points:
28 *
29 * - `POST /api/v2/repos/:owner/:repo/releases/notes` (REST callers).
30 * - `POST /:owner/:repo/releases/new/preview-notes` (release form
31 * `Generate notes` button — fills the textarea via fetch).
32 * - Autopilot `auto-release-notes` task — watches for newly-pushed
33 * semver tags and back-fills empty release bodies.
34 */
35
36import type Anthropic from "@anthropic-ai/sdk";
37import { and, eq, inArray } from "drizzle-orm";
38import { db } from "../db";
39import { pullRequests, releases, repositories, users } from "../db/schema";
40import {
41 commitsBetween,
42 resolveRef,
43 type GitCommit,
44} from "../git/repository";
45import {
46 getAnthropic,
47 isAiAvailable,
48 MODEL_SONNET,
49 extractText,
50 parseJsonResponse,
51} from "./ai-client";
52import { audit } from "./notify";
53
54// ---------------------------------------------------------------------------
55// Types
56// ---------------------------------------------------------------------------
57
58export type ReleaseSectionKey =
59 | "features"
60 | "fixes"
61 | "perf"
62 | "docs"
63 | "security"
64 | "ai_changes"
65 | "other";
66
67export interface ReleaseSection {
68 /** Plain bullet lines (no leading dash). */
69 bullets: string[];
70}
71
72export interface ReleaseSections {
73 features: ReleaseSection;
74 fixes: ReleaseSection;
75 perf: ReleaseSection;
76 docs: ReleaseSection;
77 security: ReleaseSection;
78 ai_changes: ReleaseSection;
79 other: ReleaseSection;
80}
81
82export interface ReleaseNotesResult {
83 /** Rendered Markdown ready for `releases.body`. */
84 markdown: string;
85 /** Structured grouping used to render `markdown`. */
86 sections: ReleaseSections;
87 /** Headline + 1-3 sentence summary Claude (or the fallback) emitted. */
88 headline: string;
89 summary: string;
90 /** Count of PRs cross-referenced from the commit range. */
91 prCount: number;
92 /** Whether Claude was actually called (false → deterministic fallback). */
93 aiUsed: boolean;
94}
95
96export interface ResolvedPullRequest {
97 number: number;
98 title: string;
99 body: string | null;
100 authorUsername: string;
101 headBranch: string;
102 mergedAt: Date | null;
103 /** Label list — may be empty. Populated from issue_labels via PR id where the join exists. */
104 labels: string[];
105 /** True when the PR was auto-merged by Claude (audit log + comment marker). */
106 autoMergedByAi: boolean;
107}
108
109export interface GenerateReleaseNotesOptions {
110 repositoryId: string;
111 /** Previous tag — pass `null` for "everything reachable from toTag". */
112 fromTag: string | null;
113 /** Target tag. Must resolve to a commit. */
114 toTag: string;
115 /**
116 * Optional Anthropic client override — primarily for tests. When
117 * omitted, production code lazily constructs one via `ai-client`.
118 */
119 client?: Pick<Anthropic, "messages">;
120}
121
122// ---------------------------------------------------------------------------
123// Bucketing
124// ---------------------------------------------------------------------------
125
126const EMPTY_SECTIONS = (): ReleaseSections => ({
127 features: { bullets: [] },
128 fixes: { bullets: [] },
129 perf: { bullets: [] },
130 docs: { bullets: [] },
131 security: { bullets: [] },
132 ai_changes: { bullets: [] },
133 other: { bullets: [] },
134});
135
136const SECTION_TITLES: Record<ReleaseSectionKey, string> = {
137 features: "Features",
138 fixes: "Bug fixes",
139 perf: "Performance",
140 docs: "Documentation",
141 security: "Security",
142 ai_changes: "AI changes",
143 other: "Other",
144};
145
146/**
147 * Decide which bucket a PR belongs to. Order matters — `ai_changes`
148 * wins outright if Claude auto-merged the PR; otherwise we go by label
149 * then conventional-commit prefix in the title.
150 */
151export function classifyPr(pr: ResolvedPullRequest): ReleaseSectionKey {
152 if (pr.autoMergedByAi) return "ai_changes";
153 const labels = pr.labels.map((l) => l.toLowerCase());
154 if (labels.some((l) => l === "ai:auto-merge" || l === "ai:auto")) {
155 return "ai_changes";
156 }
157 if (labels.some((l) => l === "ai:feature" || l === "feature")) {
158 return "features";
159 }
160 if (labels.some((l) => l === "security")) return "security";
161 if (labels.some((l) => l === "bug" || l === "fix")) return "fixes";
162 if (labels.some((l) => l === "performance" || l === "perf")) return "perf";
163 if (labels.some((l) => l === "documentation" || l === "docs")) return "docs";
164
165 const title = pr.title.toLowerCase();
166 // Conventional-commit prefix wins next — match "feat:", "feat(scope):", etc.
167 if (/^feat(\(.+?\))?!?:/.test(title)) return "features";
168 if (/^fix(\(.+?\))?!?:/.test(title)) return "fixes";
169 if (/^perf(\(.+?\))?!?:/.test(title)) return "perf";
170 if (/^docs(\(.+?\))?!?:/.test(title)) return "docs";
171 if (/^security(\(.+?\))?!?:/.test(title)) return "security";
172 // Common in this repo — "feat(scope):" w/o the prefix exact match.
173 if (title.startsWith("feat ")) return "features";
174 if (title.startsWith("fix ")) return "fixes";
175
176 return "other";
177}
178
179/** Render a single PR as a bullet line (no leading dash). */
180export function renderPrBullet(pr: ResolvedPullRequest): string {
181 const cleaned = pr.title.replace(/^(feat|fix|perf|docs|chore|refactor|test|build|ci|style|security)(\(.+?\))?!?:\s*/i, "");
182 return `${cleaned} (#${pr.number}) — @${pr.authorUsername}`;
183}
184
185/** Bucket the resolved PRs into the structured sections shape. */
186export function bucketPrs(prs: ResolvedPullRequest[]): ReleaseSections {
187 const out = EMPTY_SECTIONS();
188 for (const pr of prs) {
189 const key = classifyPr(pr);
190 out[key].bullets.push(renderPrBullet(pr));
191 }
192 return out;
193}
194
195// ---------------------------------------------------------------------------
196// Cross-referencing
197// ---------------------------------------------------------------------------
198
199/**
200 * Extract a PR number from a merge-commit subject line such as
201 * "Merge pull request #42 from foo/bar"
202 * "feat: thing (#42)"
203 * Returns null when no PR number is present.
204 */
205export function prNumberFromCommitMessage(message: string): number | null {
206 const m = message.match(/(?:#|pull request #)(\d+)/);
207 if (!m) return null;
208 const n = Number(m[1]);
209 return Number.isFinite(n) && n > 0 ? n : null;
210}
211
212/**
213 * Look up every merged PR referenced by the commits in the range,
214 * returning a deduplicated `ResolvedPullRequest[]` ordered by mergedAt
215 * descending (newest first). DB errors degrade to an empty array — the
216 * caller can still emit a fallback summary from raw commits.
217 */
218export async function resolvePrsForCommits(
219 repositoryId: string,
220 commits: GitCommit[]
221): Promise<ResolvedPullRequest[]> {
222 const prNumbers = new Set<number>();
223 for (const c of commits) {
224 const n = prNumberFromCommitMessage(c.message);
225 if (n !== null) prNumbers.add(n);
226 }
227 if (prNumbers.size === 0) return [];
228
229 try {
230 const numbers = [...prNumbers];
231 const rows = await db
232 .select({
233 id: pullRequests.id,
234 number: pullRequests.number,
235 title: pullRequests.title,
236 body: pullRequests.body,
237 authorId: pullRequests.authorId,
238 headBranch: pullRequests.headBranch,
239 mergedAt: pullRequests.mergedAt,
240 state: pullRequests.state,
241 username: users.username,
242 })
243 .from(pullRequests)
244 .innerJoin(users, eq(pullRequests.authorId, users.id))
245 .where(
246 and(
247 eq(pullRequests.repositoryId, repositoryId),
248 inArray(pullRequests.number, numbers)
249 )
250 );
251
252 const out: ResolvedPullRequest[] = rows
253 .filter((r) => r.state === "merged")
254 .map((r) => ({
255 number: r.number,
256 title: r.title,
257 body: r.body,
258 authorUsername: r.username,
259 headBranch: r.headBranch,
260 mergedAt: r.mergedAt,
261 labels: [],
262 autoMergedByAi: false,
263 }));
264
265 // Sort newest first.
266 out.sort((a, b) => {
267 const at = a.mergedAt ? a.mergedAt.getTime() : 0;
268 const bt = b.mergedAt ? b.mergedAt.getTime() : 0;
269 return bt - at;
270 });
271 return out;
272 } catch (err) {
273 console.error(
274 "[ai-release-notes] resolvePrsForCommits failed:",
275 err instanceof Error ? err.message : err
276 );
277 return [];
278 }
279}
280
281// ---------------------------------------------------------------------------
282// Prompt + Claude
283// ---------------------------------------------------------------------------
284
285interface ClaudeReleaseNotesResponse {
286 headline?: string;
287 summary?: string;
288 sections?: Partial<Record<ReleaseSectionKey, { bullets?: string[] }>>;
289}
290
291export function buildReleaseNotesPrompt(input: {
292 repoFullName: string;
293 fromTag: string | null;
294 toTag: string;
295 sections: ReleaseSections;
296 prs: ResolvedPullRequest[];
297}): string {
298 const prBlob = input.prs
299 .slice(0, 200)
300 .map((p) => {
301 const bodyHint = (p.body || "").split("\n").slice(0, 3).join(" ").slice(0, 240);
302 return `#${p.number} (${p.headBranch}) @${p.authorUsername}: ${p.title}${bodyHint ? ` — ${bodyHint}` : ""}`;
303 })
304 .join("\n");
305
306 const grouped = (Object.entries(input.sections) as Array<[string, ReleaseSection]>)
307 .filter(([, v]) => v.bullets.length > 0)
308 .map(([k, v]) => `${k}:\n${v.bullets.map((b: string) => ` - ${b}`).join("\n")}`)
309 .join("\n\n");
310
311 return `Write polished release notes for ${input.repoFullName} ${input.fromTag || "(initial)"}${input.toTag}.
312
313Here are the merged pull requests in this range, already grouped:
314
315${grouped || "(no PRs)"}
316
317Raw PR list for context:
318${prBlob || "(none)"}
319
320Output a single JSON object — no prose, no code fences. Schema:
321
322{
323 "headline": "short release tagline, under 70 chars, no emojis",
324 "summary": "1-3 sentences capturing what changed and why it matters",
325 "sections": {
326 "features": { "bullets": ["..."] },
327 "fixes": { "bullets": ["..."] },
328 "perf": { "bullets": ["..."] },
329 "docs": { "bullets": ["..."] },
330 "security": { "bullets": ["..."] },
331 "ai_changes": { "bullets": ["..."] }
332 }
333}
334
335Rules:
336- Omit empty sections (don't include them at all, or include with empty bullets array — either works).
337- Each bullet must keep its "(#N)" PR reference and "— @author" attribution so links resolve.
338- Polish the wording — drop conventional-commit prefixes (feat:, fix:), describe outcomes.
339- Be concise. No marketing fluff. Facts only.
340- Don't invent PRs that aren't in the input.`;
341}
342
343/**
344 * Call Claude for a structured release-notes envelope. Returns `null`
345 * on parse failure / API error so the caller can fall back to the
346 * deterministic bucket render.
347 */
348export async function askClaudeForReleaseNotes(
349 client: Pick<Anthropic, "messages">,
350 prompt: string
351): Promise<ClaudeReleaseNotesResponse | null> {
352 try {
353 const message = await client.messages.create({
354 model: MODEL_SONNET,
355 max_tokens: 2048,
356 messages: [{ role: "user", content: prompt }],
357 });
358 const text = extractText(message);
359 if (!text) return null;
360 return parseJsonResponse<ClaudeReleaseNotesResponse>(text);
361 } catch (err) {
362 console.error(
363 "[ai-release-notes] askClaudeForReleaseNotes failed:",
364 err instanceof Error ? err.message : err
365 );
366 return null;
367 }
368}
369
370/**
371 * Merge Claude's structured output back over the deterministic
372 * `bucketPrs` output. Claude wins for bullet wording when its bullet
373 * count matches the count per bucket; if Claude dropped bullets, we
374 * keep the deterministic ones so PR numbers never go missing.
375 */
376export function mergeClaudeSections(
377 deterministic: ReleaseSections,
378 fromClaude: ClaudeReleaseNotesResponse | null
379): ReleaseSections {
380 if (!fromClaude || !fromClaude.sections) return deterministic;
381 const out = EMPTY_SECTIONS();
382 for (const key of Object.keys(deterministic) as ReleaseSectionKey[]) {
383 const cb = fromClaude.sections[key]?.bullets;
384 if (Array.isArray(cb) && cb.length > 0 && cb.length >= deterministic[key].bullets.length) {
385 // Trust Claude's wording.
386 out[key].bullets = cb.map((b) => String(b).trim()).filter(Boolean);
387 } else {
388 out[key].bullets = deterministic[key].bullets;
389 }
390 }
391 return out;
392}
393
394// ---------------------------------------------------------------------------
395// Rendering
396// ---------------------------------------------------------------------------
397
398/**
399 * Render structured sections to Markdown. Empty sections are omitted.
400 */
401export function renderSectionsToMarkdown(input: {
402 repoFullName: string;
403 fromTag: string | null;
404 toTag: string;
405 headline: string;
406 summary: string;
407 sections: ReleaseSections;
408}): string {
409 const out: string[] = [];
410 out.push(`## ${input.toTag}${input.fromTag ? ` (since ${input.fromTag})` : ""}`);
411 if (input.headline) out.push("", `**${input.headline}**`);
412 if (input.summary) out.push("", input.summary);
413
414 const order: ReleaseSectionKey[] = [
415 "features",
416 "fixes",
417 "perf",
418 "security",
419 "docs",
420 "ai_changes",
421 "other",
422 ];
423 for (const key of order) {
424 const sec = input.sections[key];
425 if (!sec || sec.bullets.length === 0) continue;
426 out.push("", `### ${SECTION_TITLES[key]}`);
427 for (const b of sec.bullets) {
428 out.push(`- ${b}`);
429 }
430 }
431 out.push("", `_Full changelog_: \`${input.fromTag || "(initial)"}...${input.toTag}\``);
432 return out.join("\n");
433}
434
435// ---------------------------------------------------------------------------
436// Public entrypoint
437// ---------------------------------------------------------------------------
438
439/**
440 * Resolve a repository row → `{ owner, name }`. Used by both
441 * `generateReleaseNotes` and the autopilot watcher. Returns null when
442 * the repo has been deleted.
443 */
444async function resolveOwnerName(
445 repositoryId: string
446): Promise<{ owner: string; name: string } | null> {
447 try {
448 const [row] = await db
449 .select({
450 username: users.username,
451 name: repositories.name,
452 })
453 .from(repositories)
454 .innerJoin(users, eq(repositories.ownerId, users.id))
455 .where(eq(repositories.id, repositoryId))
456 .limit(1);
457 if (!row) return null;
458 return { owner: row.username, name: row.name };
459 } catch (err) {
460 console.error(
461 "[ai-release-notes] resolveOwnerName failed:",
462 err instanceof Error ? err.message : err
463 );
464 return null;
465 }
466}
467
468/**
469 * Build polished release notes for `fromTag..toTag` on the repo at
470 * `repositoryId`. Returns markdown + the structured sections used to
471 * render it. Never throws — degrades to a deterministic summary when
472 * Claude is unavailable or returns garbage.
473 */
474export async function generateReleaseNotes(
475 opts: GenerateReleaseNotesOptions
476): Promise<ReleaseNotesResult> {
477 const repoName = await resolveOwnerName(opts.repositoryId);
478 if (!repoName) {
479 return {
480 markdown: `## ${opts.toTag}\n\n(repository not found)\n`,
481 sections: EMPTY_SECTIONS(),
482 headline: "",
483 summary: "Repository not found.",
484 prCount: 0,
485 aiUsed: false,
486 };
487 }
488
489 // Resolve refs — caller may pass plain tag names; we accept either
490 // form. If `toTag` doesn't resolve there is nothing to summarise.
491 const toSha = await resolveRef(repoName.owner, repoName.name, opts.toTag);
492 if (!toSha) {
493 return {
494 markdown: `## ${opts.toTag}\n\n(target tag ${opts.toTag} does not resolve)\n`,
495 sections: EMPTY_SECTIONS(),
496 headline: "",
497 summary: `Tag ${opts.toTag} not found.`,
498 prCount: 0,
499 aiUsed: false,
500 };
501 }
502 const fromSha = opts.fromTag
503 ? await resolveRef(repoName.owner, repoName.name, opts.fromTag)
504 : null;
505
506 const commits = await commitsBetween(
507 repoName.owner,
508 repoName.name,
509 fromSha || (opts.fromTag ? opts.fromTag : null),
510 toSha
511 );
512
513 const prs = await resolvePrsForCommits(opts.repositoryId, commits);
514 const deterministic = bucketPrs(prs);
515
516 // Build the fallback summary up front so we always have something
517 // to return even if Claude is unavailable.
518 const fallbackHeadline = opts.toTag;
519 const fallbackSummary =
520 prs.length === 0
521 ? `No merged PRs were found between ${opts.fromTag || "(initial)"} and ${opts.toTag}; ${commits.length} commit(s) shipped.`
522 : `${prs.length} merged PR(s) ship in ${opts.toTag}.`;
523
524 let aiUsed = false;
525 let sections = deterministic;
526 let headline = fallbackHeadline;
527 let summary = fallbackSummary;
528
529 if (isAiAvailable() && prs.length > 0) {
530 let client: Pick<Anthropic, "messages">;
531 try {
532 client = opts.client ?? getAnthropic();
533 } catch {
534 // ANTHROPIC_API_KEY missing or SDK init failed — fall back.
535 client = null as any;
536 }
537 if (client) {
538 const prompt = buildReleaseNotesPrompt({
539 repoFullName: `${repoName.owner}/${repoName.name}`,
540 fromTag: opts.fromTag,
541 toTag: opts.toTag,
542 sections: deterministic,
543 prs,
544 });
545 const claudeOut = await askClaudeForReleaseNotes(client, prompt);
546 if (claudeOut) {
547 aiUsed = true;
548 sections = mergeClaudeSections(deterministic, claudeOut);
549 if (typeof claudeOut.headline === "string" && claudeOut.headline.trim()) {
550 headline = claudeOut.headline.trim().slice(0, 120);
551 }
552 if (typeof claudeOut.summary === "string" && claudeOut.summary.trim()) {
553 summary = claudeOut.summary.trim();
554 }
555 }
556 }
557 } else if (opts.client) {
558 // Test path: caller injected a fake client even without an API key.
559 const prompt = buildReleaseNotesPrompt({
560 repoFullName: `${repoName.owner}/${repoName.name}`,
561 fromTag: opts.fromTag,
562 toTag: opts.toTag,
563 sections: deterministic,
564 prs,
565 });
566 const claudeOut = await askClaudeForReleaseNotes(opts.client, prompt);
567 if (claudeOut) {
568 aiUsed = true;
569 sections = mergeClaudeSections(deterministic, claudeOut);
570 if (typeof claudeOut.headline === "string" && claudeOut.headline.trim()) {
571 headline = claudeOut.headline.trim().slice(0, 120);
572 }
573 if (typeof claudeOut.summary === "string" && claudeOut.summary.trim()) {
574 summary = claudeOut.summary.trim();
575 }
576 }
577 }
578
579 const markdown = renderSectionsToMarkdown({
580 repoFullName: `${repoName.owner}/${repoName.name}`,
581 fromTag: opts.fromTag,
582 toTag: opts.toTag,
583 headline,
584 summary,
585 sections,
586 });
587
588 return {
589 markdown,
590 sections,
591 headline,
592 summary,
593 prCount: prs.length,
594 aiUsed,
595 };
596}
597
598// ---------------------------------------------------------------------------
599// Autopilot wiring — auto-release-notes
600// ---------------------------------------------------------------------------
601
602/** Loose semver pattern: v?MAJOR.MINOR.PATCH (+optional -pre / +meta). */
603const SEMVER_RE = /^v?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.\-+]+)?$/;
604
605/** Public for tests — checks the pattern only, no DB access. */
606export function isSemverTag(tag: string): boolean {
607 return SEMVER_RE.test(tag.trim());
608}
609
610/** Threshold below which we consider a release body "empty enough" to overwrite. */
611export const RELEASE_BODY_SHORT_CHARS = 24;
612
613export interface AutoReleaseNotesTaskSummary {
614 considered: number;
615 filled: number;
616 skipped: number;
617 errors: number;
618}
619
620export interface AutoReleaseNotesTaskOptions {
621 /** Test seam — inject a fake generator. */
622 generate?: typeof generateReleaseNotes;
623 /** Hard cap to avoid burning credits on a backlog. */
624 cap?: number;
625}
626
627/**
628 * One pass of the auto-release-notes task. Scans the `releases` table
629 * for rows whose tag matches semver, body is empty/short, and there is
630 * at least one earlier tag to diff against. For each, generate notes
631 * and write them back. Stamps `ai.release_notes.generated` in the audit
632 * log per fill so operators can trace the change.
633 *
634 * Never throws — each row is wrapped individually so one bad repo
635 * never wedges the sweep.
636 */
637export async function runAutoReleaseNotesTaskOnce(
638 opts: AutoReleaseNotesTaskOptions = {}
639): Promise<AutoReleaseNotesTaskSummary> {
640 const cap = opts.cap ?? 20;
641 const gen = opts.generate ?? generateReleaseNotes;
642
643 const summary: AutoReleaseNotesTaskSummary = {
644 considered: 0,
645 filled: 0,
646 skipped: 0,
647 errors: 0,
648 };
649
650 let rows: Array<{
651 id: string;
652 repositoryId: string;
653 tag: string;
654 body: string | null;
655 }> = [];
656 try {
657 rows = await db
658 .select({
659 id: releases.id,
660 repositoryId: releases.repositoryId,
661 tag: releases.tag,
662 body: releases.body,
663 })
664 .from(releases)
665 .limit(500);
666 } catch (err) {
667 console.error(
668 "[ai-release-notes] task: load releases failed:",
669 err instanceof Error ? err.message : err
670 );
671 return summary;
672 }
673
674 const candidates = rows.filter(
675 (r) =>
676 isSemverTag(r.tag) &&
677 (!r.body || r.body.trim().length < RELEASE_BODY_SHORT_CHARS)
678 );
679
680 for (const r of candidates.slice(0, cap)) {
681 summary.considered += 1;
682 try {
683 // Find the previous semver tag on the same repo so we have a
684 // diff base. Look up all releases for the repo and pick the
685 // closest earlier one by createdAt.
686 const repoReleases = await db
687 .select({
688 tag: releases.tag,
689 createdAt: releases.createdAt,
690 })
691 .from(releases)
692 .where(eq(releases.repositoryId, r.repositoryId));
693 const ordered = repoReleases
694 .filter((x) => isSemverTag(x.tag) && x.tag !== r.tag)
695 .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
696
697 // The previous tag is the most recent one strictly before this row.
698 const thisRow = repoReleases.find((x) => x.tag === r.tag);
699 const prev = ordered
700 .filter((x) =>
701 thisRow ? x.createdAt.getTime() < thisRow.createdAt.getTime() : true
702 )
703 .pop();
704
705 const result = await gen({
706 repositoryId: r.repositoryId,
707 fromTag: prev?.tag ?? null,
708 toTag: r.tag,
709 });
710
711 if (!result.markdown || result.markdown.trim().length < RELEASE_BODY_SHORT_CHARS) {
712 summary.skipped += 1;
713 continue;
714 }
715
716 await db
717 .update(releases)
718 .set({ body: result.markdown })
719 .where(eq(releases.id, r.id));
720
721 await audit({
722 repositoryId: r.repositoryId,
723 action: "ai.release_notes.generated",
724 targetType: "release",
725 targetId: r.id,
726 metadata: {
727 tag: r.tag,
728 fromTag: prev?.tag ?? null,
729 prCount: result.prCount,
730 aiUsed: result.aiUsed,
731 },
732 });
733
734 summary.filled += 1;
735 } catch (err) {
736 summary.errors += 1;
737 console.error(
738 "[ai-release-notes] task: row failed:",
739 err instanceof Error ? err.message : err
740 );
741 }
742 }
743
744 return summary;
745}
746
747// ---------------------------------------------------------------------------
748// Test-only export
749// ---------------------------------------------------------------------------
750
751export const __test = {
752 resolveOwnerName,
753 SECTION_TITLES,
754};
Modifiedsrc/lib/autopilot.ts+35−0View fileUnifiedSplit
6363import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
6464import { runMigrationWatcherTaskOnce } from "./migration-assistant";
6565import { sweepStale as sweepStalePrLive } from "./pr-live";
66import { runAutoReleaseNotesTaskOnce } from "./ai-release-notes";
6667
6768export interface AutopilotTaskResult {
6869 name: string;
118119 */
119120const MIGRATION_WATCHER_INTERVAL_MS = 6 * 60 * 60 * 1000;
120121let _lastMigrationWatcherAt = 0;
122/**
123 * Auto-release-notes cadence. Cheap once tags are rare; we still throttle
124 * to every 10 minutes so freshly-pushed tags (whose release row was just
125 * created by `POST /:owner/:repo/releases`) get notes within ~one tick
126 * without us scanning the table every 5 minutes.
127 */
128const AUTO_RELEASE_NOTES_INTERVAL_MS = 10 * 60 * 1000;
129let _lastAutoReleaseNotesAt = 0;
121130
122131/**
123132 * Default task set. Each task is a thin wrapper around an existing locked
419428 }
420429 },
421430 },
431 {
432 // Auto-release-notes — backfills `releases.body` with Claude-generated
433 // polished changelogs for any semver-tagged release whose body is
434 // empty / too short. Cadence-gated to every 10 minutes; the lib
435 // itself caps the per-tick batch and is no-op when no candidates
436 // exist. Falls back to a deterministic bucketed summary when
437 // ANTHROPIC_API_KEY is unset, so the body still ends up populated.
438 name: "auto-release-notes",
439 run: async () => {
440 const now = Date.now();
441 if (now - _lastAutoReleaseNotesAt < AUTO_RELEASE_NOTES_INTERVAL_MS) {
442 return;
443 }
444 _lastAutoReleaseNotesAt = now;
445 try {
446 const summary = await runAutoReleaseNotesTaskOnce();
447 if (summary.considered > 0 || summary.filled > 0) {
448 console.log(
449 `[autopilot] auto-release-notes: considered=${summary.considered} filled=${summary.filled} skipped=${summary.skipped} errors=${summary.errors}`
450 );
451 }
452 } catch (err) {
453 console.error("[autopilot] auto-release-notes: threw:", err);
454 }
455 },
456 },
422457 ];
423458}
424459
Modifiedsrc/routes/api-v2.ts+44−0View fileUnifiedSplit
27002700 }
27012701);
27022702
2703// ─── Release notes (AI-generated) ───────────────────────────────────────────
2704//
2705// POST /api/v2/repos/:owner/:repo/releases/notes
2706// Body: { from_tag?: string | null, to_tag: string }
2707// Returns: { markdown, sections, headline, summary, prCount, aiUsed }
2708//
2709// Used by the release-form `Generate notes` button and external
2710// automation (e.g. CI scripts that auto-fill release bodies).
2711//
2712// Auth: read on the repo (lazy — public repos are open, private ones
2713// 404 from the resolver above). Generates notes; never persists them
2714// (caller decides). The autopilot watcher persists via a separate path.
2715
2716apiv2.post("/repos/:owner/:repo/releases/notes", async (c) => {
2717 const { owner, repo } = c.req.param();
2718 const resolved = await resolveRepo(owner, repo);
2719 if (!resolved) return c.json({ error: "Not Found" }, 404);
2720
2721 let body: { from_tag?: unknown; to_tag?: unknown } = {};
2722 try {
2723 body = await c.req.json();
2724 } catch {
2725 return c.json({ error: "Invalid JSON body" }, 400);
2726 }
2727 const toTag = typeof body.to_tag === "string" ? body.to_tag.trim() : "";
2728 if (!toTag) return c.json({ error: "to_tag is required" }, 400);
2729 const fromTag =
2730 typeof body.from_tag === "string" && body.from_tag.trim()
2731 ? body.from_tag.trim()
2732 : null;
2733
2734 const { generateReleaseNotes } = await import("../lib/ai-release-notes");
2735 const result = await generateReleaseNotes({
2736 repositoryId: (resolved.repo as any).id,
2737 fromTag,
2738 toTag,
2739 });
2740 return c.json(result);
2741});
2742
27032743// ─── API Info ───────────────────────────────────────────────────────────────
27042744
27052745apiv2.get("/", (c) => {
27642804 "GET /api/v2/repos/:owner/:repo/pulls/:number": "Get PR with comments",
27652805 "POST /api/v2/repos/:owner/:repo/pulls/:number/comments": "Add PR comment",
27662806 },
2807 releases: {
2808 "POST /api/v2/repos/:owner/:repo/releases/notes":
2809 "Generate AI release notes for from_tag → to_tag (markdown + structured sections)",
2810 },
27672811 stars: {
27682812 "PUT /api/v2/repos/:owner/:repo/star": "Star repository",
27692813 "DELETE /api/v2/repos/:owner/:repo/star": "Unstar repository",
Modifiedsrc/routes/releases.tsx+66−1View fileUnifiedSplit
777777 </div>
778778 <div class="rel-field">
779779 <label class="rel-field-label" for="rel-body">Notes (leave blank for AI-generated)</label>
780 <div style="display:flex; align-items:center; gap:8px; margin-bottom: 6px;">
781 <button
782 type="button"
783 id="rel-gen-notes"
784 class="rel-btn rel-btn-ghost"
785 aria-label="Generate AI release notes from merged PRs"
786 >
787 Generate notes
788 </button>
789 <span
790 id="rel-gen-notes-status"
791 style="font-size:12px; color: var(--text-muted);"
792 aria-live="polite"
793 ></span>
794 </div>
780795 <textarea
781796 class="rel-textarea"
782797 id="rel-body"
783798 name="body"
784799 rows={10}
785 placeholder="Markdown supported. Leave blank to have Claude generate a grouped changelog from commits."
800 placeholder="Markdown supported. Click 'Generate notes' to have Claude draft a polished changelog from every merged PR since the previous tag."
786801 ></textarea>
787802 </div>
788803 <div class="rel-checks">
800815 Publish release
801816 </button>
802817 </form>
818 <script
819 dangerouslySetInnerHTML={{
820 __html: `
821 (function(){
822 var btn = document.getElementById('rel-gen-notes');
823 if (!btn) return;
824 var status = document.getElementById('rel-gen-notes-status');
825 var tagInput = document.getElementById('rel-tag');
826 var prevSel = document.getElementById('rel-prev');
827 var bodyArea = document.getElementById('rel-body');
828 btn.addEventListener('click', async function(){
829 var toTag = (tagInput && tagInput.value || '').trim();
830 if (!toTag) {
831 status.textContent = 'Enter a tag name first.';
832 return;
833 }
834 var fromTag = (prevSel && prevSel.value || '').trim() || null;
835 btn.disabled = true;
836 status.textContent = 'Asking Claude…';
837 try {
838 var res = await fetch(${JSON.stringify(`/api/v2/repos/${owner}/${repo}/releases/notes`)}, {
839 method: 'POST',
840 headers: { 'Content-Type': 'application/json' },
841 credentials: 'same-origin',
842 body: JSON.stringify({ to_tag: toTag, from_tag: fromTag }),
843 });
844 if (!res.ok) {
845 var err = await res.text();
846 status.textContent = 'Failed: ' + (err || res.status);
847 return;
848 }
849 var data = await res.json();
850 if (data && typeof data.markdown === 'string') {
851 bodyArea.value = data.markdown;
852 status.textContent = data.aiUsed
853 ? ('Drafted from ' + (data.prCount || 0) + ' PR(s).')
854 : ('Deterministic summary (' + (data.prCount || 0) + ' PR(s) — set ANTHROPIC_API_KEY for polished output).');
855 } else {
856 status.textContent = 'Empty response.';
857 }
858 } catch (e) {
859 status.textContent = 'Network error.';
860 } finally {
861 btn.disabled = false;
862 }
863 });
864 })();
865 `,
866 }}
867 />
803868 </div>
804869 <style dangerouslySetInnerHTML={{ __html: relStyles }} />
805870 </Layout>
806871