From b3a50545897ed528808df48c6860710ab923d6ba Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:48:01 +0000 Subject: [PATCH 1/4] Gate draft PRs too and rewrite the auto-close comment Drafts were skipped until marked ready for review, which let unlinked PRs sit open indefinitely as drafts. They now go through the same rule as any other PR. The comment the gate leaves is rewritten to be clearer about what happened, what (if anything) the author can do, and what to expect: it no longer implies the PR will be reopened, and says plainly that review capacity for community PRs is limited. --- .github/scripts/pr_intake_gate.js | 37 ++++++++++++++-------- .github/scripts/pr_intake_gate.test.js | 7 ++-- .github/workflows/require-linked-issue.yml | 6 ++-- CONTRIBUTING.md | 2 +- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/.github/scripts/pr_intake_gate.js b/.github/scripts/pr_intake_gate.js index 40f429d61f..24e486c3b1 100644 --- a/.github/scripts/pr_intake_gate.js +++ b/.github/scripts/pr_intake_gate.js @@ -78,11 +78,10 @@ module.exports = async function run({ github, context, core }) { if (pr.merged_at) return log('merged — nothing to do'); 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). + // 1. Exempt authors: bots and anyone with triage or better. Drafts are + // gated like any other PR. 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. @@ -137,21 +136,33 @@ module.exports = async function run({ github, context, core }) { 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 #\` 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.`; + const rule = + 'This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue'; + const situation = linkedIssues.length + ? [ + `${rule}, and you aren't currently assigned to ${issues}.`, + '', + `If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`, + ] + : [ + `${rule}, and this PR doesn't link an issue yet.`, + '', + "- **If you're already assigned to an issue for this**, add `Fixes #` to the description and the PR will reopen on its own.", + `- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`, + "- **If there's an issue but you're not assigned**, add `Fixes #` anyway so they're linked, then engage on the issue itself by confirming the repro or describing the approach you'd take. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. If you are assigned, this PR reopens 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}.`, + ...situation, '', - next, + "You're welcome to keep pushing commits here (just avoid force-pushing, since GitHub can't reopen a rewritten branch), but that on its own won't get the PR reviewed or the issue assigned, and realistically most auto-closed PRs stay closed. There's no need to open a new PR either way.", '', - "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.", + `[CONTRIBUTING.md](${contributingUrl}) has the full reasoning, but in short:`, '', - `*Maintainers: reopening this PR, removing the \`${LABEL}\` label, or adding \`${BYPASS_LABEL}\` bypasses the check.*`, + "- We're a small team with very little capacity to review community PRs right now.", + '- Many recent PRs are AI-generated with little human review, and reviewing one carefully still costs a maintainer as much time as it ever did. A well-described issue is usually more useful to us than the code.', + '', + `*Maintainers: reopen, remove \`${LABEL}\`, or add \`${BYPASS_LABEL}\` to override.*`, ].join('\n'); } diff --git a/.github/scripts/pr_intake_gate.test.js b/.github/scripts/pr_intake_gate.test.js index 75c9624765..dceb6363b0 100644 --- a/.github/scripts/pr_intake_gate.test.js +++ b/.github/scripts/pr_intake_gate.test.js @@ -146,14 +146,13 @@ const scenarios = [ writes: 0, }, { - name: 'draft PR is skipped until it is marked ready for review', + name: 'draft PR with no issue link is closed like any other', prs: [pr(3300, 'outsider', { draft: true })], event: opened(3300, 'outsider'), - expect: { 3300: { state: 'open', labels: [], comment: null } }, - writes: 0, + expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } }, }, { - name: 'draft marked ready for review with no link → closed', + name: 'pre-existing draft marked ready for review with no link → closed', prs: [pr(3300, 'outsider')], event: readyForReview(3300, 'outsider'), expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } }, diff --git a/.github/workflows/require-linked-issue.yml b/.github/workflows/require-linked-issue.yml index de036ebf64..31ccdc3e43 100644 --- a/.github/workflows/require-linked-issue.yml +++ b/.github/workflows/require-linked-issue.yml @@ -4,9 +4,9 @@ # 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, removing the label, or adding -# `bypass-issue-check` overrides. +# and it reopens automatically once the author is assigned. Drafts are gated +# too; bots are skipped. A triage+ user reopening the PR, removing the label, +# or adding `bypass-issue-check` overrides. # # Operating it: # - Live by default. To pause it without a revert, set the repository diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57c2b14a86..8a736213e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ A PR from someone outside the maintainer team stays open when both of these hold 1. Its description links an open issue in this repository with a closing keyword (`Fixes #123`, `Closes #123`, `Resolves #123`). 2. A maintainer has assigned that issue to you, or the issue carries the [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) label (which means we'd welcome a PR for it from anyone). -Otherwise a bot labels the PR `missing-issue-link`, leaves a comment explaining this, and closes it. If that happens to yours, there's no need to open a new one: it reopens automatically as soon as a maintainer assigns you the issue, or when you edit the description to link one that qualifies. While it's closed, push updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. This applies to small fixes like typos too — for those, an issue pointing at the problem is all we need. +Otherwise a bot labels the PR `missing-issue-link`, leaves a comment explaining this, and closes it. If that happens to yours, there's no need to open a new one: it reopens automatically as soon as a maintainer assigns you the issue, or when you edit the description to link one that qualifies. While it's closed, push updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. This applies to draft PRs, and to small fixes like typos too — for those, an issue pointing at the problem is all we need. Whether to assign an issue, and to whom, is a [maintainer](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/MAINTAINERS.md#python-sdk) call, and it depends on our capacity at the time as much as on the change itself. Comments that only ask to be assigned don't factor into it, so please skip those (and don't have an agent post them). What does help is engaging with the issue itself: confirming the reproduction, asking about the intended behaviour, or briefly describing the approach you'd take. If you reported the issue and would like to fix it yourself, mention that in the issue — the reporter has first call if we do take an outside PR for it. From 4b53405bf9ed6da281dbdb19612384591e77449a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:11:18 +0000 Subject: [PATCH 2/4] Tell draft authors why their PR is closed before it's marked ready --- .github/scripts/pr_intake_gate.js | 10 ++++++---- .github/scripts/pr_intake_gate.test.js | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/scripts/pr_intake_gate.js b/.github/scripts/pr_intake_gate.js index 24e486c3b1..b33f247a63 100644 --- a/.github/scripts/pr_intake_gate.js +++ b/.github/scripts/pr_intake_gate.js @@ -121,7 +121,7 @@ module.exports = async function run({ github, context, core }) { 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)); + await upsertGateComment(prNumber, closedComment(pr.draft, linkedIssues)); if (pr.state === 'open') { await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'closed' })); } @@ -134,10 +134,12 @@ module.exports = async function run({ github, context, core }) { // ── Comment text ───────────────────────────────────────────────────────── - function closedComment(linkedIssues) { + function closedComment(draft, linkedIssues) { const issues = linkedIssues.map((n) => `#${n}`).join(', '); - const rule = - 'This PR has been closed automatically. This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue'; + const opener = draft + ? "This PR has been closed automatically. It's still a draft, but we close those early so you don't put in more time only to have it closed the moment you mark it ready.\n\n" + : 'This PR has been closed automatically. '; + const rule = `${opener}This repo only keeps pull requests open when they come from a maintainer, or from a contributor a maintainer has assigned to the linked issue`; const situation = linkedIssues.length ? [ `${rule}, and you aren't currently assigned to ${issues}.`, diff --git a/.github/scripts/pr_intake_gate.test.js b/.github/scripts/pr_intake_gate.test.js index dceb6363b0..a7cde4bf32 100644 --- a/.github/scripts/pr_intake_gate.test.js +++ b/.github/scripts/pr_intake_gate.test.js @@ -28,7 +28,8 @@ const PEOPLE = { // ── Scenarios ────────────────────────────────────────────────────────────── // `prs` / `issues` describe the world before the event; `expect` describes each // PR afterwards: state, labels, and comment ('closed' = the "this PR has been -// closed" comment, 'cannot-reopen' = the refused-reopen comment, null = none). +// closed" comment, 'closed-draft' = its draft wording, 'cannot-reopen' = the +// refused-reopen comment, null = none). // `writes: 0` additionally asserts the gate touched nothing at all. const scenarios = [ @@ -146,10 +147,10 @@ const scenarios = [ writes: 0, }, { - name: 'draft PR with no issue link is closed like any other', + name: 'draft PR with no issue link is closed like any other, with a note about why drafts are closed early', prs: [pr(3300, 'outsider', { draft: true })], event: opened(3300, 'outsider'), - expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed' } }, + expect: { 3300: { state: 'closed', labels: [LABEL], comment: 'closed-draft' } }, }, { name: 'pre-existing draft marked ready for review with no link → closed', @@ -298,7 +299,8 @@ function observe(world, expect) { const p = world.prs.get(Number(num)); const gateComments = p.comments.filter((c) => c.user === 'github-actions[bot]' && c.body.includes('')); assert.ok(gateComments.length <= 1, `PR #${num} has ${gateComments.length} gate comments`); - const kind = !gateComments.length ? null : gateComments[0].body.includes("won't let it be reopened") ? 'cannot-reopen' : 'closed'; + const body = gateComments[0]?.body; + const kind = !body ? null : body.includes("won't let it be reopened") ? 'cannot-reopen' : body.includes('still a draft') ? 'closed-draft' : 'closed'; out[num] = { state: p.state, labels: [...p.labels].sort(), comment: kind }; if ('foreignComments' in expect[num]) out[num].foreignComments = p.comments.length - gateComments.length; } From 512ac3987f7ab8b04e922a10465f8ba0afb94202 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:19:13 +0000 Subject: [PATCH 3/4] Say "open issue" in the no-link comment --- .github/scripts/pr_intake_gate.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/pr_intake_gate.js b/.github/scripts/pr_intake_gate.js index b33f247a63..0fb2864da4 100644 --- a/.github/scripts/pr_intake_gate.js +++ b/.github/scripts/pr_intake_gate.js @@ -147,7 +147,7 @@ module.exports = async function run({ github, context, core }) { `If a maintainer assigns you to ${issues}, this PR reopens on its own and there's nothing more you need to do here. Assignment is a maintainer call based on capacity; comments that only ask to be assigned don't factor in. What does help is engaging on the issue itself by confirming the repro, explaining why it matters for your use case, or describing the approach you'd take.`, ] : [ - `${rule}, and this PR doesn't link an issue yet.`, + `${rule}, and this PR doesn't link an open issue yet.`, '', "- **If you're already assigned to an issue for this**, add `Fixes #` to the description and the PR will reopen on its own.", `- **If there's no issue yet**, please [open one](https://github.com/${owner}/${repo}/issues/new/choose) instead: what you ran into, why it matters for your use case, and a minimal reproduction. That context is super important to us and is what we use to decide what to prioritise.`, From ccd46b7d4f3f3c5bd9494b5967801d89a86c68f4 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:50:40 +0000 Subject: [PATCH 4/4] Leave CONTRIBUTING.md unchanged --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8a736213e2..57c2b14a86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,7 +20,7 @@ A PR from someone outside the maintainer team stays open when both of these hold 1. Its description links an open issue in this repository with a closing keyword (`Fixes #123`, `Closes #123`, `Resolves #123`). 2. A maintainer has assigned that issue to you, or the issue carries the [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) label (which means we'd welcome a PR for it from anyone). -Otherwise a bot labels the PR `missing-issue-link`, leaves a comment explaining this, and closes it. If that happens to yours, there's no need to open a new one: it reopens automatically as soon as a maintainer assigns you the issue, or when you edit the description to link one that qualifies. While it's closed, push updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. This applies to draft PRs, and to small fixes like typos too — for those, an issue pointing at the problem is all we need. +Otherwise a bot labels the PR `missing-issue-link`, leaves a comment explaining this, and closes it. If that happens to yours, there's no need to open a new one: it reopens automatically as soon as a maintainer assigns you the issue, or when you edit the description to link one that qualifies. While it's closed, push updates as new commits rather than force-pushing, since GitHub can't reopen a PR whose branch has been rewritten. This applies to small fixes like typos too — for those, an issue pointing at the problem is all we need. Whether to assign an issue, and to whom, is a [maintainer](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/MAINTAINERS.md#python-sdk) call, and it depends on our capacity at the time as much as on the change itself. Comments that only ask to be assigned don't factor into it, so please skip those (and don't have an agent post them). What does help is engaging with the issue itself: confirming the reproduction, asking about the intended behaviour, or briefly describing the approach you'd take. If you reported the issue and would like to fix it yourself, mention that in the issue — the reporter has first call if we do take an outside PR for it.