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.
| 424eb72 | 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 | ||
| 36 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 37 | import { and, eq, inArray } from "drizzle-orm"; | |
| 38 | import { db } from "../db"; | |
| 39 | import { pullRequests, releases, repositories, users } from "../db/schema"; | |
| 40 | import { | |
| 41 | commitsBetween, | |
| 42 | resolveRef, | |
| 43 | type GitCommit, | |
| 44 | } from "../git/repository"; | |
| 45 | import { | |
| 46 | getAnthropic, | |
| 47 | isAiAvailable, | |
| 48 | MODEL_SONNET, | |
| 49 | extractText, | |
| 50 | parseJsonResponse, | |
| 51 | } from "./ai-client"; | |
| 52 | import { audit } from "./notify"; | |
| 53 | ||
| 54 | // --------------------------------------------------------------------------- | |
| 55 | // Types | |
| 56 | // --------------------------------------------------------------------------- | |
| 57 | ||
| 58 | export type ReleaseSectionKey = | |
| 59 | | "features" | |
| 60 | | "fixes" | |
| 61 | | "perf" | |
| 62 | | "docs" | |
| 63 | | "security" | |
| 64 | | "ai_changes" | |
| 65 | | "other"; | |
| 66 | ||
| 67 | export interface ReleaseSection { | |
| 68 | /** Plain bullet lines (no leading dash). */ | |
| 69 | bullets: string[]; | |
| 70 | } | |
| 71 | ||
| 72 | export 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 | ||
| 82 | export 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 | ||
| 96 | export 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 | ||
| 109 | export 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 | ||
| 126 | const 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 | ||
| 136 | const 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 | */ | |
| 151 | export 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). */ | |
| 180 | export 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. */ | |
| 186 | export 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 | */ | |
| 205 | export 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 | */ | |
| 218 | export 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 | ||
| 285 | interface ClaudeReleaseNotesResponse { | |
| 286 | headline?: string; | |
| 287 | summary?: string; | |
| 288 | sections?: Partial<Record<ReleaseSectionKey, { bullets?: string[] }>>; | |
| 289 | } | |
| 290 | ||
| 291 | export 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 | ||
| 313 | Here are the merged pull requests in this range, already grouped: | |
| 314 | ||
| 315 | ${grouped || "(no PRs)"} | |
| 316 | ||
| 317 | Raw PR list for context: | |
| 318 | ${prBlob || "(none)"} | |
| 319 | ||
| 320 | Output 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 | ||
| 335 | Rules: | |
| 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 | */ | |
| 348 | export 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 | */ | |
| 376 | export 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 | */ | |
| 401 | export 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 | */ | |
| 444 | async 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 | */ | |
| 474 | export 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). */ | |
| 603 | const SEMVER_RE = /^v?\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.\-+]+)?$/; | |
| 604 | ||
| 605 | /** Public for tests — checks the pattern only, no DB access. */ | |
| 606 | export 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. */ | |
| 611 | export const RELEASE_BODY_SHORT_CHARS = 24; | |
| 612 | ||
| 613 | export interface AutoReleaseNotesTaskSummary { | |
| 614 | considered: number; | |
| 615 | filled: number; | |
| 616 | skipped: number; | |
| 617 | errors: number; | |
| 618 | } | |
| 619 | ||
| 620 | export 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 | */ | |
| 637 | export 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 | ||
| 751 | export const __test = { | |
| 752 | resolveOwnerName, | |
| 753 | SECTION_TITLES, | |
| 754 | }; |