diff --git a/.beads/.br_history/issues.20260530_093241_401576000.jsonl b/.beads/.br_history/issues.20260530_093241_401576000.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/.beads/.br_history/issues.20260530_093241_401576000.jsonl.meta.json b/.beads/.br_history/issues.20260530_093241_401576000.jsonl.meta.json new file mode 100644 index 0000000..77b392c --- /dev/null +++ b/.beads/.br_history/issues.20260530_093241_401576000.jsonl.meta.json @@ -0,0 +1 @@ +{"target":{"kind":"relative","path":"issues.jsonl"}} \ No newline at end of file diff --git a/.beads/.local_version b/.beads/.local_version new file mode 100644 index 0000000..947e8cd --- /dev/null +++ b/.beads/.local_version @@ -0,0 +1 @@ +0.49.4 diff --git a/.beads/beads.db-shm b/.beads/beads.db-shm new file mode 100644 index 0000000..7317466 Binary files /dev/null and b/.beads/beads.db-shm differ diff --git a/.beads/beads.db-wal b/.beads/beads.db-wal new file mode 100644 index 0000000..335a687 Binary files /dev/null and b/.beads/beads.db-wal differ diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 0000000..c787975 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,4 @@ +{ + "database": "beads.db", + "jsonl_export": "issues.jsonl" +} \ No newline at end of file diff --git a/bin/rewrite-release-notes.mjs b/bin/rewrite-release-notes.mjs index 8ae12fa..4925752 100755 --- a/bin/rewrite-release-notes.mjs +++ b/bin/rewrite-release-notes.mjs @@ -56,6 +56,8 @@ import { writeFileSync, } from 'node:fs'; +import { buildFallbackNotes } from '../lib/release-notes.mjs'; + // ─── Help ──────────────────────────────────────────────────────────────────── if (process.argv.includes('--help') || process.argv.includes('-h')) { @@ -186,9 +188,12 @@ Each commit below includes the subject line and, where available, the commit bod ${commitBlocks}`, filteredCount: filtered.length, + commits: filtered, }; } +// buildFallbackNotes lives in ../lib/release-notes.mjs (pure + unit-tested). + // ─── LLM call (protoLabs LiteLLM gateway, OpenAI-compatible) ───────────────── const LLM_BASE_URL = @@ -205,32 +210,56 @@ async function callLLM(userPrompt) { const apiKey = process.env.GATEWAY_API_KEY; if (!apiKey) throw new Error('GATEWAY_API_KEY is not set'); - const res = await fetch(`${LLM_BASE_URL}/chat/completions`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: LLM_MODEL, - // Set high enough to fit reasoning + the final answer; capping - // too low truncates mid-reasoning and leaks unfinished thinking - // into `content` (see comment on LLM_MODEL above). - max_tokens: 8192, - messages: [ - { role: 'system', content: SYSTEM_PROMPT }, - { role: 'user', content: userPrompt }, - ], - }), - }); + // The gateway can blip — a Cloudflare 524 origin timeout once lost an entire + // release announcement. Retry transient failures (network throws, 429, 5xx) + // with backoff; fail fast on other 4xx. The caller falls back to plain commit + // notes if every attempt fails, so a blip never drops the Discord post. + const delays = [0, 3000, 10000]; + let lastErr; + for (let attempt = 0; attempt < delays.length; attempt++) { + if (delays[attempt] > 0) { + await new Promise((r) => setTimeout(r, delays[attempt])); + console.log(`Retrying LLM call (attempt ${attempt + 1})...`); + } + let res; + try { + res = await fetch(`${LLM_BASE_URL}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: LLM_MODEL, + // Set high enough to fit reasoning + the final answer; capping + // too low truncates mid-reasoning and leaks unfinished thinking + // into `content` (see comment on LLM_MODEL above). + max_tokens: 8192, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: userPrompt }, + ], + }), + }); + } catch (e) { + // fetch() itself threw (DNS / connection reset / timeout) — transient. + lastErr = e; + console.warn(`LLM call failed (${e.message}) — will retry.`); + continue; + } - if (!res.ok) { - const err = await res.text(); - throw new Error(`LLM API error ${res.status}: ${err}`); - } + if (res.ok) { + const data = await res.json(); + return data.choices?.[0]?.message?.content ?? ''; + } - const data = await res.json(); - return data.choices?.[0]?.message?.content ?? ''; + // Truncate — a 524 returns a full HTML error page that would flood the logs. + const body = (await res.text()).slice(0, 300); + lastErr = new Error(`LLM API error ${res.status}: ${body}`); + if (res.status !== 429 && res.status < 500) throw lastErr; // non-transient 4xx → fail fast + console.warn(`LLM API ${res.status} — will retry.`); + } + throw lastErr; } // ─── Discord ────────────────────────────────────────────────────────────────── @@ -478,11 +507,11 @@ console.log(`Generating release notes: ${previousVersion} → ${version}`); const commits = getCommitsBetween(previousVersion, version); console.log(`Found ${commits.length} commits`); -let { prompt: userPrompt, filteredCount } = buildUserPrompt( - version, - previousVersion, - commits, -); +let { + prompt: userPrompt, + filteredCount, + commits: filteredCommits, +} = buildUserPrompt(version, previousVersion, commits); // Fallback: when dev→main is squash-merged, the tag-to-tag range only contains // "chore: release" commits. Try origin/dev which preserves the individual @@ -500,6 +529,7 @@ if (filteredCount === 0) { ); userPrompt = devResult.prompt; filteredCount = devResult.filteredCount; + filteredCommits = devResult.commits; } } } @@ -516,4 +546,16 @@ if (filteredCount === 0) { process.exit(0); } -await emitNotes(await callLLM(userPrompt)); +let notes; +try { + notes = await callLLM(userPrompt); +} catch (err) { + console.warn(`LLM rewrite failed after retries (${err.message}).`); +} +// Empty or failed rewrite → fall back to a plain commit list so the release is +// still announced (a gateway blip must not silently drop the Discord post). +if (!notes?.trim()) { + console.warn('Falling back to raw commit notes.'); + notes = buildFallbackNotes(version, filteredCommits); +} +await emitNotes(notes); diff --git a/lib/release-notes.mjs b/lib/release-notes.mjs new file mode 100644 index 0000000..3d9966c --- /dev/null +++ b/lib/release-notes.mjs @@ -0,0 +1,19 @@ +// Pure helpers for release-note generation (testable; the bin wires them up). + +/** + * Plain, un-themed notes from commit blocks — used when the LLM rewrite can't be + * reached (e.g. a gateway outage). Better a raw changelog in Discord than no + * announcement at all. + * + * @param {string} version The release tag / version label. + * @param {string[]} commits Commit blocks (subject line + optional body). + * @returns {string} Markdown: a bolded version header + one bullet per subject. + */ +export function buildFallbackNotes(version, commits) { + const bullets = (commits || []) + .map((c) => String(c).split('\n')[0].trim()) + .filter(Boolean) + .map((s) => `• ${s}`) + .join('\n'); + return `**${version}**\n\n${bullets || '_See the GitHub release for details._'}`; +} diff --git a/package-lock.json b/package-lock.json index b9e50ab..239db4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@protolabsai/release-tools", - "version": "2.2.0", + "version": "2.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@protolabsai/release-tools", - "version": "2.2.0", + "version": "2.3.0", "license": "Apache-2.0", "bin": { "apply-branch-protection": "bin/apply-branch-protection.mjs", diff --git a/package.json b/package.json index 681db50..00bdd43 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@protolabsai/release-tools", - "version": "2.3.0", + "version": "2.4.0", "description": "Release-notes generator + GitHub Action for protoLabs repos. Rewrites raw git commits into themed release notes via the protoLabs LLM gateway and posts a Discord embed.", "type": "module", "license": "Apache-2.0", diff --git a/test/release-notes.test.mjs b/test/release-notes.test.mjs new file mode 100644 index 0000000..4b5e152 --- /dev/null +++ b/test/release-notes.test.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildFallbackNotes } from '../lib/release-notes.mjs'; + +test('buildFallbackNotes lists one bullet per commit subject', () => { + const out = buildFallbackNotes('@protolabsai/ui@0.35.0', [ + 'fix(app-shell): let the left column shrink to minLeftWidth\n\nbody text here', + 'feat(ui): something else', + ]); + assert.match(out, /\*\*@protolabsai\/ui@0\.35\.0\*\*/); + assert.match(out, /• fix\(app-shell\): let the left column shrink to minLeftWidth/); + assert.match(out, /• feat\(ui\): something else/); + // Only the subject line — the commit body is dropped. + assert.doesNotMatch(out, /body text here/); +}); + +test('buildFallbackNotes degrades gracefully with no commits', () => { + const out = buildFallbackNotes('v1.2.3', []); + assert.match(out, /\*\*v1\.2\.3\*\*/); + assert.match(out, /See the GitHub release for details/); +}); + +test('buildFallbackNotes tolerates a nullish commit list', () => { + assert.match(buildFallbackNotes('v9', undefined), /See the GitHub release/); +});