返回 DeepSeek-Reasonix
stale-report-sweep.mjs
根目录 / scripts / stale-report-sweep.mjs
1 #!/usr/bin/env node
2 // Ask, wait, then close — for bug reports filed against a version far enough
3 // back that nobody can tell whether they still reproduce.
4 //
5 // Age alone is never the reason. The close only ever follows an unanswered
6 // question, the window is long, and the reason is "not planned" with reopening
7 // invited, so a wrong guess costs the reporter one click.
8 //
9 // Usage:
10 // GH_TOKEN=... node scripts/stale-report-sweep.mjs [--dry-run] [--limit N]
11
12 import { execFileSync } from "node:child_process";
13 import { fileURLToPath } from "node:url";
14 import { resolve } from "node:path";
15
16 // A report this far back predates rewrites large enough that its symptom cannot
17 // be assumed to survive; anything newer is still close enough to reason about.
18 export const CUTOFF_MINOR = 10;
19 // Consequences too expensive to guess at. These stay for a human.
20 export const SEVERITY_LABELS = ["data-loss", "security", "crash"];
21 // A maintainer reply means the report was already judged; a bot must not
22 // overrule that by timing it out.
23 export const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
24 // Long enough to see a notification, install a release and actually re-test.
25 export const WINDOW_DAYS = 21;
26 export const ASK_MARKER = "<!-- stale-report-sweep:ask -->";
27
28 export function parseReportedVersion(body) {
29 const section = (body || "").split(/^###\s+/m).find((s) => /^Exact version/i.test(s));
30 const m = /(\d+)\.(\d+)\.(\d+)/.exec(section || "");
31 return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), raw: m[0] } : null;
32 }
33
34 // "1.7.18" parses cleanly and 1.7.0 shipped, so a dropped digit in "1.17.18"
35 // reads as a release eleven minors old instead of the current one. Only a
36 // version that was actually published counts as evidence of age.
37 export function isStaleVersion(version, cutoffMinor = CUTOFF_MINOR, released = null) {
38 if (!version) return false;
39 if (released && !released.has(`${version.major}.${version.minor}.${version.patch}`)) return false;
40 return version.major < 1 || (version.major === 1 && version.minor < cutoffMinor);
41 }
42
43 // The form's version field is whatever the reporter typed there, and they get
44 // it wrong in both directions: a dropped digit, or the default left in place
45 // while the real build is named further down the body. A report cannot be older
46 // than the newest release it mentions anywhere, so that is what age is measured
47 // from. Filtering to published versions keeps Node versions and timestamps out.
48 export function highestMentionedVersion(body, released) {
49 let best = null;
50 for (const m of (body || "").matchAll(/(\d+)\.(\d+)\.(\d+)/g)) {
51 const v = { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), raw: m[0] };
52 if (released && !released.has(`${v.major}.${v.minor}.${v.patch}`)) continue;
53 if (!best || v.major > best.major || (v.major === best.major && (v.minor > best.minor || (v.minor === best.minor && v.patch > best.patch)))) {
54 best = v;
55 }
56 }
57 return best;
58 }
59
60 export function releasedVersions(tags) {
61 const out = new Set();
62 for (const tag of tags) {
63 const m = /(\d+)\.(\d+)\.(\d+)$/.exec(tag);
64 if (m) out.add(`${Number(m[1])}.${Number(m[2])}.${Number(m[3])}`);
65 }
66 return out;
67 }
68
69 export function hasMaintainerReply(comments = []) {
70 return comments.some((c) => MAINTAINER_ASSOCIATIONS.has(c.authorAssociation));
71 }
72
73 export function findAsk(comments = []) {
74 return comments.find((c) => (c.body || "").includes(ASK_MARKER)) || null;
75 }
76
77 export function shouldAsk(issue, { cutoffMinor = CUTOFF_MINOR, released = null } = {}) {
78 // "does it still reproduce" is a question only a defect report can answer; a
79 // feature request does not go stale because the version moved on.
80 if (!(issue.labels || []).includes("bug")) return false;
81 if (!isStaleVersion(issue.version, cutoffMinor, released)) return false;
82 if ((issue.labels || []).some((l) => SEVERITY_LABELS.includes(l))) return false;
83 if (hasMaintainerReply(issue.comments)) return false;
84 return !findAsk(issue.comments);
85 }
86
87 // Only an ask that nobody answered may expire. Any later comment — from the
88 // reporter, a bystander or a maintainer — takes the issue back out of the sweep.
89 export function shouldClose(issue, { now, windowDays = WINDOW_DAYS } = {}) {
90 const ask = findAsk(issue.comments);
91 if (!ask) return false;
92 if ((issue.labels || []).some((l) => SEVERITY_LABELS.includes(l))) return false;
93 const askedAt = Date.parse(ask.createdAt);
94 if (Number.isNaN(askedAt)) return false;
95 const answered = (issue.comments || []).some((c) => Date.parse(c.createdAt) > askedAt);
96 if (answered) return false;
97 return Date.parse(now) - askedAt >= windowDays * 86400000;
98 }
99
100 export function renderAsk({ version, current, windowDays = WINDOW_DAYS }) {
101 return [
102 ASK_MARKER,
103 `This was reported against **${version}**, and the current release is **${current}** — far enough apart that we cannot tell from here whether it still happens.`,
104 "",
105 "Does it still reproduce on the current release?",
106 "",
107 `If we hear nothing in ${windowDays} days this gets closed as stale, which is a bookkeeping state and not a judgement about the report. Reopening takes one click, and a reply at any point — including after it closes — brings it back.`,
108 ].join("\n");
109 }
110
111 export function renderClose({ windowDays = WINDOW_DAYS }) {
112 return [
113 `Closing as stale — the verification question above went ${windowDays} days without an answer.`,
114 "",
115 "This is not a decision that the report was invalid. It means nobody can confirm the symptom against a current build, and leaving it open makes the tracker less useful for the reports that can be acted on.",
116 "",
117 "If you hit this again, comment here and it reopens — no need to file a new one.",
118 ].join("\n");
119 }
120
121 function gh(args, { json = true } = {}) {
122 const out = execFileSync("gh", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
123 return json ? JSON.parse(out) : out;
124 }
125
126 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
127
128 async function main() {
129 const argv = process.argv.slice(2);
130 const dryRun = argv.includes("--dry-run");
131 const li = argv.indexOf("--limit");
132 const limit = li >= 0 ? Number(argv[li + 1]) : 40;
133 const now = new Date().toISOString();
134
135 const repo = gh(["repo", "view", "--json", "nameWithOwner"]).nameWithOwner;
136 const current = gh(["release", "list", "--limit", "40", "--json", "tagName"])
137 .map((r) => r.tagName)
138 .find((t) => t.startsWith("desktop-v")) || "the latest release";
139
140 const released = releasedVersions(gh(["api", `repos/${repo}/tags`, "--paginate"]).map((t) => t.name));
141 const issues = gh([
142 "issue", "list", "--state", "open", "--limit", "1200",
143 "--json", "number,body,labels,comments",
144 ]).map((i) => ({
145 number: i.number,
146 version: highestMentionedVersion(i.body, released),
147 labels: (i.labels || []).map((l) => l.name),
148 comments: (i.comments || []).map((c) => ({
149 body: c.body,
150 createdAt: c.createdAt,
151 authorAssociation: c.authorAssociation,
152 })),
153 }));
154
155 const toClose = issues.filter((i) => shouldClose(i, { now })).slice(0, limit);
156 const toAsk = issues.filter((i) => shouldAsk(i, { released })).slice(0, limit);
157 console.log(`${issues.length} open; ${toAsk.length} to ask (cap ${limit}), ${toClose.length} to close`);
158
159 for (const issue of toClose) {
160 if (dryRun) {
161 console.log(`[dry-run] close #${issue.number}`);
162 continue;
163 }
164 gh(["issue", "comment", String(issue.number), "--body", renderClose({})], { json: false });
165 gh(["issue", "close", String(issue.number), "--reason", "not planned"], { json: false });
166 console.log(`closed #${issue.number}`);
167 await sleep(3000);
168 }
169
170 for (const issue of toAsk) {
171 const body = renderAsk({ version: issue.version.raw, current });
172 if (dryRun) {
173 console.log(`[dry-run] ask #${issue.number} (reported ${issue.version.raw})`);
174 continue;
175 }
176 gh(["issue", "comment", String(issue.number), "--body", body], { json: false });
177 console.log(`asked #${issue.number}`);
178 await sleep(3000);
179 }
180 }
181
182 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
183 await main();
184 }
185
185 lines Plain Text