-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Gate external PRs on an assigned, linked issue #3291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
maxisbey
wants to merge
3
commits into
main
Choose a base branch
from
pr-intake-gate
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| <!-- | ||
| Pull requests from outside the maintainer team need to link an open issue that | ||
| a maintainer has assigned to you (or one labeled `help wanted`); others are | ||
| closed automatically until that's in place. See CONTRIBUTING.md for details. | ||
| --> | ||
|
|
||
| Fixes # | ||
|
|
||
| <!-- Provide a brief summary of your changes --> | ||
|
|
||
| ## Motivation and Context | ||
| <!-- Why is this change needed? What problem does it solve? --> | ||
|
|
||
| ## How Has This Been Tested? | ||
| <!-- Have you tested this in a real application? Which scenarios were tested? --> | ||
|
|
||
| ## Breaking Changes | ||
| <!-- Will users need to update their code or configurations? --> | ||
|
|
||
| ## Types of changes | ||
| <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> | ||
| - [ ] Bug fix (non-breaking change which fixes an issue) | ||
| - [ ] New feature (non-breaking change which adds functionality) | ||
| - [ ] Breaking change (fix or feature that would cause existing functionality to change) | ||
| - [ ] Documentation update | ||
|
|
||
| ## Checklist | ||
| <!-- Go over all the following points, and put an `x` in all the boxes that apply. --> | ||
| - [ ] I am assigned to the linked issue (or it is labeled `help wanted`, or I'm a maintainer) | ||
| - [ ] I have disclosed any AI assistance and can explain the change in my own words | ||
| - [ ] I have read the [MCP Documentation](https://modelcontextprotocol.io) | ||
| - [ ] My code follows the repository's style guidelines | ||
| - [ ] New and existing tests pass locally | ||
| - [ ] I have added appropriate error handling | ||
| - [ ] I have added or updated documentation as needed | ||
|
|
||
| ## Additional context | ||
| <!-- Add any other context, implementation notes, or design decisions --> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,275 @@ | ||
| // PR intake gate. The policy lives in CONTRIBUTING.md ("How pull requests get | ||
| // in"); .github/workflows/require-linked-issue.yml wires this up to events. | ||
| // | ||
| // A pull request from someone without triage rights stays open only if its | ||
| // description links (Fixes/Closes/Resolves #N) an open issue in this repo that | ||
| // is either assigned to the PR author or labeled `help wanted`. Otherwise the | ||
| // gate labels it `missing-issue-link`, leaves one comment, and closes it. It | ||
| // re-evaluates — and reopens — the PR when the description is edited or the | ||
| // author is assigned to the issue. A triage+ user reopening the PR or removing | ||
| // the label is a sticky override (`bypass-issue-check`). | ||
| // | ||
| // Everything that writes goes through mutate(), so with ENFORCE unset the run | ||
| // only logs what it would have done. | ||
| 'use strict'; | ||
|
|
||
| const LABEL = 'missing-issue-link'; // marks PRs the gate has closed | ||
| const BYPASS_LABEL = 'bypass-issue-check'; // sticky maintainer override | ||
| const OPEN_LABEL = 'help wanted'; // issue label that waives assignment | ||
| const MARKER = '<!-- require-linked-issue -->'; | ||
| const BOT_LOGIN = 'github-actions[bot]'; | ||
| const MAX_ISSUES = 5; | ||
|
|
||
| module.exports = async function run({ github, context, core }) { | ||
| const { owner, repo } = context.repo; | ||
| const enforce = process.env.ENFORCE === 'true'; | ||
| const contributingUrl = `https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md#how-pull-requests-get-in`; | ||
|
|
||
| // ── Entry points ───────────────────────────────────────────────────────── | ||
|
|
||
| if (context.eventName === 'issues') { | ||
| // Someone was assigned an issue: re-evaluate their gate-closed PRs that | ||
| // reference it (they may pass now). | ||
| const issueNumber = context.payload.issue.number; | ||
| const assignee = context.payload.assignee.login; | ||
| const closed = await github.paginate(github.rest.issues.listForRepo, { | ||
| owner, repo, state: 'closed', creator: assignee, labels: LABEL, per_page: 100, | ||
| }); | ||
| const prs = closed.filter((i) => i.pull_request && closingRefs(i.body).includes(issueNumber)); | ||
| console.log(`#${issueNumber} assigned to ${assignee}: ${prs.length} gate-closed PR(s) reference it`); | ||
| for (const pr of prs) await evaluate(pr.number, 'assigned', context.payload.sender?.login); | ||
| return; | ||
| } | ||
|
|
||
| if (context.eventName === 'workflow_dispatch') { | ||
| const n = parseInt(process.env.PR_NUMBER_INPUT, 10); | ||
| if (!Number.isInteger(n) || n <= 0) throw new Error(`Bad pr_number input: ${process.env.PR_NUMBER_INPUT}`); | ||
| await evaluate(n, 'dispatch', context.payload.sender?.login); | ||
| return; | ||
| } | ||
|
|
||
| await evaluate(context.payload.pull_request.number, context.payload.action, context.payload.sender?.login); | ||
|
|
||
| // ── The rules ──────────────────────────────────────────────────────────── | ||
|
|
||
| async function evaluate(prNumber, action, sender) { | ||
| // Always read the PR live; the event payload can be stale by the time a | ||
| // queued run starts. | ||
| const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber }); | ||
| const labels = pr.labels.map((l) => l.name); | ||
| // An `unlabeled` run only fires for LABEL (see the workflow `if:`), so the | ||
| // event itself proves the label was there a moment ago. | ||
| const gated = action === 'unlabeled' || labels.includes(LABEL); | ||
| console.log(`PR #${prNumber} by ${pr.user.login} (${pr.state}${pr.draft ? ', draft' : ''}) — ${action} by ${sender ?? '-'}, enforce=${enforce}`); | ||
|
|
||
| // 0. Scope: open PRs, plus closed PRs the gate closed itself. A PR someone | ||
| // closed for other reasons is left alone. | ||
| if (pr.state === 'closed' && !gated) return log('closed by someone else — not ours'); | ||
|
|
||
| // 1. Exempt authors: bots, anyone with triage or better, and drafts (which | ||
| // are checked again on ready_for_review). | ||
| if (pr.user.type === 'Bot') return log('author is a bot — exempt'); | ||
| if (await isTrusted(pr.user.login)) return pass('author has triage+ on this repo'); | ||
| if (pr.draft) return log('draft — skipped until ready for review'); | ||
|
|
||
| // 2. Overrides: a triage+ user reopening the PR or removing the label wants | ||
| // it open. Anyone else doing so just triggers a re-check. | ||
| if ((action === 'reopened' || action === 'unlabeled') && sender && (await isTrusted(sender))) { | ||
| return pass(`${sender} ${action === 'reopened' ? 'reopened it' : 'removed the label'} — override`, { sticky: true }); | ||
| } | ||
| if (labels.includes(BYPASS_LABEL)) return pass(`carries ${BYPASS_LABEL}`); | ||
|
|
||
| // 3. The rule: the description links an open issue in this repo that is | ||
| // labeled `help wanted` or assigned to the author. | ||
| const author = pr.user.login.toLowerCase(); | ||
| const linked = []; | ||
| for (const num of closingRefs(pr.body).slice(0, MAX_ISSUES)) { | ||
| const issue = await getIssue(num); | ||
| if (!issue) continue; // missing, a PR, closed, or transferred away | ||
| linked.push(num); | ||
| if (issue.labels.some((l) => (l.name ?? l).toLowerCase() === OPEN_LABEL)) return pass(`#${num} is labeled "${OPEN_LABEL}"`); | ||
| if (issue.assignees.some((a) => a.login.toLowerCase() === author)) return pass(`author is assigned to #${num}`); | ||
| } | ||
| return fail(linked); | ||
|
|
||
| // ── Outcomes ───────────────────────────────────────────────────────── | ||
|
|
||
| async function pass(reason, { sticky = false } = {}) { | ||
| console.log(`PASS: ${reason}`); | ||
| if (sticky) await addLabel(prNumber, BYPASS_LABEL); | ||
| if (pr.state === 'closed' && !(await reopen(pr, reason))) return; | ||
| if (gated) { | ||
| await removeLabel(prNumber, LABEL); | ||
| await deleteGateComment(prNumber); | ||
| } | ||
| } | ||
|
|
||
| async function fail(linkedIssues) { | ||
| console.log(`FAIL: ${linkedIssues.length ? `not assigned to ${linkedIssues.map((n) => `#${n}`).join(', ')}` : 'no usable issue link'}`); | ||
| await addLabel(prNumber, LABEL); | ||
| await upsertGateComment(prNumber, closedComment(linkedIssues)); | ||
| if (pr.state === 'open') { | ||
| await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' })); | ||
| } | ||
| } | ||
|
|
||
| function log(msg) { | ||
| console.log(msg); | ||
| } | ||
| } | ||
|
|
||
| // ── Comment text ───────────────────────────────────────────────────────── | ||
|
|
||
| function closedComment(linkedIssues) { | ||
| const issues = linkedIssues.map((n) => `#${n}`).join(', '); | ||
| const why = linkedIssues.length | ||
| ? `you aren't currently assigned to ${issues}` | ||
| : "its description doesn't yet link an open issue in this repository (with `Fixes #123` or similar)"; | ||
| const next = linkedIssues.length | ||
| ? `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.)` | ||
| : `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.`; | ||
| return [ | ||
| MARKER, | ||
| `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}.`, | ||
| '', | ||
| next, | ||
| '', | ||
| "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.", | ||
| '', | ||
| `*Maintainers: reopening this PR or removing the \`${LABEL}\` label bypasses the check.*`, | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| function cannotReopenComment(pr, reason) { | ||
| return [ | ||
| MARKER, | ||
| `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.`, | ||
| '', | ||
| `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 with the same \`Fixes #<issue>\` line.`, | ||
| ].join('\n'); | ||
| } | ||
|
|
||
| // ── Helpers ────────────────────────────────────────────────────────────── | ||
|
|
||
| async function mutate(description, fn) { | ||
| if (!enforce) { | ||
| console.log(`[dry-run] would ${description}`); | ||
| return undefined; | ||
| } | ||
| return fn(); | ||
| } | ||
|
|
||
| // Triage-or-better on this repo, from the permission endpoint's capability | ||
| // flags (role names can be custom; author_association hides private org | ||
| // members). Only a nonexistent user 404s; any other error must throw rather | ||
| // than be read as "untrusted", or a maintainer's PR could be closed. | ||
| async function isTrusted(username) { | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); | ||
| const p = data.user?.permissions; | ||
| if (!p) throw new Error(`permission response for ${username} has no capability flags`); | ||
| const trusted = Boolean(p.triage || p.push || p.maintain || p.admin); | ||
| console.log(` ${username}: ${trusted ? 'trusted' : 'not trusted'} (role ${data.role_name || '-'})`); | ||
| return trusted; | ||
| } catch (e) { | ||
| if (e.status === 404) return false; | ||
| throw new Error(`Permission check failed for ${username} (HTTP ${e.status ?? '?'}): ${e.message}`); | ||
| } | ||
| } | ||
|
|
||
| // Issue numbers referenced with a closing keyword, in the forms GitHub itself | ||
| // honors: `Fixes #1`, `closes owner/repo#1`, `Resolved https://github.com/owner/repo/issues/1`. | ||
| function closingRefs(body) { | ||
| const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const re = new RegExp( | ||
| `\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`, | ||
| 'gi', | ||
| ); | ||
| return [...new Set([...(body || '').matchAll(re)].map((m) => parseInt(m[1], 10)))]; | ||
| } | ||
|
|
||
| // The linked issue, or null if it doesn't exist, is actually a PR, isn't | ||
| // open, or has been transferred to another repository. | ||
| async function getIssue(num) { | ||
| let issue; | ||
| try { | ||
| ({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: num })); | ||
| } catch (e) { | ||
| if (e.status === 404 || e.status === 410) return null; | ||
| throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? '?'}): ${e.message}`); | ||
| } | ||
| if (issue.pull_request || issue.state !== 'open') return null; | ||
| if (!issue.repository_url?.endsWith(`/${owner}/${repo}`)) return null; | ||
| return issue; | ||
| } | ||
|
|
||
| // Reopen a gate-closed PR. GitHub refuses (422) if the branch was rewritten | ||
| // or deleted while closed, or another open PR uses it; in that case keep | ||
| // the label so the PR stays findable and explain in the comment. | ||
| async function reopen(pr, reason) { | ||
| try { | ||
| await mutate(`reopen PR #${pr.number}`, () => github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'open' })); | ||
| return true; | ||
| } catch (e) { | ||
| if (e.status !== 422) throw e; | ||
| core.warning(`GitHub refused to reopen PR #${pr.number}: ${e.message}`); | ||
| await upsertGateComment(pr.number, cannotReopenComment(pr, reason)); | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| async function addLabel(prNumber, name) { | ||
| await mutate(`add "${name}" to PR #${prNumber}`, async () => { | ||
| await ensureLabelExists(name); | ||
| await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [name] }); | ||
| }); | ||
| } | ||
|
|
||
| async function removeLabel(prNumber, name) { | ||
| await mutate(`remove "${name}" from PR #${prNumber}`, async () => { | ||
| try { | ||
| await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| async function ensureLabelExists(name) { | ||
| const meta = { | ||
| [LABEL]: ['b76e79', 'Auto-closed: PR needs a linked issue assigned to its author (see CONTRIBUTING.md)'], | ||
| [BYPASS_LABEL]: ['0e8a16', 'Maintainer override for the linked-issue intake gate'], | ||
| }[name]; | ||
| try { | ||
| await github.rest.issues.getLabel({ owner, repo, name }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| try { | ||
| await github.rest.issues.createLabel({ owner, repo, name, color: meta[0], description: meta[1] }); | ||
| } catch (createErr) { | ||
| if (createErr.status !== 422) throw createErr; // created concurrently | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // The gate keeps at most one comment per PR: authored by the Actions bot and | ||
| // carrying MARKER. It's created or updated on failure and deleted on pass. | ||
| async function findGateComment(prNumber) { | ||
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: prNumber, per_page: 100 }); | ||
| return comments.find((c) => c.user?.login === BOT_LOGIN && c.body?.includes(MARKER)); | ||
| } | ||
|
|
||
| async function upsertGateComment(prNumber, body) { | ||
| const existing = await findGateComment(prNumber); | ||
| if (!existing) { | ||
| await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body })); | ||
| } else if (existing.body !== body) { | ||
| await mutate(`update the gate comment on PR #${prNumber}`, () => github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })); | ||
| } | ||
| } | ||
|
|
||
| async function deleteGateComment(prNumber) { | ||
| const existing = await findGateComment(prNumber); | ||
| if (existing) await mutate(`delete the gate comment on PR #${prNumber}`, () => github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id })); | ||
| } | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # PR intake gate — see CONTRIBUTING.md ("How pull requests get in") for the | ||
| # policy and .github/scripts/pr_intake_gate.js for the rules as applied. | ||
| # | ||
| # In short: a PR from someone without triage rights stays open only if it links | ||
| # an open issue here that is assigned to them (or labeled `help wanted`); | ||
| # otherwise it is labeled `missing-issue-link`, gets one comment, and is closed, | ||
| # and it reopens automatically once the author is assigned. Bots and drafts are | ||
| # skipped. A triage+ user reopening the PR or removing the label overrides. | ||
| # | ||
| # Operating it: | ||
| # - Dry-run until the repository variable PR_GATE_ENFORCE is set to "true". | ||
| # - PRs below the number in the job `if:` predate the gate and are ignored | ||
| # unless evaluated by hand: `gh workflow run require-linked-issue.yml -f pr_number=N`. | ||
| # | ||
| # Security: pull_request_target runs with a write token in the base repo's | ||
| # context. This workflow checks out only the default branch (for the script) | ||
| # and never fetches, builds, or runs anything from the pull request. | ||
| # | ||
| # Adapted from PrefectHQ/fastmcp's require-issue-link.yml (Apache-2.0), itself | ||
| # from langchain-ai/langchain (MIT). | ||
|
|
||
| name: Require Linked Issue | ||
|
|
||
| on: | ||
| pull_request_target: # zizmor: ignore[dangerous-triggers] checks out the default branch only and never runs PR code — see header | ||
| types: [opened, edited, reopened, ready_for_review, unlabeled] | ||
| issues: | ||
| types: [assigned] | ||
| workflow_dispatch: | ||
| inputs: | ||
| pr_number: | ||
| description: PR number to evaluate | ||
| required: true | ||
| type: number | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| gate: | ||
| name: Evaluate | ||
| # Routing only; the rules are in the script. PR events run at or above the | ||
| # grandfathering floor, or for PRs the gate has already labeled. | ||
| if: >- | ||
| github.event_name == 'workflow_dispatch' || | ||
| (github.event_name == 'issues' && !github.event.issue.pull_request && github.event.issue.state == 'open') || | ||
| ( | ||
| github.event_name == 'pull_request_target' && | ||
| ( | ||
| github.event.pull_request.number >= 3200 || | ||
| contains(github.event.pull_request.labels.*.name, 'missing-issue-link') || | ||
| github.event.action == 'unlabeled' | ||
| ) && | ||
| (github.event.action != 'unlabeled' || github.event.label.name == 'missing-issue-link') | ||
| ) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| concurrency: | ||
| group: require-linked-issue-${{ github.event.pull_request.number || inputs.pr_number || format('issue-{0}', github.event.issue.number) }} | ||
| cancel-in-progress: false | ||
| permissions: | ||
| contents: read # check out the gate script from the default branch | ||
| issues: write # read linked issues; label and comment on the PR | ||
| pull-requests: write # close and reopen the PR | ||
| env: | ||
| ENFORCE: ${{ vars.PR_GATE_ENFORCE == 'true' && 'true' || 'false' }} | ||
| PR_NUMBER_INPUT: ${{ inputs.pr_number }} | ||
| steps: | ||
| - name: Check out the gate script (default branch) | ||
| uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 | ||
| with: | ||
| persist-credentials: false | ||
| sparse-checkout: .github/scripts | ||
|
|
||
| - name: Evaluate | ||
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | ||
| with: | ||
| script: | | ||
|
maxisbey marked this conversation as resolved.
|
||
| const run = require('./.github/scripts/pr_intake_gate.js'); | ||
| await run({ github, context, core }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.