Skip to content

Commit f4ce436

Browse files
Yuruzuuclaude
andcommitted
Merge remote-tracking branch 'upstream/main' into HEAD
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2 parents 912c7ef + 7462f23 commit f4ce436

895 files changed

Lines changed: 49160 additions & 8821 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/contribution-triage/SKILL.md‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ trusted policy and reassess changed submission evidence; neither example grants
111111

112112
Do not automatically close a PR if a maintainer in the trusted `TRIAGE_EXEMPTIONS.td` list has
113113
commented or submitted a review, including on earlier heads, or if `triage:keep-open` is present.
114+
Comments that triage itself posted, such as an earlier notice or question, are not maintainer
115+
involvement, even when they were posted from a maintainer's account on that maintainer's behalf.
114116
Leave protected PRs open for maintainer decision. This does not grant an author exemption,
115117
eligibility, or review approval.
116118

@@ -126,7 +128,17 @@ comment text but do not write to GitHub. Keep the eligibility finding separate f
126128
This is a routing exception, not a claim that the PR passed eligibility or correctness review.
127129
- **Closure warranted.** The assessment is complete and establishes a specific policy violation.
128130
Prepare a clear explanation under [closure and reconsideration](../../../CONTRIBUTING.md#closure-and-reconsideration),
129-
post it, and close the PR. Verify that the explanation is present and the PR is closed. The comment
131+
post it, then read the repository variable `PR_LIMITS_ENFORCE`
132+
(`gh variable get PR_LIMITS_ENFORCE -R pingdotgg/t3code`). When it prints `true`, apply
133+
`triage:closing` as the last thing you do to the PR, verify that the explanation and the label
134+
are present, and do not close the PR yourself:
135+
[pr-limits.yml](../../../.github/workflows/pr-limits.yml) closes it 72 hours after the label. Any
136+
activity on the PR after the label, including a further comment or label from you, removes the
137+
label; treat an author's reply or push as new evidence, reassess, and apply the label again only
138+
if the finding still holds. When the command prints any other value, or fails saying the variable
139+
was not found, the variable is off and nothing would close a labeled PR: close it yourself and
140+
verify that the explanation is present and the PR is closed. Only another failure, such as a
141+
permission error, means the variable cannot be read; then report the closure as incomplete. The comment
130142
must name the violated rule, cite supporting submission evidence, link the maintained guide section,
131143
and give a concrete remedy. Missing contributor evidence can warrant closure; explain what must be
132144
established. Missing product approval calls for maintainer discussion, not an agent's product decision.
@@ -173,9 +185,10 @@ description, evidence, approvals, existing triage comments, PR open/closed state
173185
label when relevant. Reassess changes that could invalidate the finding. Reuse an existing explanation
174186
only if it still matches the current finding; avoid duplicate comments, closures, or label applications.
175187

176-
For closure, establish that the required explanation is posted before closing. If posting fails or its
177-
result is ambiguous, read back the comments before retrying or proceeding. If the comment succeeds but
178-
closure fails, preserve the comment and retry only the unfinished closure after checking current state.
188+
For closure, establish that the required explanation is posted before applying `triage:closing` or
189+
closing. If posting fails or its result is ambiguous, read back the comments before retrying or
190+
proceeding. If the comment succeeds but the label or the closure fails, preserve the comment and retry
191+
only that unfinished step after checking current state.
179192
Handle a failed or ambiguous label application the same way: inspect labels before any retry. If access
180193
or state still cannot be verified, report an incomplete action and the step needed to resume. Do not
181194
retry blindly or claim success for an unverified action.

‎.github/scripts/pr-limits.cjs‎

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
const fs = require("node:fs");
2+
3+
const HOUR_MS = 60 * 60 * 1000;
4+
const OPEN_LIMIT = 3;
5+
const CONFLICT_IDLE_MS = 7 * 24 * HOUR_MS;
6+
const 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.
9+
const MAX_REQUESTS_PER_RUN = 100;
10+
const 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.
12+
const MAX_RECHECKS_PER_RUN = 150;
13+
const WRITE_PAUSE_MS = 1_000;
14+
// Measured here: about two and a half minutes until every open PR has an answer.
15+
const MERGEABLE_WAIT_MS = 150_000;
16+
const READ_ATTEMPTS = 4;
17+
const READ_RETRY_MS = 5_000;
18+
// GitHub stamps a label event and the PR's updatedAt a second or two apart.
19+
const SAME_MOMENT_MS = 15_000;
20+
21+
const REBASE_LABEL = "needs-rebase";
22+
const CLOSING_LABEL = "triage:closing";
23+
const KEEP_OPEN_LABEL = "triage:keep-open";
24+
const UNVOUCHED_LABEL = "vouch:unvouched";
25+
const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]);
26+
const EXEMPTIONS_PATH = ".github/TRIAGE_EXEMPTIONS.td";
27+
const VOUCHED_PATH = ".github/VOUCHED.td";
28+
const 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.
31+
const 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+
51+
const 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+
63+
const AUTHOR_OPEN_PRS_QUERY = `
64+
query ($q: String!) {
65+
search(query: $q, type: ISSUE, first: 50) {
66+
nodes { ... on PullRequest { number } }
67+
}
68+
}`;
69+
70+
const 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.
74+
async 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".
87+
function 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.
103+
function 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+
113+
function 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.
120+
function 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+
144+
function guideLink(context, anchor) {
145+
return `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md#${anchor}`;
146+
}
147+
148+
async 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+
156+
async 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+
161+
async 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+
274+
async 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+
320+
module.exports = { decide, enforceOpenLimit, parseExemptions, sweep };

0 commit comments

Comments
 (0)