Clarify PR-reopen flow and fix label-race that broke auto-reopen (#4518)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jeremiah Lowin 2026-07-17 17:43:05 -04:00 committed by GitHub
commit a3ecd1edb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 104 additions and 20 deletions

73
.github/scripts/triage-label.sh vendored Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Locked-down label helper for the Marvin triage workflow.
#
# Marvin runs on untrusted issue/PR bodies from non-write users, so it must
# NOT be handed raw `gh api` (that would expose every endpoint the app token
# can reach). This helper is the ONLY GitHub write it is allowed to perform:
# it adds or removes repository labels on the one issue/PR being triaged.
#
# The target repo and number come from the environment set by the workflow —
# never from the model — and the operation is fixed to the additive labels
# endpoint (POST/DELETE /repos/{repo}/issues/{n}/labels), which works for both
# issues and PRs and cannot clobber labels applied by other workflows.
set -euo pipefail
repo="${TRIAGE_REPO:?TRIAGE_REPO not set}"
number="${TRIAGE_NUMBER:?TRIAGE_NUMBER not set}"
if [[ ! "$number" =~ ^[0-9]+$ ]]; then
echo "TRIAGE_NUMBER must be numeric, got: $number" >&2
exit 1
fi
op="${1:-}"
shift || true
case "$op" in
add) method=POST ;;
remove) method=DELETE ;;
*)
echo "usage: triage-label.sh <add|remove> <label>..." >&2
exit 1
;;
esac
if [[ "$#" -eq 0 ]]; then
echo "no labels given" >&2
exit 1
fi
# Reject anything that isn't a plausible label name. Notably blocks '/' so a
# crafted value can't turn the DELETE path into a different endpoint.
label_re="^[A-Za-z0-9 ._'-]+$"
for label in "$@"; do
if [[ ! "$label" =~ $label_re ]]; then
echo "refusing suspicious label name: $label" >&2
exit 1
fi
done
# Never let triage add or remove the Require Issue Link control labels. Those
# govern PR enforcement (bypass-issue-check / trusted-contributor are sticky
# exemptions) and reopening (missing-issue-link is how closed PRs are found),
# so a prompt-injected triage run must not be able to grant an exemption or
# break recovery. Enforced here — in code — not merely in the prompt.
protected=" missing-issue-link bypass-issue-check trusted-contributor "
for label in "$@"; do
lower="${label,,}"
if [[ "$protected" == *" $lower "* ]]; then
echo "refusing to touch protected control label: $label" >&2
exit 1
fi
done
if [[ "$method" == POST ]]; then
args=()
for label in "$@"; do
args+=(-f "labels[]=$label")
done
gh api --method POST "/repos/${repo}/issues/${number}/labels" "${args[@]}"
else
for label in "$@"; do
gh api --method DELETE "/repos/${repo}/issues/${number}/labels/${label}"
done
fi

View file

@ -49,13 +49,16 @@ jobs:
PROMPT<<PROMPT_END
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients. Your task is to analyze issues/PRs and apply appropriate labels.
IMPORTANT: Your primary action should be to apply labels using mcp__github__update_issue. DO NOT post comments EXCEPT when applying the too-long label (see below).
IMPORTANT: Your primary action should be to apply labels using the locked-down helper `.github/scripts/triage-label.sh`. DO NOT post comments EXCEPT when applying the too-long label (see below).
CRITICAL — LABEL MECHANICS:
- `mcp__github__update_issue` REPLACES all labels on the issue — it does not add to them.
- Before applying labels, read the issue's current labels with `mcp__github__get_issue`.
- Always include any existing labels you want to keep alongside the new ones.
- Apply labels ONLY through the helper, which adds or removes repository labels on THIS issue/PR. It already knows the target repo and number (from the workflow environment) — you never pass them:
add: `bash .github/scripts/triage-label.sh add "label1" "label2"`
remove: `bash .github/scripts/triage-label.sh remove "label1"`
- The helper uses the additive REST labels endpoint, so it works for both issues and PRs and never clobbers labels applied by other workflows — notably the Require Issue Link workflow's `missing-issue-link` control label, which must survive or an auto-closed PR won't reopen when its author is assigned.
- The helper is your ONLY GitHub write access. Do NOT use raw `gh api`, `gh issue edit`, `gh pr edit`, or any other mutation — they are not available to you.
- Only apply labels that exist in the repository (from `gh label list` in step 1). Never invent labels.
- Use `remove` only to correct a label you believe is wrong, and never remove the control labels `missing-issue-link`, `bypass-issue-check`, or `trusted-contributor`.
Issue/PR Information:
- REPO: ${{ github.repository }}
@ -131,7 +134,7 @@ jobs:
- DON'T MERGE: Only if PR author explicitly states it's not ready
4. Apply selected labels:
Use mcp__github__update_issue to apply your selected labels
Add them with `bash .github/scripts/triage-label.sh add "label1" "label2"`.
DO NOT post any comments unless applying too-long (see above)
PROMPT_END
EOF
@ -149,11 +152,13 @@ jobs:
allowed_non_write_users: "*"
allowed_bots: "marvin-context-protocol"
claude_args: |
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
--allowedTools Bash(gh label list),Bash(bash .github/scripts/triage-label.sh:*),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
settings: |
{
"model": "claude-sonnet-4-6",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}",
"TRIAGE_REPO": "${{ github.repository }}",
"TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}"
}
}

View file

@ -354,29 +354,30 @@ jobs:
async function enforceFailure(kind) {
await addLabel();
const intro = kind === 'no-link'
? '**This PR has been automatically closed** because its description does not reference a tracked issue.'
: '**This PR has been automatically closed** because you are not assigned to the issue it references.';
const reason = kind === 'no-link'
? "it doesn't reference a tracked issue assigned to you"
: "you aren't assigned to the issue it references";
const steps = kind === 'no-link'
? [
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`,
'2. Comment on the issue to ask a maintainer to assign it to you.',
'3. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to the PR description.',
'4. Once you are assigned and the link is present, the PR reopens automatically.',
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`,
"2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to **this** PR's description — edit it in place, don't open a new PR.",
]
: [
'1. Comment on the linked issue to ask a maintainer to assign it to you.',
'2. Once a maintainer assigns you, the PR reopens automatically.',
"1. If you opened the linked issue, a maintainer will assign you when they pick it up and this PR reopens automatically. If someone else opened it, the PR reopens only if a maintainer chooses to assign it to you — please don't comment to ask.",
];
const commentBody = [
MARKER,
intro,
"**Don't open a new pull request — this one reopens on its own.** It's closed for " +
`now because ${reason}, but the moment that's fixed it reopens automatically. Keep this ` +
'PR and edit it; opening a fresh duplicate just starts you over and creates more to triage.',
'',
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that is assigned to its author. To proceed:`,
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`,
'',
...steps,
'',
"Once you're assigned and the link is present, this PR reopens automatically — no further action needed.",
'',
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
].join('\n');