Skip to content

Commit 8a66b3e

Browse files
committed
Intake gate: honour the bypass label on its own, keep refused reopens managed
Review follow-ups: - Adding `bypass-issue-check` is now a real override: the workflow listens for that `labeled` event (and only that label), so the bot comment's third suggestion works without an accompanying reopen. - When GitHub refuses to reopen a PR, put the control label (back) on so the PR stays gate-managed and a later edit or override retries, rather than falling out of scope after a label-removal override. - On assignment, check the just-assigned issue first so the reference cap can't hide it. - Tolerate a concurrent delete of the gate comment, as label removal already does. - Run the scenario tests on Node 24 to match the github-script runtime. Four scenarios added for the above. No-Verification-Needed: workflow script, its tests, and CI wiring only Signed-off-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
1 parent 5461e1a commit 8a66b3e

4 files changed

Lines changed: 58 additions & 14 deletions

File tree

.github/scripts/pr_intake_gate.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
// is either assigned to the PR author or labeled `help wanted`. Otherwise the
77
// gate labels it `missing-issue-link`, leaves one comment, and closes it. It
88
// 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 or removing
10-
// the label is a sticky override (`bypass-issue-check`).
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.
1112
//
1213
// Everything that writes goes through mutate(), so with ENFORCE unset the run
1314
// only logs what it would have done.
@@ -37,7 +38,7 @@ module.exports = async function run({ github, context, core }) {
3738
});
3839
const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber));
3940
console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`);
40-
for (const pr of prs) await evaluate(pr.number, 'assigned', context.payload.sender?.login);
41+
for (const pr of prs) await evaluate(pr.number, 'assigned', context.payload.sender?.login, issueNumber);
4142
return;
4243
}
4344

@@ -52,7 +53,7 @@ module.exports = async function run({ github, context, core }) {
5253

5354
// ── The rules ────────────────────────────────────────────────────────────
5455

55-
async function evaluate(prNumber, action, sender) {
56+
async function evaluate(prNumber, action, sender, hintIssue = null) {
5657
// Always read the PR live; the event payload can be stale by the time a
5758
// queued run starts.
5859
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
@@ -81,10 +82,13 @@ module.exports = async function run({ github, context, core }) {
8182
if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`);
8283

8384
// 3. The rule: the description links an open issue in this repo that is
84-
// labeled `help wanted` or assigned to the author.
85+
// labeled `help wanted` or assigned to the author. Only the first few
86+
// references are fetched; a just-assigned issue is checked first.
8587
const author = pr.user.login.toLowerCase();
88+
const refs = closingRefs(pr.body);
89+
if (hintIssue && refs.includes(hintIssue)) refs.unshift(...refs.splice(refs.indexOf(hintIssue), 1));
8690
const linked = [];
87-
for (const num of closingRefs(pr.body).slice(0, MAX_ISSUES)) {
91+
for (const num of refs.slice(0, MAX_ISSUES)) {
8892
const issue = await getIssue(num);
8993
if (!issue) continue; // missing, a PR, closed, or transferred away
9094
linked.push(num);
@@ -205,16 +209,17 @@ module.exports = async function run({ github, context, core }) {
205209
}
206210

207211
// Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten
208-
// or deleted while closed, or another open PR uses it. That state is
209-
// terminal for this PR, so just explain it in the comment; the control
210-
// label is left as it is (still on, unless a maintainer removed it).
212+
// or deleted while closed, or another open PR uses it. Explain that in the
213+
// comment and make sure the control label is (still) on, so the PR stays
214+
// gate-managed and a later edit or override retries the reopen.
211215
async function reopen(pr, reason) {
212216
try {
213217
await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' }));
214218
return true;
215219
} catch (e) {
216220
if (e.status !== 422) throw e;
217221
core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`);
222+
await addLabel(pr.number, LABEL);
218223
await upsertGateComment(pr.number, cannotReopenComment(pr, reason));
219224
return false;
220225
}
@@ -272,6 +277,13 @@ module.exports = async function run({ github, context, core }) {
272277

273278
async function deleteGateComment(prNumber) {
274279
const existing = await findGateComment(prNumber);
275-
if (existing) await mutate(`delete the gate comment on PR #${prNumber}`, () => github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }));
280+
if (!existing) return;
281+
await mutate(`delete the gate comment on PR #${prNumber}`, async () => {
282+
try {
283+
await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id });
284+
} catch (e) {
285+
if (e.status !== 404) throw e; // already deleted by a concurrent run
286+
}
287+
});
276288
}
277289
};

.github/scripts/pr_intake_gate.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,31 @@ const scenarios = [
180180
event: edited(3300, 'outsider'),
181181
expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'cannot-reopen' } },
182182
},
183+
{
184+
name: 'gate-closed PR: maintainer adds the bypass label → reopened, and the label sticks',
185+
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL, BYPASS], gateComment: true })], // payload arrives post-label
186+
event: labeled(3300, 'maintainer', BYPASS),
187+
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
188+
},
189+
{
190+
name: 'refused reopen after a label-removal override → both labels on, so the PR stays gate-managed',
191+
prs: [pr(3300, 'outsider', { state: 'closed', labels: [], gateComment: true, refuseReopen: true })],
192+
event: unlabeled(3300, 'triager'),
193+
expect: { 3300: { state: 'closed', labels: [BYPASS, LABEL], comment: 'cannot-reopen' } },
194+
},
195+
{
196+
name: 'after a refused reopen, once the branch is restored an edit retries and reopens',
197+
prs: [pr(3300, 'outsider', { state: 'closed', labels: [BYPASS, LABEL], comments: [{ user: 'github-actions[bot]', body: "<!-- require-linked-issue -->\n…GitHub won't let it be reopened…" }] })],
198+
event: edited(3300, 'outsider'),
199+
expect: { 3300: { state: 'open', labels: [BYPASS], comment: null } },
200+
},
201+
{
202+
name: 'assignment reopens the PR even when the assigned issue is referenced after several others',
203+
prs: [pr(3300, 'outsider', { state: 'closed', labels: [LABEL], body: 'Fixes #1 fixes #2 fixes #3 fixes #4 fixes #5 fixes #6', gateComment: true })],
204+
issues: [issue(1), issue(2), issue(3), issue(4), issue(5), issue(6, { assignees: ['outsider'] })],
205+
event: assigned(6, 'outsider', 'maintainer'),
206+
expect: { 3300: { state: 'open', labels: [], comment: null } },
207+
},
183208
{
184209
name: 'a comment planted by the author with the marker is ignored; the gate posts its own',
185210
prs: [pr(3300, 'outsider', { comments: [{ user: 'outsider', body: '<!-- require-linked-issue -->\nnice try' }] })],
@@ -234,6 +259,7 @@ function edited(number, sender) { return prEvent('edited', number, sender); }
234259
function reopened(number, sender) { return prEvent('reopened', number, sender); }
235260
function readyForReview(number, sender) { return prEvent('ready_for_review', number, sender); }
236261
function unlabeled(number, sender) { return prEvent('unlabeled', number, sender, { label: { name: LABEL } }); }
262+
function labeled(number, sender, name) { return prEvent('labeled', number, sender, { label: { name } }); }
237263
function assigned(issueNumber, assignee, sender) {
238264
return { eventName: 'issues', payload: { action: 'assigned', issue: { number: issueNumber }, assignee: { login: assignee }, sender: { login: sender } } };
239265
}

.github/workflows/require-linked-issue.yml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
# an open issue here that is assigned to them (or labeled `help wanted`);
66
# otherwise it is labeled `missing-issue-link`, gets one comment, and is closed,
77
# and it reopens automatically once the author is assigned. Bots and drafts are
8-
# skipped. A triage+ user reopening the PR or removing the label overrides.
8+
# skipped. A triage+ user reopening the PR, removing the label, or adding
9+
# `bypass-issue-check` overrides.
910
#
1011
# Operating it:
1112
# - Dry-run until the repository variable PR_GATE_ENFORCE is set to "true".
@@ -25,7 +26,7 @@ name: Require Linked Issue
2526

2627
on:
2728
pull_request_target: # zizmor: ignore[dangerous-triggers] checks out the default branch only and never runs PR code — see header
28-
types: [opened, edited, reopened, ready_for_review, unlabeled]
29+
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
2930
issues:
3031
types: [assigned]
3132
workflow_dispatch:
@@ -41,7 +42,8 @@ jobs:
4142
gate:
4243
name: Evaluate
4344
# Routing only; the rules are in the script. PR events run at or above the
44-
# grandfathering floor, or for PRs the gate has already labeled.
45+
# grandfathering floor, or for PRs the gate has already labeled; label
46+
# events only for the two labels the gate cares about.
4547
if: >-
4648
github.event_name == 'workflow_dispatch' ||
4749
(github.event_name == 'issues' && !github.event.issue.pull_request && github.event.issue.state == 'open') ||
@@ -52,7 +54,8 @@ jobs:
5254
contains(github.event.pull_request.labels.*.name, 'missing-issue-link') ||
5355
github.event.action == 'unlabeled'
5456
) &&
55-
(github.event.action != 'unlabeled' || github.event.label.name == 'missing-issue-link')
57+
(github.event.action != 'unlabeled' || github.event.label.name == 'missing-issue-link') &&
58+
(github.event.action != 'labeled' || github.event.label.name == 'bypass-issue-check')
5659
)
5760
runs-on: ubuntu-latest
5861
timeout-minutes: 10

.github/workflows/shared.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ jobs:
4141
with:
4242
extra_args: --all-files --verbose
4343

44+
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
45+
with:
46+
node-version: 24 # match the runtime actions/github-script@v8 uses
4447
- name: PR intake gate scenarios
4548
run: node --test .github/scripts/pr_intake_gate.test.js
4649

0 commit comments

Comments
 (0)