返回 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
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
245 lines Plain Text