Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"target":{"kind":"relative","path":"issues.jsonl"}}
1 change: 1 addition & 0 deletions .beads/.local_version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.49.4
Binary file added .beads/beads.db-shm
Binary file not shown.
Binary file added .beads/beads.db-wal
Binary file not shown.
4 changes: 4 additions & 0 deletions .beads/metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}
102 changes: 72 additions & 30 deletions bin/rewrite-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down Expand Up @@ -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 =
Expand All @@ -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 ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand All @@ -500,6 +529,7 @@ if (filteredCount === 0) {
);
userPrompt = devResult.prompt;
filteredCount = devResult.filteredCount;
filteredCommits = devResult.commits;
}
}
}
Expand All @@ -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);
19 changes: 19 additions & 0 deletions lib/release-notes.mjs
Original file line number Diff line number Diff line change
@@ -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._'}`;
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
26 changes: 26 additions & 0 deletions test/release-notes.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
Loading