Skip to content

Commit 2a1cc94

Browse files
authored
Gate external PRs on an assigned, linked issue (#3291)
Signed-off-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent e473cca commit 2a1cc94

7 files changed

Lines changed: 874 additions & 35 deletions

File tree

.github/pull_request_template.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<!--
2+
Pull requests from outside the maintainer team need to link an open issue that
3+
a maintainer has assigned to you (or one labeled `help wanted`); others are
4+
closed automatically until that's in place. See CONTRIBUTING.md for details.
5+
-->
6+
7+
Fixes #
8+
9+
<!-- Provide a brief summary of your changes -->
10+
11+
## Motivation and Context
12+
<!-- Why is this change needed? What problem does it solve? -->
13+
14+
## How Has This Been Tested?
15+
<!-- Have you tested this in a real application? Which scenarios were tested? -->
16+
17+
## Breaking Changes
18+
<!-- Will users need to update their code or configurations? -->
19+
20+
## Types of changes
21+
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
22+
- [ ] Bug fix (non-breaking change which fixes an issue)
23+
- [ ] New feature (non-breaking change which adds functionality)
24+
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
25+
- [ ] Documentation update
26+
27+
## Checklist
28+
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
29+
- [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer)
30+
- [ ] I have disclosed any AI assistance and can explain the change in my own words
31+
- [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io)
32+
- [ ] My code follows the repository's style guidelines
33+
- [ ] New and existing tests pass locally
34+
- [ ] I have added appropriate error handling
35+
- [ ] I have added or updated documentation as needed
36+
37+
## Additional context
38+
<!-- Add any other context, implementation notes, or design decisions -->

.github/scripts/pr_intake_gate.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
// PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get
2+
// in"); .github/workflows/require-linked-issue.yml wires this up to events.
3+
//
4+
// A pull request from someone without triage rights stays open only if its
5+
// description links (Fixes/Closes/Resolves #N) an open issue in this repo that
6+
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
7+
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
8+
// re-evaluates — and reopens — the PR when the description is edited or the
9+
// author is assigned to the issue. A triage+ user reopening the PR, removing
10+
// the label, or adding `bypass-issue-check` overrides it, and the override
11+
// sticks.
12+
//
13+
// Everything that writes goes through mutate(); when the workflow passes
14+
// ENFORCE=false (its kill switch) the run only logs what it would have done.
15+
'use strict';
16+
17+
const LABEL = 'missing-issue-link'; // marks PRs the gate has closed
18+
const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override
19+
const OPEN_LABEL = 'help wanted'; // issue label that waives assignment
20+
const MARKER = '<!-- require-linked-issue -->';
21+
const BOT_LOGIN = 'github-actions[bot]';
22+
const MAX_ISSUES = 5;
23+
24+
module.exports = async function run({ github, context, core }) {
25+
const { owner, repo } = context.repo;
26+
const enforce = process.env.ENFORCE === 'true';
27+
const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`;
28+
29+
// ── Entry points ─────────────────────────────────────────────────────────
30+
31+
if (context.eventName === 'issues') {
32+
// Someone was assigned an issue: re-evaluate their gate-closed PRs that
33+
// reference it (they may pass now).
34+
const issueNumber = context.payload.issue.number;
35+
const assignee = context.payload.assignee.login;
36+
const closed = await github.paginate(github.rest.issues.listForRepo, {
37+
owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100,
38+
});
39+
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
40+
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
41+
// Evaluate each independently so one transient failure doesn't strand
42+
// the rest (this event won't fire again for the same assignment).
43+
const failures = [];
44+
for (const pr of prs) {
45+
try {
46+
await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
47+
} catch (e) {
48+
failures.push(`#${pr.number}: ${e.message}`);
49+
}
50+
}
51+
if (failures.length) throw new Error(`Could not re-evaluate ${failures.join('; ')}`);
52+
return;
53+
}
54+
55+
if (context.eventName === 'workflow_dispatch') {
56+
const n = parseInt(process.env.PR_NUMBER_INPUT, 10);
57+
if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`);
58+
await evaluate(n, 'dispatch', context.payload.sender?.login);
59+
return;
60+
}
61+
62+
await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login);
63+
64+
// ── The rules ────────────────────────────────────────────────────────────
65+
66+
async function evaluate(prNumber, action, sender, hintIssue = null) {
67+
// Always read the PR live; the event payload can be stale by the time a
68+
// queued run starts.
69+
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
70+
const labels = pr.labels.map((l) => l.name);
71+
// An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the
72+
// event itself proves the label was there a moment ago.
73+
const gated = action === 'unlabeled' || labels.includes(LABEL);
74+
console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`);
75+
76+
// 0. Scope: open PRs, plus closed PRs the gate closed itself. Merged PRs
77+
// and PRs someone closed for other reasons are left alone.
78+
if (pr.merged_at) return log('merged — nothing to do');
79+
if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours');
80+
81+
// 1. Exempt authors: bots, anyone with triage or better, and drafts (which
82+
// are checked again on ready_for_review).
83+
if (pr.user.type === 'Bot') return log('author is a bot — exempt');
84+
if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo');
85+
if (pr.draft) return log('draft — skipped until ready for review');
86+
87+
// 2. Overrides: a triage+ user reopening the PR or removing the label wants
88+
// it open. Anyone else doing so just triggers a re-check.
89+
if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) {
90+
return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true });
91+
}
92+
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
93+
94+
// 3. The rule: the description links an open issue in this repo that is
95+
// labeled `help wanted` or assigned to the author. Only the first few
96+
// references are fetched; a just-assigned issue is checked first.
97+
const author = pr.user.login.toLowerCase();
98+
const refs = closingRefs(pr.body);
99+
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
100+
const linked = [];
101+
for (const num of refs.slice(0, MAX_ISSUES)) {
102+
const issue = await getIssue(num);
103+
if (!issue) continue; // missing, a PR, closed, or transferred away
104+
linked.push(num);
105+
if (issue.labels.some((l) => l.name.toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`);
106+
if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`);
107+
}
108+
return fail(linked);
109+
110+
// ── Outcomes ─────────────────────────────────────────────────────────
111+
112+
async function pass(reason, { sticky = false } = {}) {
113+
console.log(`PASS: ${reason}`);
114+
if (sticky) await addLabel(prNumber, BYPASS_LABEL);
115+
if (pr.state === 'closed' && !(await reopen(pr, reason))) return;
116+
if (gated) {
117+
await removeLabel(prNumber, LABEL);
118+
await deleteGateComment(prNumber);
119+
}
120+
}
121+
122+
async function fail(linkedIssues) {
123+
console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`);
124+
await addLabel(prNumber, LABEL);
125+
await upsertGateComment(prNumber, closedComment(linkedIssues));
126+
if (pr.state === 'open') {
127+
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' }));
128+
}
129+
}
130+
131+
function log(msg) {
132+
console.log(msg);
133+
}
134+
}
135+
136+
// ── Comment text ─────────────────────────────────────────────────────────
137+
138+
function closedComment(linkedIssues) {
139+
const issues = linkedIssues.map((n) => `#${n}`).join(', ');
140+
const why = linkedIssues.length
141+
? `you aren't currently assigned to ${issues}`
142+
: "its description doesn't yet link an open issue in this repository (with `Fixes #123` or similar)";
143+
const next = linkedIssues.length
144+
? `If a maintainer would like this change as a PR from you, they'll assign you to ${issues} and this PR will reopen automatically — there's nothing more you need to do. (If you opened the issue, this PR already shows up on its timeline.)`
145+
: `If there isn't an issue for this yet, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) — a clear description of the problem is genuinely the most useful thing for us. Then add \`Fixes #<number>\` to this PR's description. If a maintainer would like the change as a PR from you, they'll assign you to the issue and this PR will reopen automatically.`;
146+
return [
147+
MARKER,
148+
`Thanks for the contribution. This repository only keeps pull requests open when they're linked to an issue that a maintainer has assigned to the author — [CONTRIBUTING.md](${contributingUrl}) explains why and how we work. This PR has been closed for now because ${why}.`,
149+
'',
150+
next,
151+
'',
152+
"There's no need to open a new PR — this one will be reopened. While it's closed, please push any updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten.",
153+
'',
154+
`*Maintainers: reopening this PR, removing the \`${LABEL}\` label, or adding \`${BYPASS_LABEL}\` bypasses the check.*`,
155+
].join('\n');
156+
}
157+
158+
function cannotReopenComment(pr, reason) {
159+
return [
160+
MARKER,
161+
`This PR now passes the intake check (${reason}), but GitHub won't let it be reopened — usually because the branch was force-pushed or deleted while the PR was closed, or because another open PR uses the same branch.`,
162+
'',
163+
`If you have another open PR from this branch, please continue there. Otherwise, either push the branch back to \`${pr.head.sha.slice(0, 7)}\` and edit this PR's description to retry, or open a new PR that links the same issue (if a maintainer had waved this one through, mention that so they can do the same there).`,
164+
].join('\n');
165+
}
166+
167+
// ── Helpers ──────────────────────────────────────────────────────────────
168+
169+
async function mutate(description, fn) {
170+
if (!enforce) {
171+
console.log(`[dry-run] would ${description}`);
172+
return undefined;
173+
}
174+
return fn();
175+
}
176+
177+
// Triage-or-better on this repo, from the permission endpoint's capability
178+
// flags (role names can be custom; author_association hides private org
179+
// members). Only a nonexistent user 404s; any other error must throw rather
180+
// than be read as "untrusted", or a maintainer's PR could be closed.
181+
async function isTrusted(username) {
182+
try {
183+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username });
184+
const p = data.user?.permissions;
185+
if (!p) throw new Error(`permission response for ${username} has no capability flags`);
186+
const trusted = Boolean(p.triage || p.push || p.maintain || p.admin);
187+
console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`);
188+
return trusted;
189+
} catch (e) {
190+
if (e.status === 404) return false;
191+
throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`);
192+
}
193+
}
194+
195+
// Issue numbers referenced with a closing keyword, in the forms GitHub itself
196+
// honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`.
197+
function closingRefs(body) {
198+
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
199+
const re = new RegExp(
200+
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
201+
'gi',
202+
);
203+
return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))];
204+
}
205+
206+
// The linked issue, or null if it doesn't exist, is actually a PR, isn't
207+
// open, or has been transferred to another repository.
208+
async function getIssue(num) {
209+
let issue;
210+
try {
211+
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num }));
212+
} catch (e) {
213+
if (e.status === 404 || e.status === 410) return null;
214+
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`);
215+
}
216+
if (issue.pull_request || issue.state !== 'open') return null;
217+
if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null;
218+
return issue;
219+
}
220+
221+
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
222+
// or deleted while closed, or another open PR uses it. Explain that in the
223+
// comment and make sure the control label is (still) on, so the PR stays
224+
// gate-managed and a later edit or override retries the reopen.
225+
async function reopen(pr, reason) {
226+
try {
227+
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
228+
return true;
229+
} catch (e) {
230+
if (e.status !== 422) throw e;
231+
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
232+
await addLabel(pr.number, LABEL);
233+
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
234+
return false;
235+
}
236+
}
237+
238+
async function addLabel(prNumber, name) {
239+
await mutate(`add "${name}" to PR #${prNumber}`, async () => {
240+
await ensureLabelExists(name);
241+
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] });
242+
});
243+
}
244+
245+
async function removeLabel(prNumber, name) {
246+
await mutate(`remove "${name}" from PR #${prNumber}`, async () => {
247+
try {
248+
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name });
249+
} catch (e) {
250+
if (e.status !== 404) throw e;
251+
}
252+
});
253+
}
254+
255+
async function ensureLabelExists(name) {
256+
const meta = {
257+
[LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'],
258+
[BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'],
259+
}[name];
260+
try {
261+
await github.rest.issues.getLabel({ owner, repo, name });
262+
} catch (e) {
263+
if (e.status !== 404) throw e;
264+
try {
265+
await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] });
266+
} catch (createErr) {
267+
if (createErr.status !== 422) throw createErr; // created concurrently
268+
}
269+
}
270+
}
271+
272+
// The gate keeps at most one comment per PR: authored by the Actions bot and
273+
// carrying MARKER. It's created or updated on failure and deleted on pass.
274+
async function findGateComment(prNumber) {
275+
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 });
276+
return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER));
277+
}
278+
279+
async function upsertGateComment(prNumber, body) {
280+
const existing = await findGateComment(prNumber);
281+
if (!existing) {
282+
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }));
283+
} else if (existing.body !== body) {
284+
await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }));
285+
}
286+
}
287+
288+
async function deleteGateComment(prNumber) {
289+
const existing = await findGateComment(prNumber);
290+
if (!existing) return;
291+
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
292+
try {
293+
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
294+
} catch (e) {
295+
if (e.status !== 404) throw e; // already deleted by a concurrent run
296+
}
297+
});
298+
}
299+
};

0 commit comments

Comments
 (0)