返回 DeepSeek-Reasonix
generate-release-notes.mjs
根目录 / scripts / generate-release-notes.mjs
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 const releaseTargetOrder = ["desktop", "cli", "site", "service"];
13
14 export const releaseOutputBudgets = Object.freeze({
15 standard: Object.freeze({ highlights: 6, changes: 15, guides: 6, upgrade: 4, risks: 4, bodyChars: 280 }),
16 compact: Object.freeze({ highlights: 4, changes: 9, guides: 4, upgrade: 2, risks: 2, bodyChars: 220 }),
17 });
18
19 export function editorialLimitInstruction(compact = false) {
20 const budget = compact ? releaseOutputBudgets.compact : releaseOutputBudgets.standard;
21 return `Editorial budget: at most ${budget.highlights} highlights, ${budget.changes} total change items across new/improved/fixed, ${budget.guides} guides, ${budget.upgrade} upgrade notes, and ${budget.risks} risks. Keep every English and Chinese body within ${budget.bodyChars} characters. Select the most important user outcomes across the supplied PRs, combine related work, and avoid repeating the same outcome in highlights and changes. Always return a complete JSON object within this budget.`;
22 }
23
24 function parseArgs(argv) {
25 const values = {};
26 for (let index = 0; index < argv.length; index += 1) {
27 const arg = argv[index];
28 if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
29 values[arg.slice(2)] = argv[++index];
30 }
31 return values;
32 }
33
34 function runGit(args) {
35 return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
36 }
37
38 function normalizeVersion(version) {
39 return String(version || "").replace(/^(?:desktop-|npm-)?v/, "");
40 }
41
42 function repositoryName() {
43 if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
44 const remote = runGit(["remote", "get-url", "origin"]);
45 const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
46 if (!match) throw new Error("cannot determine GitHub repository; set GITHUB_REPOSITORY");
47 return match[1];
48 }
49
50 function commitRange(from, to) {
51 return runGit(["log", "--first-parent", "--format=%H%x09%s%x09%b%x00", `${from}..${to}`])
52 .split("\0")
53 .map((record) => record.trim())
54 .filter(Boolean)
55 .map((record) => {
56 const [sha, subject, ...body] = record.split("\t");
57 return { sha, subject, body: body.join("\t").trim() };
58 });
59 }
60
61 function prNumbersFromCommits(commits) {
62 const refs = new Set();
63 for (const commit of commits) {
64 for (const match of `${commit.subject}\n${commit.body}`.matchAll(/#(\d+)/g)) refs.add(Number(match[1]));
65 }
66 return [...refs];
67 }
68
69 async function githubJson(path, { allowMissing = false } = {}) {
70 const headers = { Accept: "application/vnd.github+json", "User-Agent": "reasonix-release-notes" };
71 if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) {
72 headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`;
73 }
74 const response = await fetch(`https://api.github.com${path}`, { headers, signal: AbortSignal.timeout(30_000) });
75 if (allowMissing && response.status === 404) return null;
76 if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status}`);
77 return response.json();
78 }
79
80 async function githubList(path) {
81 const items = [];
82 for (let page = 1; ; page += 1) {
83 const separator = path.includes("?") ? "&" : "?";
84 const batch = await githubJson(`${path}${separator}per_page=100&page=${page}`);
85 items.push(...batch);
86 if (batch.length < 100) return items;
87 }
88 }
89
90 export function inferPullTargetHints(labels, files) {
91 const labelSet = new Set(labels);
92 const targets = new Set();
93 const add = (target) => targets.add(target);
94 const hasPath = (pattern) => files.some((path) => pattern.test(path));
95
96 const explicitlyDesktop = labelSet.has("desktop");
97 const explicitlyCLI = labelSet.has("tui") || labelSet.has("cli");
98 if (explicitlyDesktop) add("desktop");
99 if (explicitlyCLI) add("cli");
100 if (hasPath(/^desktop\//)) add("desktop");
101 if (hasPath(/^(?:internal\/cli\/|cmd\/)/)) add("cli");
102 if (hasPath(/^site\//)) add("site");
103 if (hasPath(/^(?:workers\/|\.github\/|scripts\/|release-notes\/)/)) add("service");
104
105 const hasSharedProductCode = files.some((path) =>
106 /^(?:internal\/|sdk\/|npm\/)/.test(path) &&
107 !/^internal\/cli\//.test(path) &&
108 !/^internal\/telemetry\//.test(path),
109 );
110 if (!explicitlyDesktop && !explicitlyCLI && hasSharedProductCode) {
111 add("desktop");
112 add("cli");
113 }
114 if (!targets.size) {
115 add("desktop");
116 add("cli");
117 }
118 return releaseTargetOrder.filter((target) => targets.has(target));
119 }
120
121 async function collectPullRequests(repository, commits) {
122 const numbers = new Set(prNumbersFromCommits(commits));
123 const associated = await Promise.all(
124 commits.map((commit) => githubJson(`/repos/${repository}/commits/${commit.sha}/pulls`, { allowMissing: true })),
125 );
126 for (const pulls of associated) for (const pull of pulls || []) numbers.add(pull.number);
127 const pulls = await Promise.all([...numbers].map((number) => githubJson(`/repos/${repository}/pulls/${number}`, { allowMissing: true })));
128 return Promise.all(pulls.filter(Boolean).map(async (pull) => {
129 const labels = (pull.labels || []).map((label) => label.name);
130 const files = (await githubList(`/repos/${repository}/pulls/${pull.number}/files`)).map((file) => file.filename);
131 return {
132 number: pull.number,
133 title: pull.title,
134 body: String(pull.body || "").slice(0, 2000),
135 author: pull.user?.login || "",
136 labels,
137 changedFileCount: files.length,
138 files: files.slice(0, 200),
139 targetHints: inferPullTargetHints(labels, files),
140 };
141 }));
142 }
143
144 function releaseItems(release) {
145 return [
146 ...(release.highlights || []),
147 ...["new", "improved", "fixed"].flatMap((kind) => release.changes?.[kind] || []),
148 ...(release.upgrade || []),
149 ...(release.risks || []),
150 ];
151 }
152
153 function normalizeReleaseTargets(release) {
154 for (const item of releaseItems(release)) {
155 if (!Array.isArray(item.targets)) continue;
156 item.targets.sort((a, b) => releaseTargetOrder.indexOf(a) - releaseTargetOrder.indexOf(b));
157 }
158 const targets = new Set(releaseItems(release).flatMap((item) => item.targets || []));
159 release.surfaces = [
160 ...releaseTargetOrder.filter((target) => targets.has(target)),
161 ...[...targets].filter((target) => !releaseTargetOrder.includes(target)).sort(),
162 ];
163 }
164
165 function collectDocLinks(from, repository, to) {
166 const linkRef = runGit(["rev-parse", to]);
167 const paths = runGit(["diff", "--name-only", `${from}..${to}`])
168 .split("\n")
169 .filter((path) => /^(?:docs|README)[/\w.-]*\.(?:md|mdx)$/i.test(path));
170 return paths.map((path) => `https://github.com/${repository}/blob/${linkRef}/${path}`);
171 }
172
173 function assertGroundedRefs(value, allowedRefs, path = "release") {
174 if (Array.isArray(value)) {
175 value.forEach((item, index) => assertGroundedRefs(item, allowedRefs, `${path}[${index}]`));
176 return;
177 }
178 if (!value || typeof value !== "object") return;
179 if (Array.isArray(value.refs)) {
180 for (const ref of value.refs) {
181 if (!allowedRefs.has(ref)) throw new Error(`${path}.refs contains PR #${ref}, which is outside the release range`);
182 }
183 }
184 for (const [key, child] of Object.entries(value)) assertGroundedRefs(child, allowedRefs, `${path}.${key}`);
185 }
186
187 function extractJson(content) {
188 if (!content?.trim()) throw new Error("DeepSeek returned empty content");
189 const parsed = JSON.parse(content);
190 return parsed.release || parsed;
191 }
192
193 async function askDeepSeek(payload, attempt = 0) {
194 const key = process.env.DEEPSEEK_API_KEY;
195 if (!key) throw new Error("DEEPSEEK_API_KEY is required");
196 const compact = attempt > 0;
197 const response = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
198 method: "POST",
199 headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
200 body: JSON.stringify({
201 model,
202 // Release notes need deterministic structured output, not hidden chain of
203 // thought. Thinking tokens share the generation budget with content and
204 // can otherwise leave response_format JSON empty or truncated.
205 thinking: { type: "disabled" },
206 temperature: 0,
207 max_tokens: 8000,
208 response_format: { type: "json_object" },
209 messages: [
210 {
211 role: "system",
212 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:
213 {
214 \"version\": \"semver\", \"date\": \"YYYY-MM-DD\", \"channel\": \"stable|prerelease\", \"targetingVersion\": 1,
215 \"title\": {\"en\":\"\",\"zh\":\"\"}, \"summary\": {\"en\":\"\",\"zh\":\"\"},
216 \"surfaces\": [\"desktop\",\"cli\"],
217 \"guides\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"href\":\"https://...\"}],
218 \"highlights\": [{\"kind\":\"new|improved|fixed|security\",\"targets\":[\"desktop\",\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
219 \"changes\": {\"new\":[],\"improved\":[],\"fixed\":[]},
220 \"upgrade\": [{\"level\":\"info|warning\",\"targets\":[\"desktop\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
221 \"risks\": [{\"targets\":[\"cli\"],\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
222 \"contributors\": [], \"links\": {\"github\":\"https://...\",\"compare\":\"https://...\",\"download\":\"https://...\"}
223 }
224 Every highlight, change, upgrade note, and risk must have a non-empty \"targets\" array using only this canonical order: desktop, cli, site, service. Use each PR's targetHints as deterministic candidates, then choose the user-visible delivery target supported by its labels, changed files, title, and body. Shared product-core behavior belongs to both desktop and cli. Website-only work belongs to site; hosted workers and release infrastructure belong to service and must not be marked as a client update merely because a client file accompanies the server change. Set top-level surfaces to the canonical union of all item targets. Return guides only for supplied documentation URLs. Mention upgrade action or risk only when explicitly supported; otherwise use empty arrays. ${editorialLimitInstruction(compact)} Output JSON only.`,
225 },
226 {
227 role: "user",
228 content: `${compact ? "The previous response was invalid or truncated. Produce a smaller complete record and obey the compact editorial budget.\n" : ""}Create the release record from these public GitHub sources:\n${JSON.stringify(payload)}`,
229 },
230 ],
231 }),
232 });
233 if (!response.ok) throw new Error(`DeepSeek API failed: ${response.status} ${await response.text()}`);
234 const data = await response.json();
235 const choice = data.choices?.[0];
236 if (choice?.finish_reason === "length") {
237 if (compact) throw new Error("DeepSeek response exceeded the compact editorial budget (finish_reason=length)");
238 return askDeepSeek(payload, attempt + 1);
239 }
240 try {
241 return extractJson(choice?.message?.content);
242 } catch (error) {
243 if (compact) {
244 throw new Error(`${error.message} (finish_reason=${choice?.finish_reason || "unknown"})`);
245 }
246 return askDeepSeek(payload, attempt + 1);
247 }
248 }
249
250 async function main() {
251 const args = parseArgs(process.argv.slice(2));
252 if (!args.version) throw new Error("--version is required");
253 const version = normalizeVersion(args.version);
254 const catalog = await loadCatalog();
255 const channel = version.includes("-") ? "prerelease" : "stable";
256 const baseVersion = version.split("-")[0];
257 const previousRecord = channel === "stable"
258 ? catalog.releases.find((release) => release.version !== version && release.channel === "stable")
259 : catalog.releases.find(
260 (release) =>
261 release.version !== version &&
262 release.channel === "prerelease" &&
263 release.baseVersion === baseVersion,
264 ) || catalog.releases.find((release) => release.channel === "stable");
265 const previous = args.from || previousRecord?.version;
266 if (!previous) throw new Error("--from is required when no previous release exists");
267 const previousVersion = normalizeVersion(previous);
268 const previousIsPreview = previousVersion.includes("-");
269 const from = previous.match(/^(?:desktop-|npm-)?v/)
270 ? previous
271 : previousIsPreview
272 ? `v${previousVersion}`
273 : `desktop-v${previousVersion}`;
274 const to = args.to || "HEAD";
275 const repository = repositoryName();
276 const commits = commitRange(from, to);
277 if (!commits.length) throw new Error(`no commits found in ${from}..${to}`);
278 const pulls = await collectPullRequests(repository, commits);
279 if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`);
280 const docLinks = collectDocLinks(from, repository, to);
281 const date = args.date || new Date().toISOString().slice(0, 10);
282 const tag = args.tag || (channel === "prerelease" ? `v${version}` : `desktop-v${version}`);
283 const source = {
284 version,
285 date,
286 channel,
287 range: `${from}..${to}`,
288 pullRequests: pulls,
289 documentationUrls: docLinks,
290 };
291 const release = await askDeepSeek(source);
292 release.targetingVersion = 1;
293 release.version = version;
294 release.releaseId = version;
295 release.baseVersion = baseVersion;
296 release.date = date;
297 release.channel = source.channel;
298 release.status = "reviewed";
299 release.previousRelease = previousVersion;
300 const previewOrdinal = version.match(/-preview\.([1-9][0-9]*)$/)?.[1];
301 if (channel === "prerelease") {
302 if (!previewOrdinal) throw new Error("Preview release version must use MAJOR.MINOR.PATCH-preview.N");
303 release.builds = {
304 cli: `v${version}`,
305 desktop: `v${baseVersion}-preview.${previewOrdinal}`,
306 npm: `${baseVersion}-canary.${previewOrdinal}`,
307 };
308 } else {
309 release.builds = {
310 cli: `v${version}`,
311 desktop: `v${version}`,
312 npm: version,
313 };
314 }
315 release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))];
316 release.links = {
317 github: `https://github.com/${repository}/releases/tag/${tag}`,
318 compare: `https://github.com/${repository}/compare/${from}...${tag}`,
319 download: channel === "prerelease"
320 ? "https://reasonix.io/?download=desktop&channel=preview#start"
321 : "https://reasonix.io/?download=desktop&channel=stable#start",
322 };
323 release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href));
324 normalizeReleaseTargets(release);
325 assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number)));
326 validateCatalog({ schemaVersion: 1, releases: [release] });
327 await upsertRelease(release);
328 console.log(`Generated bilingual release notes for v${version} from ${pulls.length} pull request(s).`);
329 }
330
331 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
332 main().catch((error) => {
333 console.error(error.message);
334 process.exitCode = 1;
335 });
336 }
337
337 lines Plain Text