返回 DeepSeek-Reasonix
release-verify-issues.mjs
根目录 / scripts / release-verify-issues.mjs
1 #!/usr/bin/env node
2 // Ask reporters to verify, on the release that actually carries their fix.
3 // A PR that writes "Fixes #N" auto-closes its issue on merge; one that writes
4 // "Refs"/"Related"/"Addresses" does not, so the report sits open until someone
5 // remembers to come back after the release. Nobody does. This posts that
6 // follow-up for every still-open issue referenced by a PR in the release.
7 //
8 // It never closes anything — deciding a report is resolved stays human.
9 //
10 // Usage:
11 // GH_TOKEN=... node scripts/release-verify-issues.mjs --tag desktop-v1.18.0 [--dry-run]
12
13 import { execFileSync } from "node:child_process";
14 import { fileURLToPath } from "node:url";
15 import { resolve } from "node:path";
16
17 // A bare "#123" is ours. "owner/repo#123" is upstream — matching those once
18 // filed a Wails issue number as if it were one of ours.
19 const ISSUE_REF = /(^|[^\w/#-])#(\d{1,6})\b/g;
20 const MERGE_SUBJECT = /Merge pull request #(\d+)/g;
21 const SQUASH_SUBJECT = /\(#(\d+)\)\s*$/gm;
22
23 export function parseIssueRefs(text) {
24 const out = new Set();
25 for (const [, , n] of (text || "").matchAll(ISSUE_REF)) out.add(Number(n));
26 return out;
27 }
28
29 export function parsePullRequestNumbers(gitLog) {
30 const out = new Set();
31 for (const [, n] of (gitLog || "").matchAll(MERGE_SUBJECT)) out.add(Number(n));
32 for (const [, n] of (gitLog || "").matchAll(SQUASH_SUBJECT)) out.add(Number(n));
33 return [...out].sort((a, b) => a - b);
34 }
35
36 // Releases share a repo but not a series: desktop-v1.18.0 follows
37 // desktop-v1.17.21, not npm-v1.18.0.
38 export function tagSeries(tag) {
39 const m = /^(.*?)v\d/.exec(tag);
40 return m ? m[1] : "";
41 }
42
43 export function previousTag(tags, current) {
44 const series = tagSeries(current);
45 const peers = tags.filter((t) => tagSeries(t) === series);
46 const i = peers.indexOf(current);
47 return i >= 0 && i + 1 < peers.length ? peers[i + 1] : null;
48 }
49
50 export function verificationMarker(tag) {
51 return `<!-- release-verify:${tag} -->`;
52 }
53
54 export function renderComment({ tag, pullNumbers }) {
55 const prs = pullNumbers.map((n) => `#${n}`).join(", ");
56 const source = pullNumbers.length === 1 ? `${prs} is` : `${prs} are`;
57 return [
58 verificationMarker(tag),
59 `A change referencing this report shipped in **\`${tag}\`** — ${source} in that release.`,
60 "",
61 "The PR referenced this issue without claiming to close it, so this is a request to verify rather than a fix announcement: the change may resolve what you reported, address only part of it, or turn out to be unrelated.",
62 "",
63 `Could you re-test on \`${tag}\` and reply either way? "Still happening" is as useful as "fixed" — an unverified fix is why this thread stayed open.`,
64 "",
65 "No action needed if you have moved on; this stays open until someone says otherwise.",
66 ].join("\n");
67 }
68
69 // GitHub's issues API returns pull requests too, so a reference to a sibling PR
70 // looks like a perfectly good open issue until you check for this key.
71 export function isNotifiable(record, tag) {
72 if (!record || record.isPullRequest) return false;
73 if (record.state !== "open") return false;
74 return !(record.commentBodies || []).some((body) => (body || "").includes(verificationMarker(tag)));
75 }
76
77 export function selectTargets({ refsByIssue, records, tag }) {
78 return [...refsByIssue.entries()]
79 .filter(([issue]) => isNotifiable(records.get(issue), tag))
80 .map(([issue, pulls]) => ({ issue, pullNumbers: [...pulls].sort((a, b) => a - b) }))
81 .sort((a, b) => a.issue - b.issue);
82 }
83
84 function gh(args, { json = true } = {}) {
85 const out = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
86 return json ? JSON.parse(out) : out;
87 }
88
89 function git(args) {
90 return execFileSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
91 }
92
93 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
94
95 async function main() {
96 const argv = process.argv.slice(2);
97 const dryRun = argv.includes("--dry-run");
98 const tag = argv[argv.indexOf("--tag") + 1];
99 if (!tag || tag.startsWith("--")) {
100 console.error("usage: release-verify-issues.mjs --tag <release-tag> [--dry-run]");
101 process.exit(1);
102 }
103
104 const tags = git(["tag", "--list", "--sort=-v:refname"]).split("\n").filter(Boolean);
105 const from = previousTag(tags, tag);
106 if (!from) {
107 console.log(`no previous tag in the ${tagSeries(tag) || "root"} series; nothing to compare`);
108 return;
109 }
110
111 const pulls = parsePullRequestNumbers(git(["log", `${from}..${tag}`, "--pretty=%s%n%b"]));
112 console.log(`${from}..${tag}: ${pulls.length} pull request(s)`);
113
114 const refsByIssue = new Map();
115 for (const pr of pulls) {
116 let data;
117 try {
118 data = gh(["pr", "view", String(pr), "--json", "title,body"]);
119 } catch {
120 continue; // a "#N" that is not a PR in this repo
121 }
122 for (const issue of parseIssueRefs(`${data.title}\n${data.body || ""}`)) {
123 if (issue === pr) continue;
124 if (!refsByIssue.has(issue)) refsByIssue.set(issue, new Set());
125 refsByIssue.get(issue).add(pr);
126 }
127 }
128
129 const repo = gh(["repo", "view", "--json", "nameWithOwner"]).nameWithOwner;
130 const records = new Map();
131 for (const issue of refsByIssue.keys()) {
132 let head;
133 try {
134 head = gh(["api", `repos/${repo}/issues/${issue}`]);
135 } catch {
136 continue; // referenced number does not exist in this repo
137 }
138 const record = { state: head.state, isPullRequest: Boolean(head.pull_request), commentBodies: [] };
139 if (isNotifiable(record, tag)) {
140 const comments = gh(["api", `repos/${repo}/issues/${issue}/comments`, "--paginate"]);
141 record.commentBodies = comments.map((c) => c.body || "");
142 }
143 records.set(issue, record);
144 }
145
146 const targets = selectTargets({ refsByIssue, records, tag });
147 const skippedPulls = [...records.values()].filter((r) => r.isPullRequest).length;
148 console.log(`${refsByIssue.size} referenced (${skippedPulls} were pull requests), ${targets.length} to notify`);
149
150 for (const { issue, pullNumbers } of targets) {
151 const body = renderComment({ tag, pullNumbers });
152 if (dryRun) {
153 console.log(`[dry-run] #${issue} <- ${pullNumbers.map((n) => `#${n}`).join(", ")}`);
154 continue;
155 }
156 gh(["issue", "comment", String(issue), "--body", body], { json: false });
157 console.log(`commented on #${issue}`);
158 await sleep(3000); // stay under GitHub's secondary content-creation limit
159 }
160 }
161
162 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
163 await main();
164 }
165
165 lines Plain Text