| 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 |