ci: require PR author is assigned to linked issue (#35692)

Extend the external PR gate to verify that the PR author is actually
assigned to the issue they reference. Previously, anyone could link to
any open issue with `Fixes #NNN` to pass the check — this closes the
loophole by fetching each linked issue via the GitHub API and comparing
assignees against the PR author (case-insensitive). The bot comment now
adapts its message based on which check failed, and updates in place if
the failure reason changes on a re-check.

## Changes
- Add assignee validation in the `check-link` step: after parsing issue
numbers from the PR body, fetch each via `github.rest.issues.get` and
check if the PR author appears in `assignees` — short-circuits on first
match
- Gate all downstream steps (`missing-issue-link` label add/remove,
comment, `setFailed`) on both `has-link` and `is-assigned` outputs
- Serve a distinct bot comment when the issue link exists but the author
isn't assigned, directing them to request assignment from a maintainer
- Update the existing marker comment in place (via `updateComment`) when
the failure reason changes between re-runs, instead of leaving a stale
message
This commit is contained in:
Mason Daugherty
2026-03-09 12:15:11 -04:00
committed by GitHub
parent 225bb5b253
commit de5d68c3fb

View File

@@ -1,9 +1,11 @@
# Require external PRs to link to an approved issue or discussion using
# GitHub auto-close keywords (Fixes #NNN, Closes #NNN, Resolves #NNN).
# GitHub auto-close keywords (Fixes #NNN, Closes #NNN, Resolves #NNN),
# AND require that the PR author is assigned to the linked issue.
#
# - Reacts to the "external" label applied by tag-external-contributions.yml,
# avoiding a duplicate org membership check.
# - Also re-checks on PR edits/reopens for PRs that already have the label.
# - Validates the PR author is an assignee on at least one linked issue.
# - Adds a "missing-issue-link" label on failure; removes it on pass.
# - Posts a comment explaining the requirement on failure.
# - Deduplicates comments via an HTML marker so re-runs don't spam.
@@ -33,7 +35,7 @@ jobs:
pull-requests: write
steps:
- name: Check for issue link
- name: Check for issue link and assignee
id: check-link
uses: actions/github-script@v8
with:
@@ -42,17 +44,45 @@ jobs:
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
const matches = [...body.matchAll(pattern)];
if (matches.length > 0) {
const issues = matches.map(m => `#${m[1]}`).join(', ');
console.log(`Found issue link(s): ${issues}`);
core.setOutput('has-link', 'true');
} else {
if (matches.length === 0) {
console.log('No issue link found in PR body');
core.setOutput('has-link', 'false');
core.setOutput('is-assigned', 'false');
return;
}
const issues = matches.map(m => `#${m[1]}`).join(', ');
console.log(`Found issue link(s): ${issues}`);
core.setOutput('has-link', 'true');
// Check whether the PR author is assigned to at least one linked issue
const { owner, repo } = context.repo;
const prAuthor = context.payload.pull_request.user.login;
const issueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
let assignedToAny = false;
for (const num of issueNumbers) {
try {
const { data: issue } = await github.rest.issues.get({
owner, repo, issue_number: num,
});
const assignees = issue.assignees.map(a => a.login.toLowerCase());
if (assignees.includes(prAuthor.toLowerCase())) {
console.log(`PR author "${prAuthor}" is assigned to #${num}`);
assignedToAny = true;
break;
} else {
console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
}
} catch (error) {
console.log(`Could not fetch issue #${num}: ${error.message}`);
}
}
core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');
- name: Add missing-issue-link label
if: steps.check-link.outputs.has-link != 'true'
if: steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true'
uses: actions/github-script@v8
with:
script: |
@@ -63,7 +93,7 @@ jobs:
});
- name: Remove missing-issue-link label
if: steps.check-link.outputs.has-link == 'true'
if: steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'
uses: actions/github-script@v8
with:
script: |
@@ -78,25 +108,39 @@ jobs:
}
- name: Post comment and fail
if: steps.check-link.outputs.has-link != 'true'
if: steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true'
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';
const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';
const marker = '<!-- require-issue-link -->';
const body = [
marker,
'**This PR is missing a linked issue.** All external contributions must reference an approved issue or discussion.',
'',
'Please add one of the following to your PR description:',
'- `Fixes #<issue_number>`',
'- `Closes #<issue_number>`',
'- `Resolves #<issue_number>`',
'',
'If no issue exists yet, [open one](https://github.com/' + owner + '/' + repo + '/issues/new/choose) and wait for maintainer approval before proceeding.',
].join('\n');
let lines;
if (!hasLink) {
lines = [
marker,
'**This PR is missing a linked issue.** All external contributions must reference an approved issue or discussion.',
'',
'Please add one of the following to your PR description:',
'- `Fixes #<issue_number>`',
'- `Closes #<issue_number>`',
'- `Resolves #<issue_number>`',
'',
'If no issue exists yet, [open one](https://github.com/' + owner + '/' + repo + '/issues/new/choose) and wait for maintainer approval before proceeding.',
];
} else {
lines = [
marker,
'**You are not assigned to the linked issue.** External contributors must be assigned to an issue before opening a PR for it.',
'',
'Please comment on the issue to request assignment from a maintainer, then update this PR once you have been assigned.',
];
}
const body = lines.join('\n');
// Deduplicate: check for existing comment with the marker
const comments = await github.paginate(
@@ -112,9 +156,20 @@ jobs:
issue_number: prNumber,
body,
});
console.log('Posted issue-link requirement comment');
console.log('Posted requirement comment');
} else if (existing.body !== body) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
console.log('Updated existing comment with new message');
} else {
console.log('Comment already exists — skipping');
}
core.setFailed('PR must reference an issue using auto-close keywords (e.g., "Fixes #123").');
const reason = !hasLink
? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'
: 'PR author must be assigned to the linked issue.';
core.setFailed(reason);