Open History to see the last commit
1const fs = require("node:fs");
2
3const HOUR_MS = 60 * 60 * 1000;
4const OPEN_LIMIT = 3;
5const CONFLICT_IDLE_MS = 7 * 24 * HOUR_MS;
6const NOTICE_MS = 72 * HOUR_MS;
7// A first run meets the whole backlog. These keep one run from flooding watchers or GitHub's
8// limit on how fast a token may comment.
9const MAX_REQUESTS_PER_RUN = 100;
10const MAX_CLOSES_PER_RUN = 50;
11// A last look that ends in "not today" closes nothing, so the looks need a cap of their own.
12const MAX_RECHECKS_PER_RUN = 150;
13const WRITE_PAUSE_MS = 1_000;
14// Measured here: about two and a half minutes until every open PR has an answer.
15const MERGEABLE_WAIT_MS = 150_000;
16const READ_ATTEMPTS = 4;
17const READ_RETRY_MS = 5_000;
18// GitHub stamps a label event and the PR's updatedAt a second or two apart.
19const SAME_MOMENT_MS = 15_000;
20
21const REBASE_LABEL = "needs-rebase";
22const CLOSING_LABEL = "triage:closing";
23const KEEP_OPEN_LABEL = "triage:keep-open";
24const UNVOUCHED_LABEL = "vouch:unvouched";
25const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]);
26const EXEMPTIONS_PATH = ".github/TRIAGE_EXEMPTIONS.td";
27const VOUCHED_PATH = ".github/VOUCHED.td";
28const LOGIN_PATTERN = /^github:([A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38})$/;
29
30// `labels` matches a PR that carries any one of them.
31const SWEEP_QUERY = `
32 query ($owner: String!, $repo: String!, $labels: [String!], $cursor: String) {
33 repository(owner: $owner, name: $repo) {
34 pullRequests(states: OPEN, labels: $labels, first: 50, after: $cursor) {
35 pageInfo { hasNextPage endCursor }
36 nodes {
37 number
38 mergeable
39 baseRefName
40 updatedAt
41 author { login }
42 labels(first: 100) { nodes { name } }
43 timelineItems(itemTypes: [LABELED_EVENT], last: 20) {
44 nodes { ... on LabeledEvent { createdAt label { name } } }
45 }
46 }
47 }
48 }
49 }`;
50
51const RECHECK_QUERY = `
52 query ($owner: String!, $repo: String!, $number: Int!) {
53 repository(owner: $owner, name: $repo) {
54 pullRequest(number: $number) {
55 state
56 mergeable
57 updatedAt
58 labels(first: 100) { nodes { name } }
59 }
60 }
61 }`;
62
63const AUTHOR_OPEN_PRS_QUERY = `
64 query ($q: String!) {
65 search(query: $q, type: ISSUE, first: 50) {
66 nodes { ... on PullRequest { number } }
67 }
68 }`;
69
70const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
71
72// GitHub answers a heavy list request with a 502 now and then, and one of those must not end the
73// run. Only reads go through here: repeating a read is safe, repeating a comment is not.
74async function read(github, query, variables) {
75 for (let attempt = 1; ; attempt += 1) {
76 try {
77 return await github.graphql(query, variables);
78 } catch (error) {
79 const transient = typeof error.status === "number" && error.status >= 500;
80 if (attempt === READ_ATTEMPTS || !transient) throw error;
81 await pause(attempt * READ_RETRY_MS);
82 }
83 }
84}
85
86// Fails closed: a missing, empty, or malformed list must never read as "nobody is exempt".
87function parseExemptions(text) {
88 const logins = new Set();
89 for (const raw of text.split("\n")) {
90 const line = raw.trim();
91 if (line === "" || line.startsWith("#")) continue;
92 const match = LOGIN_PATTERN.exec(line);
93 if (!match) throw new Error(`${EXEMPTIONS_PATH}: malformed entry "${line}"`);
94 const login = match[1].toLowerCase();
95 if (logins.has(login)) throw new Error(`${EXEMPTIONS_PATH}: duplicate entry "${line}"`);
96 logins.add(login);
97 }
98 if (logins.size === 0) throw new Error(`${EXEMPTIONS_PATH}: no entries`);
99 return logins;
100}
101
102// VOUCHED.td: one `github:login` per line, `#` comments, and `-github:login reason` to denounce.
103function vouchedLogins(text) {
104 const logins = new Set();
105 for (const raw of text.split("\n")) {
106 const entry = raw.trim().split(/\s+/)[0];
107 if (entry.startsWith("github:")) logins.add(entry.slice("github:".length).toLowerCase());
108 }
109 if (logins.size === 0) throw new Error(`${VOUCHED_PATH}: no entries`);
110 return logins;
111}
112
113function labeledAt(pr, name) {
114 const last = pr.timelineItems.nodes.filter((event) => event.label?.name === name).at(-1);
115 return last ? Date.parse(last.createdAt) : undefined;
116}
117
118// The whole policy for the two windows. Activity is anything that moves the PR's updatedAt: a
119// push, a comment, a review, a label. Nobody can set that time, so nothing here trusts the author.
120function decide(pr, exemptions, now) {
121 const labels = new Set(pr.labels.nodes.map((label) => label.name));
122 if (labels.has(KEEP_OPEN_LABEL)) return "skip";
123 const updatedAt = Date.parse(pr.updatedAt);
124
125 if (labels.has(CLOSING_LABEL)) {
126 const since = labeledAt(pr, CLOSING_LABEL);
127 if (since === undefined) return "unknown-notice";
128 // Any activity after the notice sends the PR back to triage.
129 if (updatedAt - since > SAME_MOMENT_MS) return "clear-notice";
130 return now - since >= NOTICE_MS ? "close-notice" : "skip";
131 }
132
133 const exempt = exemptions.has((pr.author?.login ?? "").toLowerCase());
134 const subject = labels.has(UNVOUCHED_LABEL) && !exempt;
135 if (labels.has(REBASE_LABEL)) {
136 if (!subject || pr.mergeable === "MERGEABLE") return "clear-rebase";
137 // Activity restarts the seven days and keeps the label, so a PR is asked to rebase once.
138 const idle = now - updatedAt >= CONFLICT_IDLE_MS;
139 return idle && pr.mergeable === "CONFLICTING" ? "close-conflict" : "skip";
140 }
141 return subject && pr.mergeable === "CONFLICTING" ? "request-rebase" : "skip";
142}
143
144function guideLink(context, anchor) {
145 return `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#${anchor}`;
146}
147
148async function removeLabel(github, context, number, name) {
149 try {
150 await github.rest.issues.removeLabel({ ...context.repo, issue_number: number, name });
151 } catch (error) {
152 if (error.status !== 404) throw error;
153 }
154}
155
156async function closeWithComment(github, context, number, body) {
157 await github.rest.issues.createComment({ ...context.repo, issue_number: number, body });
158 await github.rest.pulls.update({ ...context.repo, pull_number: number, state: "closed" });
159}
160
161async function sweep({ github, context, core, dryRun }) {
162 const exemptions = parseExemptions(fs.readFileSync(EXEMPTIONS_PATH, "utf8"));
163 const repo = { owner: context.repo.owner, repo: context.repo.repo };
164
165 const list = async () => {
166 const prs = [];
167 for (let cursor = null; ;) {
168 const page = (
169 await read(github, SWEEP_QUERY, {
170 ...repo,
171 cursor,
172 labels: [UNVOUCHED_LABEL, REBASE_LABEL, CLOSING_LABEL],
173 })
174 ).repository.pullRequests;
175 prs.push(...page.nodes);
176 if (!page.pageInfo.hasNextPage) return prs;
177 cursor = page.pageInfo.endCursor;
178 }
179 };
180 // GitHub computes mergeability lazily and forgets it whenever the base moves. Asking starts the
181 // computation, so ask once more. A PR that is still unknown waits for tomorrow's run.
182 let prs = await list();
183 if (prs.some((pr) => pr.mergeable === "UNKNOWN")) {
184 await pause(MERGEABLE_WAIT_MS);
185 prs = await list();
186 }
187
188 const now = Date.now();
189 // Start further down the list each day. The step is no larger than a run's cap, so a cap that
190 // is reached cannot leave the same PRs waiting behind the same others every day.
191 const start = (Math.floor(now / (24 * HOUR_MS)) * MAX_RECHECKS_PER_RUN) % (prs.length || 1);
192 prs = [...prs.slice(start), ...prs.slice(0, start)];
193 const count = {};
194 let rechecks = 0;
195 for (const pr of prs) {
196 const { number } = pr;
197 let action = decide(pr, exemptions, now);
198 if (action === "skip") continue;
199 if (action === "unknown-notice") {
200 core.warning(`#${number}: cannot tell when ${CLOSING_LABEL} was applied, leaving it open.`);
201 continue;
202 }
203 if (action === "request-rebase" && (count[action] ?? 0) >= MAX_REQUESTS_PER_RUN) continue;
204 if (action.startsWith("close")) {
205 if ((count["close-conflict"] ?? 0) + (count["close-notice"] ?? 0) >= MAX_CLOSES_PER_RUN) {
206 continue;
207 }
208 if (rechecks >= MAX_RECHECKS_PER_RUN) continue;
209 rechecks += 1;
210 // The list is minutes old by now. Closing is the one step worth a second look.
211 const fresh = (await read(github, RECHECK_QUERY, { ...repo, number })).repository.pullRequest;
212 if (fresh.state !== "OPEN") continue;
213 // A merge to the base can resolve a conflict without touching the PR, so this is re-read
214 // too. An answer that is not ready yet reads as "not conflicting" and waits for tomorrow.
215 action = decide({ ...pr, ...fresh }, exemptions, now);
216 if (!action.startsWith("close")) continue;
217 }
218
219 count[action] = (count[action] ?? 0) + 1;
220 core.info(`#${number} ${action}${dryRun ? " (dry run)" : ""}`);
221 if (dryRun) continue;
222
223 if (action === "request-rebase") {
224 // The request goes first: a label without it would start a clock nobody was told about.
225 await github.rest.issues.createComment({
226 ...context.repo,
227 issue_number: number,
228 body: [
229 `This PR conflicts with \`${pr.baseRefName}\`. Rebase it or merge \`${pr.baseRefName}\` into it. If it still conflicts after 7 days with no activity, it will be closed automatically.`,
230 "",
231 `See the [conflict window](${guideLink(context, "conflict-window")}).`,
232 ].join("\n"),
233 });
234 await github.rest.issues.addLabels({
235 ...context.repo,
236 issue_number: number,
237 labels: [REBASE_LABEL],
238 });
239 } else if (action === "clear-rebase") {
240 await removeLabel(github, context, number, REBASE_LABEL);
241 } else if (action === "clear-notice") {
242 await removeLabel(github, context, number, CLOSING_LABEL);
243 } else {
244 const conflict = action === "close-conflict";
245 await closeWithComment(
246 github,
247 context,
248 number,
249 conflict
250 ? [
251 `Closing: this PR still conflicts with \`${pr.baseRefName}\` and has had no activity for 7 days since the rebase request.`,
252 "",
253 `To continue, rebase the branch and open a new PR that links this one. See the [conflict window](${guideLink(context, "conflict-window")}).`,
254 ].join("\n")
255 : [
256 "Closing: 72 hours have passed since the triage notice above with no activity on this PR.",
257 "",
258 `To ask for reconsideration, address the notice and comment here, or open a focused replacement PR that links this one. See [closure and reconsideration](${guideLink(context, "closure-and-reconsideration")}).`,
259 ].join("\n"),
260 );
261 // Without this a maintainer's reopen would be closed again by the next run.
262 await removeLabel(github, context, number, conflict ? REBASE_LABEL : CLOSING_LABEL);
263 }
264 await pause(WRITE_PAUSE_MS);
265 }
266
267 const summary = Object.entries(count).map(([action, n]) => `${n} ${action}`);
268 core.info(
269 `${dryRun ? "Dry run. " : ""}${prs.length} PRs read: ${summary.join(", ") || "nothing to do"}.`,
270 );
271 return count;
272}
273
274async function enforceOpenLimit({ github, context, core, dryRun }) {
275 const pr = context.payload.pull_request;
276 const author = pr.user.login;
277 // Who is trusted is decided here rather than by the vouch action, which reports its own failed
278 // lookups as "not vouched". Every read below throws on failure, and a throw closes nothing.
279 if (pr.user.type === "Bot") return;
280 if (parseExemptions(fs.readFileSync(EXEMPTIONS_PATH, "utf8")).has(author.toLowerCase())) return;
281 if (vouchedLogins(fs.readFileSync(VOUCHED_PATH, "utf8")).has(author.toLowerCase())) return;
282 const { data: access } = await github.rest.repos.getCollaboratorPermissionLevel({
283 ...context.repo,
284 username: author,
285 });
286 if (WRITE_PERMISSIONS.has(access.permission)) return;
287 if (pr.labels.some((label) => label.name === KEEP_OPEN_LABEL)) return;
288 // Someone else reopening a PR is a maintainer overriding the limit.
289 const reopened = context.payload.action === "reopened";
290 if (reopened && context.payload.sender.login.toLowerCase() !== author.toLowerCase()) return;
291
292 const q = `repo:${context.repo.owner}/${context.repo.repo} is:pr is:open author:${author} sort:created-asc`;
293 const { search } = await read(github, AUTHOR_OPEN_PRS_QUERY, { q });
294 // A new PR counts only the older ones, so two opened together cannot close each other.
295 const others = search.nodes.filter(
296 (node) => node.number && (reopened ? node.number !== pr.number : node.number < pr.number),
297 ).length;
298 if (others < OPEN_LIMIT) {
299 core.info(`PR #${pr.number}: ${author} has ${others} other open PRs.`);
300 return;
301 }
302 if (dryRun) {
303 core.notice(`Dry run. PR #${pr.number} would close: ${author} has ${others} other open PRs.`);
304 return;
305 }
306
307 await closeWithComment(
308 github,
309 context,
310 pr.number,
311 [
312 `Closing: ${author} already has ${OPEN_LIMIT} or more open PRs here, which is the limit for contributors who are not vouched.`,
313 "",
314 `Once one of them is merged or closed, open this again. See the [open PR limit](${guideLink(context, "open-pr-limit")}).`,
315 ].join("\n"),
316 );
317 core.info(`PR #${pr.number}: closed, ${author} has ${others} other open PRs.`);
318}
319
320module.exports = { decide, enforceOpenLimit, parseExemptions, sweep };