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-release-notes.ts

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-release-notes.tsBlame754 lines · 1 contributor
424eb72Claude1/**
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};