From fa65fa7f4194d8fdb09879541efdc7796d43cba1 Mon Sep 17 00:00:00 2001 From: Spikel Date: Fri, 10 Jul 2026 11:49:46 -0400 Subject: [PATCH] feat(changelog): BitRouter-refined drafts + multi-channel announce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the existing changelog sync into the refine → gate → publish loop for both BitRouter (OSS) and BitRouter Cloud. - sync-changelog.mjs: optional BitRouter refine pass (dogfood via api.bitrouter.ai) turns raw git-cliff bullets into benefit-oriented prose + a tighter title/description. Falls back to the deterministic draft when BITROUTER_API_KEY is absent, so nothing breaks without a key. Adds a `cloud` product path (inline body, cloud-.mdx) so the private repo is never fetched here. - source.config.ts: changelog frontmatter gains optional `product` (oss|cloud). - sync-changelog.yml: pass product/body/refine-key through from the dispatch. - announce-channels.mjs + announce-channels.yml (new): on a merged changelog entry, fan out to Discord / X (xurl) / Resend, tiered by version (alpha/patch → Discord only; x.y.0 → all). Per-channel copy is optionally spun by BitRouter, else derived deterministically. Every channel skips gracefully when unconfigured; entries are announced once (announced/ tag) and a backfill age-guard prevents old-release spam. The review gate is unchanged: the changelog/sync PR. The docs page + RSS/Atom feed still publish via the site rebuild on merge. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/announce-channels.yml | 93 ++++++++++ .github/workflows/sync-changelog.yml | 10 ++ scripts/announce-channels.mjs | 217 ++++++++++++++++++++++++ scripts/sync-changelog.mjs | 164 +++++++++++++++--- source.config.ts | 1 + 5 files changed, 461 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/announce-channels.yml create mode 100644 scripts/announce-channels.mjs diff --git a/.github/workflows/announce-channels.yml b/.github/workflows/announce-channels.yml new file mode 100644 index 0000000..273574e --- /dev/null +++ b/.github/workflows/announce-channels.yml @@ -0,0 +1,93 @@ +# Fan out newly-merged changelog entries to Discord / X / Resend. +# +# Fires when a curation PR merges to main (the review gate) and touches +# content/changelog/. The docs page + RSS/Atom feed publish themselves via the +# site rebuild; this owns only the push channels. Each entry is announced once +# (guarded by an `announced/` tag) and only if recent (the script skips +# backfill-age entries). Channels with no secret configured are skipped, so this +# is safe to merge before everything is wired. +name: Announce changelog + +on: + push: + branches: [main] + paths: ["content/changelog/**"] + +permissions: + contents: write # push the announced/ marker tags + +concurrency: announce-changelog + +jobs: + announce: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Find new, un-announced entries + id: find + run: | + set -euo pipefail + before="${{ github.event.before }}" + if ! git cat-file -e "${before}^{commit}" 2>/dev/null; then + before="${{ github.event.after }}^" + fi + added=$(git diff --name-only --diff-filter=A "$before" "${{ github.event.after }}" \ + -- 'content/changelog/*.mdx' || true) + entries="" + while IFS= read -r f; do + [ -z "$f" ] && continue + slug=$(basename "$f" .mdx) + if git rev-parse -q --verify "refs/tags/announced/$slug" >/dev/null 2>&1; then + echo "already announced: $slug"; continue + fi + entries="${entries}${f}"$'\n' + done <<< "$added" + { echo "entries<> "$GITHUB_OUTPUT" + [ -n "$entries" ] && echo "has=true" >> "$GITHUB_OUTPUT" || true + printf 'to announce:\n%s\n' "$entries" + + - uses: actions/setup-node@v4 + if: steps.find.outputs.has == 'true' + with: + node-version: 22 + + - name: Install + auth xurl (X posting; no-op without tokens) + if: steps.find.outputs.has == 'true' + env: + XURL_TOKENS: ${{ secrets.XURL_TOKENS }} + run: | + # Pin to the xurl release you use rather than 'latest'. + curl -fsSL -o /usr/local/bin/xurl \ + https://github.com/xdevplatform/xurl/releases/latest/download/xurl-linux-amd64 || true + chmod +x /usr/local/bin/xurl || true + printf '%s' "${XURL_TOKENS:-}" > "$HOME/.xurl" # verify path for your xurl version + + - name: Post to channels + if: steps.find.outputs.has == 'true' + env: + ENTRY_FILES: ${{ steps.find.outputs.entries }} + SITE_URL: ${{ vars.SITE_URL }} + DISCORD_WEBHOOK_OSS: ${{ secrets.DISCORD_WEBHOOK_OSS }} + DISCORD_WEBHOOK_CLOUD: ${{ secrets.DISCORD_WEBHOOK_CLOUD }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_AUDIENCE_OSS: ${{ vars.RESEND_AUDIENCE_OSS }} + RESEND_AUDIENCE_CLOUD: ${{ vars.RESEND_AUDIENCE_CLOUD }} + RESEND_FROM: ${{ vars.RESEND_FROM }} + # Optional: spins per-channel copy from the entry (dogfood). Absent → deterministic. + BITROUTER_API_KEY: ${{ secrets.BITROUTER_API_KEY }} + run: node scripts/announce-channels.mjs + + - name: Mark announced + if: steps.find.outputs.has == 'true' + run: | + git config user.name "bitrouter-bot" + git config user.email "bot@bitrouter.ai" + while IFS= read -r f; do + [ -z "$f" ] && continue + slug=$(basename "$f" .mdx) + git tag "announced/$slug" || true + git push origin "announced/$slug" || true + done <<< "${{ steps.find.outputs.entries }}" diff --git a/.github/workflows/sync-changelog.yml b/.github/workflows/sync-changelog.yml index 9a1649a..b7ccd60 100644 --- a/.github/workflows/sync-changelog.yml +++ b/.github/workflows/sync-changelog.yml @@ -41,6 +41,16 @@ jobs: # repository_dispatch sends client_payload.tag; workflow_dispatch sends # inputs.tag; schedule sends neither (backfill). CHANGELOG_TAG: ${{ github.event.client_payload.tag || github.event.inputs.tag }} + # product/source come from the dispatch (oss omits them → defaults). + # cloud sends its notes inline (body/name/date) since the private repo + # can't be fetched here. + PRODUCT: ${{ github.event.client_payload.product }} + SOURCE_REPO: ${{ github.event.client_payload.source_repo }} + CHANGELOG_BODY: ${{ github.event.client_payload.body }} + CHANGELOG_NAME: ${{ github.event.client_payload.name }} + CHANGELOG_DATE: ${{ github.event.client_payload.date }} + # Enables the BitRouter refine pass (dogfood). Absent → deterministic draft. + BITROUTER_API_KEY: ${{ secrets.BITROUTER_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: node scripts/sync-changelog.mjs diff --git a/scripts/announce-channels.mjs b/scripts/announce-channels.mjs new file mode 100644 index 0000000..1d38565 --- /dev/null +++ b/scripts/announce-channels.mjs @@ -0,0 +1,217 @@ +/** + * Fan out merged changelog entries to Discord, X, and a Resend broadcast. + * + * Runs after a changelog entry lands on main (the curation PR merged = the + * review gate). The docs page + RSS/Atom feed publish themselves via the site + * rebuild; this script owns only the push channels. + * + * Tiering by version: + * *-alpha/beta/rc* → Discord only + * x.y.z (z>0) → Discord only + * x.y.0 → Discord + X + email + * + * Every channel is skipped gracefully (logged, non-fatal) when its secret/env + * is missing, so this is safe to ship before all channels are wired. Idempotency + * (don't double-post) is the caller's job — the workflow tags announced entries. + * + * Env: + * ENTRY_FILES newline-separated content/changelog/*.mdx paths to announce + * SITE_URL default https://bitrouter.ai + * DISCORD_WEBHOOK_OSS / DISCORD_WEBHOOK_CLOUD + * RESEND_API_KEY, RESEND_AUDIENCE_OSS / RESEND_AUDIENCE_CLOUD, RESEND_FROM + * BITROUTER_API_KEY optional — spins per-channel copy from the entry (dogfood) + * BITROUTER_BASE_URL default https://api.bitrouter.ai/v1 + * ANNOUNCE_MODEL default bitrouter/kimi-k2.5 + * (X posting shells out to `xurl`, which the workflow installs + authenticates) + */ +import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileP = promisify(execFile); +const SITE_URL = (process.env.SITE_URL ?? "https://bitrouter.ai").replace(/\/$/, ""); +const BITROUTER_API_KEY = process.env.BITROUTER_API_KEY ?? ""; +const BITROUTER_BASE_URL = (process.env.BITROUTER_BASE_URL ?? "https://api.bitrouter.ai/v1").replace(/\/$/, ""); +const ANNOUNCE_MODEL = process.env.ANNOUNCE_MODEL ?? "bitrouter/kimi-k2.5"; +// Guard against a backfill merge spamming every channel with old releases. +const MAX_AGE_DAYS = Number(process.env.ANNOUNCE_MAX_AGE_DAYS ?? 14); + +const CHANNELS = { + prerelease: ["discord"], + patch: ["discord"], + significant: ["discord", "x", "email"], +}; + +function tierFor(version) { + if (/-(alpha|beta|rc)/i.test(version)) return "prerelease"; + if (/^v?\d+\.\d+\.0$/.test(version)) return "significant"; + return "patch"; +} + +// Parse the leading `---` frontmatter. sync-changelog.mjs writes every value as +// a JSON scalar/array, so each line parses with JSON.parse. +function parseEntry(raw, file) { + const m = raw.match(/^---\n([\s\S]*?)\n---/); + const fm = {}; + if (m) { + for (const line of m[1].split("\n")) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (!kv) continue; + try { + fm[kv[1]] = JSON.parse(kv[2]); + } catch { + fm[kv[1]] = kv[2]; + } + } + } + const body = m ? raw.slice(m[0].length) : raw; + const slug = file.replace(/^.*\//, "").replace(/\.mdx$/, ""); + return { ...fm, slug, body: body.replace(/\{\/\*[\s\S]*?\*\/\}/, "").trim() }; +} + +function deterministicCopy(entry, url) { + const line = `${entry.title} — ${entry.description}`; + return { + discord: `**${entry.title}**\n${entry.description}\n${url}`, + tweet: `${line}\n\n${url}`.slice(0, 279), + email_subject: entry.title, + email_html: `

${entry.title}

${entry.description}

Read the full changelog →

`, + }; +} + +// Optionally spin channel-native copy from the entry via BitRouter. Falls back +// to the deterministic copy on any failure. +async function channelCopy(entry, url) { + const fallback = deterministicCopy(entry, url); + if (!BITROUTER_API_KEY) return fallback; + const product = entry.product === "cloud" ? "BitRouter Cloud (paying customers)" : "BitRouter open source (self-hosters)"; + const system = + `Write release-announcement copy for ${product}. Respond with ONLY JSON: ` + + '{"discord": string (a few bullets, tasteful emoji, ends with the URL), ' + + '"tweet": string (<=270 chars, at most one link = the URL), ' + + '"email_subject": string (<=70 chars), "email_html": string (self-contained HTML, one CTA link to the URL)}. ' + + `Use this exact URL for links: ${url}`; + const user = `Title: ${entry.title}\nSummary: ${entry.description}\n\nChangelog:\n${entry.body}`; + try { + const res = await fetch(`${BITROUTER_BASE_URL}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${BITROUTER_API_KEY}` }, + body: JSON.stringify({ + model: ANNOUNCE_MODEL, + temperature: 0.4, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + }), + }); + if (!res.ok) return fallback; + const data = await res.json(); + let t = String(data?.choices?.[0]?.message?.content ?? "").trim(); + const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) t = fence[1].trim(); + const s = t.indexOf("{"); + const e = t.lastIndexOf("}"); + if (s === -1 || e === -1) return fallback; + const j = JSON.parse(t.slice(s, e + 1)); + return { + discord: j.discord || fallback.discord, + tweet: j.tweet || fallback.tweet, + email_subject: j.email_subject || fallback.email_subject, + email_html: j.email_html || fallback.email_html, + }; + } catch { + return fallback; + } +} + +async function postDiscord(entry, copy) { + const hook = + entry.product === "cloud" ? process.env.DISCORD_WEBHOOK_CLOUD : process.env.DISCORD_WEBHOOK_OSS; + if (!hook) return skip("discord", "no webhook configured"); + const res = await fetch(hook, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: copy.discord }), + }); + if (!res.ok) throw new Error(`Discord ${res.status}`); + console.log(` discord: posted`); +} + +async function postX(copy) { + try { + await execFileP("xurl", ["-X", "POST", "/2/tweets", "-d", JSON.stringify({ text: copy.tweet })]); + console.log(` x: posted`); + } catch (err) { + return skip("x", `xurl failed (${err.message?.split("\n")[0]})`); + } +} + +async function postEmail(entry, copy) { + const key = process.env.RESEND_API_KEY; + const audience = + entry.product === "cloud" ? process.env.RESEND_AUDIENCE_CLOUD : process.env.RESEND_AUDIENCE_OSS; + const from = process.env.RESEND_FROM; + if (!key || !audience || !from) return skip("email", "RESEND_API_KEY / audience / from not set"); + const create = await fetch("https://api.resend.com/broadcasts", { + method: "POST", + headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, + body: JSON.stringify({ audience_id: audience, from, subject: copy.email_subject, html: copy.email_html }), + }); + if (!create.ok) throw new Error(`Resend create ${create.status}`); + const { id } = await create.json(); + const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { + method: "POST", + headers: { Authorization: `Bearer ${key}` }, + }); + if (!send.ok) throw new Error(`Resend send ${send.status}`); + console.log(` email: broadcast ${id} sent`); +} + +function skip(channel, why) { + console.log(` ${channel}: skipped — ${why}`); +} + +async function main() { + const files = (process.env.ENTRY_FILES ?? "") + .split("\n") + .map((f) => f.trim()) + .filter((f) => f.endsWith(".mdx")); + if (files.length === 0) { + console.log("No changelog entries to announce."); + return; + } + + let failed = false; + for (const file of files) { + const entry = parseEntry(await readFile(file, "utf8"), file); + const ageDays = entry.date ? (Date.now() - Date.parse(entry.date)) / 86_400_000 : 0; + if (Number.isFinite(MAX_AGE_DAYS) && MAX_AGE_DAYS > 0 && ageDays > MAX_AGE_DAYS) { + console.log(`\n${file}: skipped — ${Math.round(ageDays)}d old (> ${MAX_AGE_DAYS}d, likely a backfill)`); + continue; + } + const tier = tierFor(entry.version ?? ""); + const url = `${SITE_URL}/changelog/${entry.slug}`; + const channels = CHANNELS[tier]; + console.log(`\n${entry.slug} (product=${entry.product ?? "oss"}, tier=${tier}) → ${channels.join(", ")}`); + const copy = await channelCopy(entry, url); + + for (const ch of channels) { + try { + if (ch === "discord") await postDiscord(entry, copy); + else if (ch === "x") await postX(copy); + else if (ch === "email") await postEmail(entry, copy); + } catch (err) { + console.error(` ${ch}: FAILED — ${err.message}`); + failed = true; + } + } + } + + if (failed) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/sync-changelog.mjs b/scripts/sync-changelog.mjs index bc8f11c..5ae4afd 100644 --- a/scripts/sync-changelog.mjs +++ b/scripts/sync-changelog.mjs @@ -1,30 +1,57 @@ /** - * Sync the marketing changelog from bitrouter/bitrouter GitHub Releases. + * Sync the marketing changelog from BitRouter GitHub Releases. * - * Deterministic generator for the "curated, PR-assisted" flow: it writes a - * *draft* MDX entry per release into content/changelog/, which a human curates - * (title/description/prose) before the PR is merged. It never overwrites an - * existing entry, so once an entry is hand-edited and merged it is frozen. + * Curated, PR-assisted flow: writes a *draft* MDX entry per release into + * content/changelog/, which a human curates before the PR is merged. It never + * overwrites an existing entry, so a merged entry is frozen. + * + * The draft is optionally polished by BitRouter (dogfood) when BITROUTER_API_KEY + * is present — the raw git-cliff bullets become benefit-oriented prose plus a + * tighter title/description. Without a key it falls back to the deterministic + * clean-up, so local runs and CI-without-secrets still work. + * + * Two products feed the same directory: + * - oss → reads releases from bitrouter/bitrouter (public; fetched via API) + * - cloud → body is passed inline via CHANGELOG_BODY (the private repo is not + * fetched), and entries are written as cloud-.mdx * * Env: - * SOURCE_REPO owner/repo to read releases from (default bitrouter/bitrouter) - * CHANGELOG_TAG a single tag to sync (set from the repository_dispatch - * payload); when unset, backfills the most recent releases - * CHANGELOG_LIMIT how many recent releases to consider when no tag is given - * (default 20) + * PRODUCT oss | cloud (default oss) + * SOURCE_REPO owner/repo to read releases from (default per product) + * CHANGELOG_TAG a single tag to sync (from the repository_dispatch payload); + * unset → backfill the most recent releases + * CHANGELOG_BODY inline release notes (used instead of fetching; cloud path) + * CHANGELOG_NAME release title for the inline path (optional) + * CHANGELOG_DATE ISO date for the inline path (optional) + * CHANGELOG_LIMIT how many recent releases when no tag is given (default 20) + * BITROUTER_API_KEY enables the refine pass (brk_ key for api.bitrouter.ai) + * BITROUTER_BASE_URL default https://api.bitrouter.ai/v1 + * CHANGELOG_REFINE_MODEL default bitrouter/kimi-k2.5 + * CHANGELOG_FORCE_REFINE "1" to also refine backfills (default: only single-tag) * GITHUB_TOKEN optional — raises the API rate limit * - * Writes the list of created files to $GITHUB_OUTPUT as `created` (newline- - * joined) and `count`, so the workflow can decide whether to open a PR. + * Writes `created` (newline-joined) and `count` to $GITHUB_OUTPUT. */ import { readdir, writeFile, appendFile } from "node:fs/promises"; import { join } from "node:path"; -const SOURCE_REPO = process.env.SOURCE_REPO ?? "bitrouter/bitrouter"; +const PRODUCT = (process.env.PRODUCT ?? "oss").toLowerCase() === "cloud" ? "cloud" : "oss"; +const SOURCE_REPO = + process.env.SOURCE_REPO?.trim() || + (PRODUCT === "cloud" ? "bitrouter/bitrouter-cloud" : "bitrouter/bitrouter"); const ONLY_TAG = process.env.CHANGELOG_TAG?.trim() || null; +const INLINE_BODY = process.env.CHANGELOG_BODY ?? null; const LIMIT = Number(process.env.CHANGELOG_LIMIT ?? 20); const DIR = "content/changelog"; +const BITROUTER_API_KEY = process.env.BITROUTER_API_KEY ?? ""; +const BITROUTER_BASE_URL = (process.env.BITROUTER_BASE_URL ?? "https://api.bitrouter.ai/v1").replace(/\/$/, ""); +const REFINE_MODEL = process.env.CHANGELOG_REFINE_MODEL ?? "bitrouter/kimi-k2.5"; +// Refining every backfilled release is wasteful; by default only refine the +// real-time single-tag path. CHANGELOG_FORCE_REFINE=1 overrides. +const REFINE_ENABLED = + Boolean(BITROUTER_API_KEY) && (Boolean(ONLY_TAG) || process.env.CHANGELOG_FORCE_REFINE === "1"); + function ghHeaders() { const h = { Accept: "application/vnd.github+json", "User-Agent": "bitrouter-docs-changelog-sync" }; if (process.env.GITHUB_TOKEN) h.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; @@ -32,6 +59,19 @@ function ghHeaders() { } async function fetchReleases() { + // Inline path (cloud / private): synthesize a release from the payload. + if (INLINE_BODY != null) { + if (!ONLY_TAG) throw new Error("CHANGELOG_BODY requires CHANGELOG_TAG"); + return [ + { + tag_name: ONLY_TAG, + name: process.env.CHANGELOG_NAME?.trim() || ONLY_TAG, + body: INLINE_BODY, + published_at: process.env.CHANGELOG_DATE?.trim() || new Date().toISOString(), + draft: false, + }, + ]; + } if (ONLY_TAG) { const res = await fetch( `https://api.github.com/repos/${SOURCE_REPO}/releases/tags/${encodeURIComponent(ONLY_TAG)}`, @@ -49,8 +89,10 @@ async function fetchReleases() { } // Tag → file slug, matching the existing v0-4-0.mdx convention (dots → dashes). +// Cloud entries are prefixed so they never collide with an equal OSS version. function slugForTag(tag) { - return tag.replace(/[^\w.-]/g, "").replace(/\./g, "-").toLowerCase(); + const base = tag.replace(/[^\w.-]/g, "").replace(/\./g, "-").toLowerCase(); + return PRODUCT === "cloud" ? `cloud-${base}` : base; } // git-cliff release notes group bullets under "### ⛰️ Features" etc. and end @@ -58,7 +100,6 @@ function slugForTag(tag) { // link) so the draft reads less like a raw commit log. function cleanBody(body) { return (body ?? "") - // strip the trailing " - ([hash](url))" without crossing line boundaries .replace(/[^\S\n]*-[^\S\n]*\(\[[0-9a-f]{7,}\]\([^)]+\)\)[^\S\n]*$/gim, "") .replace(/\n{3,}/g, "\n\n") .trim(); @@ -70,8 +111,8 @@ function firstFeatureLine(body) { if (line.startsWith("- ")) { return line .replace(/^- /, "") - .replace(/\*\(([^)]+)\)\*\s*/, "") // drop the *(scope)* prefix - .replace(/\s*\(\[#\d+\][^)]*\)\s*.*$/, "") // drop PR/commit refs + tail + .replace(/\*\(([^)]+)\)\*\s*/, "") + .replace(/\s*\(\[#\d+\][^)]*\)\s*.*$/, "") .trim(); } } @@ -95,7 +136,70 @@ function yaml(v) { return JSON.stringify(v); // JSON scalars/arrays are valid YAML flow syntax } -function buildMdx(release) { +// Product-aware voice for the refine pass. +const VOICE = { + oss: + "BitRouter (open source) is a self-hosted LLM proxy/gateway written in Rust. " + + "Readers self-host it; they care about routing correctness, new providers/models, " + + "config/CLI changes, performance, and breaking changes. Be precise and technical, no hype.", + cloud: + "BitRouter Cloud is the managed gateway (api.bitrouter.ai). Readers are paying customers; " + + "they care about new models/providers they can call, reliability, billing/dashboard changes, " + + "and new capabilities. Benefit-first and clear, never salesy; drop internal-only changes.", +}; + +function extractJson(text) { + let t = String(text ?? "").trim(); + const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence) t = fence[1].trim(); + const s = t.indexOf("{"); + const e = t.lastIndexOf("}"); + if (s === -1 || e === -1) return null; + try { + return JSON.parse(t.slice(s, e + 1)); + } catch { + return null; + } +} + +// Polish the draft through BitRouter. Returns { title, description, body } or +// null on any failure so the caller falls back to the deterministic draft. +async function refine(release, cleaned) { + const label = PRODUCT === "cloud" ? "BitRouter Cloud" : "BitRouter (open source)"; + const system = + `You are the changelog editor for ${label}. ${VOICE[PRODUCT]} ` + + "Rewrite the raw release notes into a polished changelog entry. Respond with ONLY a JSON " + + 'object: {"title": string (<=60 chars), "description": string (one sentence, <=140 chars), ' + + '"body": string}. In "body": keep the "### Group" headings and the PR links like ' + + "([#123](url)); turn terse bullets into clear, benefit-oriented lines; drop noise; no H1."; + const user = `Release ${release.tag_name}\n\nRaw notes:\n${cleaned || "(none)"}`; + try { + const res = await fetch(`${BITROUTER_BASE_URL}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${BITROUTER_API_KEY}` }, + body: JSON.stringify({ + model: REFINE_MODEL, + temperature: 0.3, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + }), + }); + if (!res.ok) { + console.warn(`refine: BitRouter ${res.status} — falling back to deterministic draft`); + return null; + } + const data = await res.json(); + const json = extractJson(data?.choices?.[0]?.message?.content); + return json?.body ? json : null; + } catch (err) { + console.warn(`refine: ${err} — falling back to deterministic draft`); + return null; + } +} + +async function buildMdx(release) { const tag = release.tag_name; const date = (release.published_at ?? release.created_at ?? "").slice(0, 10); const cleaned = cleanBody(release.body); @@ -103,23 +207,35 @@ function buildMdx(release) { const tags = deriveTags(cleaned); const breaking = isBreaking(cleaned); + let title = release.name?.trim() || tag; + let description = lead ? `${lead}.` : `BitRouter ${tag} release.`; + let body = cleaned || "_No release notes._"; + + const refined = REFINE_ENABLED ? await refine(release, cleaned) : null; + if (refined) { + title = refined.title?.trim() || title; + description = refined.description?.trim() || description; + body = refined.body?.trim() || body; + } + const fm = [ "---", - `title: ${yaml(release.name?.trim() || tag)}`, - `description: ${yaml(lead ? `${lead}.` : `BitRouter ${tag} release.`)}`, + `title: ${yaml(title)}`, + `description: ${yaml(description)}`, `date: ${yaml(date)}`, `version: ${yaml(tag)}`, + `product: ${yaml(PRODUCT)}`, `tags: ${yaml(tags)}`, ...(breaking ? ["breaking: true"] : []), "---", ].join("\n"); const banner = - `{/* AUTO-GENERATED DRAFT from ${SOURCE_REPO} release ${tag}.\n` + + `{/* AUTO-GENERATED DRAFT${refined ? " (BitRouter-refined)" : ""} from ${SOURCE_REPO} release ${tag}.\n` + ` Curate the title, description and prose below, then merge.\n` + ` Re-running the sync will NOT overwrite this file. */}`; - return `${fm}\n\n${banner}\n\n${cleaned || "_No release notes._"}\n`; + return `${fm}\n\n${banner}\n\n${body}\n`; } async function main() { @@ -139,9 +255,9 @@ async function main() { console.log(`skip ${file} (already present)`); continue; } - await writeFile(join(DIR, file), buildMdx(release), "utf8"); + await writeFile(join(DIR, file), await buildMdx(release), "utf8"); created.push(join(DIR, file)); - console.log(`write ${file} (${release.tag_name})`); + console.log(`write ${file} (${release.tag_name}, product=${PRODUCT})`); } console.log(`\n${created.length} new entr${created.length === 1 ? "y" : "ies"}.`); diff --git a/source.config.ts b/source.config.ts index a48d93c..280fc81 100644 --- a/source.config.ts +++ b/source.config.ts @@ -58,6 +58,7 @@ export const changelog = defineDocs({ schema: frontmatterSchema.extend({ date: z.string(), // ISO YYYY-MM-DD (required) version: z.string().optional(), + product: z.enum(["oss", "cloud"]).optional(), tags: z.array(z.string()).optional(), breaking: z.boolean().optional(), }),