| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import { execFileSync } from "node:child_process"; |
| 4 | import { resolve } from "node:path"; |
| 5 | import { fileURLToPath } from "node:url"; |
| 6 | import { dirname } from "node:path"; |
| 7 | import { loadCatalog, upsertRelease, validateCatalog } from "./release-notes.mjs"; |
| 8 | |
| 9 | const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 10 | const apiBase = process.env.DEEPSEEK_API_BASE || "https://api.deepseek.com"; |
| 11 | const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro"; |
| 12 | |
| 13 | function parseArgs(argv) { |
| 14 | const values = {}; |
| 15 | for (let index = 0; index < argv.length; index += 1) { |
| 16 | const arg = argv[index]; |
| 17 | if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`); |
| 18 | values[arg.slice(2)] = argv[++index]; |
| 19 | } |
| 20 | return values; |
| 21 | } |
| 22 | |
| 23 | function runGit(args) { |
| 24 | return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim(); |
| 25 | } |
| 26 | |
| 27 | function normalizeVersion(version) { |
| 28 | return String(version || "").replace(/^(?:desktop-|npm-)?v/, ""); |
| 29 | } |
| 30 | |
| 31 | function repositoryName() { |
| 32 | if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY; |
| 33 | const remote = runGit(["remote", "get-url", "origin"]); |
| 34 | const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/); |
| 35 | if (!match) throw new Error("cannot determine GitHub repository; set GITHUB_REPOSITORY"); |
| 36 | return match[1]; |
| 37 | } |
| 38 | |
| 39 | function commitRange(from, to) { |
| 40 | return runGit(["log", "--first-parent", "--format=%H%x09%s%x09%b%x00", `${from}..${to}`]) |
| 41 | .split("\0") |
| 42 | .map((record) => record.trim()) |
| 43 | .filter(Boolean) |
| 44 | .map((record) => { |
| 45 | const [sha, subject, ...body] = record.split("\t"); |
| 46 | return { sha, subject, body: body.join("\t").trim() }; |
| 47 | }); |
| 48 | } |
| 49 | |
| 50 | function prNumbersFromCommits(commits) { |
| 51 | const refs = new Set(); |
| 52 | for (const commit of commits) { |
| 53 | for (const match of `${commit.subject}\n${commit.body}`.matchAll(/#(\d+)/g)) refs.add(Number(match[1])); |
| 54 | } |
| 55 | return [...refs]; |
| 56 | } |
| 57 | |
| 58 | async function githubJson(path, { allowMissing = false } = {}) { |
| 59 | const headers = { Accept: "application/vnd.github+json", "User-Agent": "reasonix-release-notes" }; |
| 60 | if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) { |
| 61 | headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`; |
| 62 | } |
| 63 | const response = await fetch(`https://api.github.com${path}`, { headers, signal: AbortSignal.timeout(30_000) }); |
| 64 | if (allowMissing && response.status === 404) return null; |
| 65 | if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status}`); |
| 66 | return response.json(); |
| 67 | } |
| 68 | |
| 69 | async function collectPullRequests(repository, commits) { |
| 70 | const numbers = new Set(prNumbersFromCommits(commits)); |
| 71 | const associated = await Promise.all( |
| 72 | commits.map((commit) => githubJson(`/repos/${repository}/commits/${commit.sha}/pulls`, { allowMissing: true })), |
| 73 | ); |
| 74 | for (const pulls of associated) for (const pull of pulls || []) numbers.add(pull.number); |
| 75 | const pulls = await Promise.all([...numbers].map((number) => githubJson(`/repos/${repository}/pulls/${number}`, { allowMissing: true }))); |
| 76 | return pulls.filter(Boolean).map((pull) => ({ |
| 77 | number: pull.number, |
| 78 | title: pull.title, |
| 79 | body: String(pull.body || "").slice(0, 2000), |
| 80 | author: pull.user?.login || "", |
| 81 | labels: (pull.labels || []).map((label) => label.name), |
| 82 | })); |
| 83 | } |
| 84 | |
| 85 | function collectDocLinks(from, repository, to) { |
| 86 | const linkRef = runGit(["rev-parse", to]); |
| 87 | const paths = runGit(["diff", "--name-only", `${from}..${to}`]) |
| 88 | .split("\n") |
| 89 | .filter((path) => /^(?:docs|README)[/\w.-]*\.(?:md|mdx)$/i.test(path)); |
| 90 | return paths.map((path) => `https://github.com/${repository}/blob/${linkRef}/${path}`); |
| 91 | } |
| 92 | |
| 93 | function assertGroundedRefs(value, allowedRefs, path = "release") { |
| 94 | if (Array.isArray(value)) { |
| 95 | value.forEach((item, index) => assertGroundedRefs(item, allowedRefs, `${path}[${index}]`)); |
| 96 | return; |
| 97 | } |
| 98 | if (!value || typeof value !== "object") return; |
| 99 | if (Array.isArray(value.refs)) { |
| 100 | for (const ref of value.refs) { |
| 101 | if (!allowedRefs.has(ref)) throw new Error(`${path}.refs contains PR #${ref}, which is outside the release range`); |
| 102 | } |
| 103 | } |
| 104 | for (const [key, child] of Object.entries(value)) assertGroundedRefs(child, allowedRefs, `${path}.${key}`); |
| 105 | } |
| 106 | |
| 107 | function extractJson(content) { |
| 108 | if (!content?.trim()) throw new Error("DeepSeek returned empty content"); |
| 109 | const parsed = JSON.parse(content); |
| 110 | return parsed.release || parsed; |
| 111 | } |
| 112 | |
| 113 | async function askDeepSeek(payload, retry = true) { |
| 114 | const key = process.env.DEEPSEEK_API_KEY; |
| 115 | if (!key) throw new Error("DEEPSEEK_API_KEY is required"); |
| 116 | const response = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, { |
| 117 | method: "POST", |
| 118 | headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" }, |
| 119 | body: JSON.stringify({ |
| 120 | model, |
| 121 | // Release notes need deterministic structured output, not hidden chain of |
| 122 | // thought. Thinking tokens share the generation budget with content and |
| 123 | // can otherwise leave response_format JSON empty or truncated. |
| 124 | thinking: { type: "disabled" }, |
| 125 | temperature: 0, |
| 126 | max_tokens: 8000, |
| 127 | response_format: { type: "json_object" }, |
| 128 | messages: [ |
| 129 | { |
| 130 | role: "system", |
| 131 | content: `You are Reasonix's release editor. Return one JSON object with a \"release\" property. Write factual, user-facing product release notes in equivalent English and Simplified Chinese. Group changes by user outcome, not by commit. Never invent capabilities, migrations, risks, PR numbers, contributors, URLs, or metrics. Every highlight and change must cite one or more supplied PR numbers. Use this exact release shape: |
| 132 | { |
| 133 | \"version\": \"semver\", \"date\": \"YYYY-MM-DD\", \"channel\": \"stable|prerelease\", |
| 134 | \"title\": {\"en\":\"\",\"zh\":\"\"}, \"summary\": {\"en\":\"\",\"zh\":\"\"}, |
| 135 | \"surfaces\": [\"desktop\"], |
| 136 | \"guides\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"href\":\"https://...\"}], |
| 137 | \"highlights\": [{\"kind\":\"new|improved|fixed|security\",\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}], |
| 138 | \"changes\": {\"new\":[],\"improved\":[],\"fixed\":[]}, |
| 139 | \"upgrade\": [{\"level\":\"info|warning\",\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}], |
| 140 | \"risks\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}], |
| 141 | \"contributors\": [], \"links\": {\"github\":\"https://...\",\"compare\":\"https://...\",\"download\":\"https://...\"} |
| 142 | } |
| 143 | Return guides only for supplied documentation URLs. Mention upgrade action or risk only when explicitly supported; otherwise use empty arrays. Output JSON only.`, |
| 144 | }, |
| 145 | { role: "user", content: `Create the release record from these public GitHub sources:\n${JSON.stringify(payload)}` }, |
| 146 | ], |
| 147 | }), |
| 148 | }); |
| 149 | if (!response.ok) throw new Error(`DeepSeek API failed: ${response.status} ${await response.text()}`); |
| 150 | const data = await response.json(); |
| 151 | const choice = data.choices?.[0]; |
| 152 | try { |
| 153 | return extractJson(choice?.message?.content); |
| 154 | } catch (error) { |
| 155 | if (!retry) { |
| 156 | throw new Error(`${error.message} (finish_reason=${choice?.finish_reason || "unknown"})`); |
| 157 | } |
| 158 | return askDeepSeek(payload, false); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | async function main() { |
| 163 | const args = parseArgs(process.argv.slice(2)); |
| 164 | if (!args.version) throw new Error("--version is required"); |
| 165 | const version = normalizeVersion(args.version); |
| 166 | const catalog = await loadCatalog(); |
| 167 | const channel = version.includes("-") ? "prerelease" : "stable"; |
| 168 | const baseVersion = version.split("-")[0]; |
| 169 | const previousRecord = channel === "stable" |
| 170 | ? catalog.releases.find((release) => release.version !== version && release.channel === "stable") |
| 171 | : catalog.releases.find( |
| 172 | (release) => |
| 173 | release.version !== version && |
| 174 | release.channel === "prerelease" && |
| 175 | release.baseVersion === baseVersion, |
| 176 | ) || catalog.releases.find((release) => release.channel === "stable"); |
| 177 | const previous = args.from || previousRecord?.version; |
| 178 | if (!previous) throw new Error("--from is required when no previous release exists"); |
| 179 | const previousVersion = normalizeVersion(previous); |
| 180 | const previousIsPreview = previousVersion.includes("-"); |
| 181 | const from = previous.match(/^(?:desktop-|npm-)?v/) |
| 182 | ? previous |
| 183 | : previousIsPreview |
| 184 | ? `v${previousVersion}` |
| 185 | : `desktop-v${previousVersion}`; |
| 186 | const to = args.to || "HEAD"; |
| 187 | const repository = repositoryName(); |
| 188 | const commits = commitRange(from, to); |
| 189 | if (!commits.length) throw new Error(`no commits found in ${from}..${to}`); |
| 190 | const pulls = await collectPullRequests(repository, commits); |
| 191 | if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`); |
| 192 | const docLinks = collectDocLinks(from, repository, to); |
| 193 | const date = args.date || new Date().toISOString().slice(0, 10); |
| 194 | const tag = args.tag || (channel === "prerelease" ? `v${version}` : `desktop-v${version}`); |
| 195 | const source = { |
| 196 | version, |
| 197 | date, |
| 198 | channel, |
| 199 | range: `${from}..${to}`, |
| 200 | pullRequests: pulls, |
| 201 | documentationUrls: docLinks, |
| 202 | }; |
| 203 | const release = await askDeepSeek(source); |
| 204 | release.version = version; |
| 205 | release.releaseId = version; |
| 206 | release.baseVersion = baseVersion; |
| 207 | release.date = date; |
| 208 | release.channel = source.channel; |
| 209 | release.status = "reviewed"; |
| 210 | release.previousRelease = previousVersion; |
| 211 | const previewOrdinal = version.match(/-preview\.([1-9][0-9]*)$/)?.[1]; |
| 212 | if (channel === "prerelease") { |
| 213 | if (!previewOrdinal) throw new Error("Preview release version must use MAJOR.MINOR.PATCH-preview.N"); |
| 214 | release.builds = { |
| 215 | cli: `v${version}`, |
| 216 | desktop: `v${baseVersion}-preview.${previewOrdinal}`, |
| 217 | npm: `${baseVersion}-canary.${previewOrdinal}`, |
| 218 | }; |
| 219 | } else { |
| 220 | release.builds = { |
| 221 | cli: `v${version}`, |
| 222 | desktop: `v${version}`, |
| 223 | npm: version, |
| 224 | }; |
| 225 | } |
| 226 | release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))]; |
| 227 | release.links = { |
| 228 | github: `https://github.com/${repository}/releases/tag/${tag}`, |
| 229 | compare: `https://github.com/${repository}/compare/${from}...${tag}`, |
| 230 | download: channel === "prerelease" |
| 231 | ? "https://reasonix.io/?download=desktop&channel=preview#start" |
| 232 | : "https://reasonix.io/?download=desktop&channel=stable#start", |
| 233 | }; |
| 234 | release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href)); |
| 235 | assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number))); |
| 236 | validateCatalog({ schemaVersion: 1, releases: [release] }); |
| 237 | await upsertRelease(release); |
| 238 | console.log(`Generated bilingual release notes for v${version} from ${pulls.length} pull request(s).`); |
| 239 | } |
| 240 | |
| 241 | main().catch((error) => { |
| 242 | console.error(error.message); |
| 243 | process.exitCode = 1; |
| 244 | }); |
| 245 |