ci: require external PRs to link a tracked issue

This commit is contained in:
strawgate 2026-05-17 14:43:17 -05:00
commit 8c3c5a54a9

386
.github/workflows/require-issue-link.yml vendored Normal file
View file

@ -0,0 +1,386 @@
# Require external PRs to reference an issue with an auto-close keyword
# (e.g. "Fixes #123"). On failure the PR is labeled "missing-issue-link",
# commented on, and closed. CONTRIBUTING.md requires every PR to address a
# tracked issue; this enforces that for outside contributors.
#
# Adapted from langchain-ai/langchain's require_issue_link.yml. Differences:
# - Self-contained: "external" is derived from the PR author's
# author_association, not a label applied by a separate labeler
# workflow, so this can also run on `opened`.
# - No assignee requirement. fastmcp's CONTRIBUTING.md states that
# referencing an issue "isn't a permission step" — only the link is
# enforced, not issue assignment.
#
# Maintainer override: reopen the PR, or remove the "missing-issue-link"
# label — either applies "bypass-issue-check" and reopens.
name: Require Issue Link
on:
pull_request_target:
# SECURITY: this is a pull_request_target workflow. It runs with repo
# write scope against the BASE repo. NEVER check out or execute code
# from the PR head here — it would let a PR run arbitrary code with
# these permissions. This workflow only reads the PR payload via the
# GitHub API; it never checks anything out.
types: [opened, edited, reopened, labeled, unlabeled]
# Set to 'false' for a dry run: the check still runs and logs its verdict
# but will NOT label, comment, close, or fail PRs. Flip to 'true' to enforce.
env:
ENFORCE_ISSUE_LINK: "true"
permissions:
contents: read
concurrency:
group: require-issue-link-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
check-issue-link:
# Skip drafts, bots, maintainers (write+ access shows up as OWNER /
# MEMBER / COLLABORATOR), and PRs already bypassed or trusted. Only the
# primary actions plus the maintainer-override action (removing the
# missing-issue-link label) get past this gate.
if: >-
github.event.pull_request.draft == false &&
github.actor != 'dependabot[bot]' &&
!endsWith(github.actor, '[bot]') &&
!contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) &&
!contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
!contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
(
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
(github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link')
)
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
actions: write
pull-requests: write
steps:
- name: Check for issue link
id: check-link
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const action = context.payload.action;
// ── Ensure a label exists, then add it to the PR ───────────────
async function ensureAndAddLabel(labelName, color) {
try {
await github.rest.issues.getLabel({ owner, repo, name: labelName });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name: labelName, color });
} catch (createErr) {
// 422 = created by a concurrent run between GET and POST.
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [labelName],
});
}
// ── Does the event sender have write+ access on this repo? ─────
// Uses the collaborator-permission endpoint, not org membership:
// GITHUB_TOKEN is an app installation token and is never an org
// member, so the org endpoint always 403s.
async function senderIsMaintainer() {
const sender = context.payload.sender?.login;
if (!sender) {
throw new Error('Event has no sender — cannot check permissions');
}
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username: sender,
});
const perm = data.permission;
if (['admin', 'maintain', 'write'].includes(perm)) {
console.log(`${sender} has ${perm} permission — treating as maintainer`);
return { isMaintainer: true, login: sender };
}
console.log(`${sender} has ${perm} permission — not a maintainer`);
return { isMaintainer: false, login: sender };
} catch (e) {
if (e.status === 404) {
console.log(`Cannot resolve permissions for ${sender} — treating as non-maintainer`);
return { isMaintainer: false, login: sender };
}
throw new Error(
`Permission check failed for ${sender} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
}
const MARKER = '<!-- require-issue-link -->';
// ── Minimize a stale enforcement comment, if any (best-effort) ──
async function minimizeStaleComment() {
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(MARKER));
if (stale) {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id });
console.log(`Minimized stale enforcement comment ${stale.id}`);
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
}
// ── Maintainer bypass: clear enforcement state and reopen ──────
async function applyMaintainerBypass(reason) {
console.log(reason);
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'missing-issue-link',
});
} catch (e) {
if (e.status !== 404) throw e;
}
if (context.payload.pull_request.state === 'closed') {
try {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
});
console.log(`Reopened PR #${prNumber}`);
} catch (e) {
core.warning(
`Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +
`Bypass label was applied — reopen manually if needed.`,
);
}
}
await ensureAndAddLabel('bypass-issue-check', '0e8a16');
await minimizeStaleComment();
core.setOutput('has-link', 'true');
}
// ── Maintainer override: removed "missing-issue-link" label ────
if (action === 'unlabeled') {
const { isMaintainer, login } = await senderIsMaintainer();
if (isMaintainer) {
await applyMaintainerBypass(
`Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing`,
);
return;
}
// Non-maintainer stripped the label — re-add it and let the
// downstream steps re-enforce. addLabels fires a "labeled"
// event, but the job gate ignores labeled events, so there is
// no re-trigger loop.
console.log(`Non-maintainer ${login} removed missing-issue-link — re-adding`);
try {
await ensureAndAddLabel('missing-issue-link', 'b76e79');
} catch (e) {
core.warning(
`Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
core.setOutput('has-link', 'false');
return;
}
// ── Maintainer override: reopened a PR we had closed ───────────
const prLabels = context.payload.pull_request.labels.map(l => l.name);
if (action === 'reopened' && prLabels.includes('missing-issue-link')) {
const { isMaintainer, login } = await senderIsMaintainer();
if (isMaintainer) {
await applyMaintainerBypass(
`Maintainer ${login} reopened PR #${prNumber} — bypassing`,
);
return;
}
console.log(`Non-maintainer ${login} reopened PR — proceeding with check`);
}
// ── Race guard: re-read live labels ────────────────────────────
const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber,
});
const liveNames = liveLabels.map(l => l.name);
if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
console.log('PR carries trusted-contributor or bypass-issue-check — skipping');
core.setOutput('has-link', 'true');
return;
}
// ── The actual check: an auto-close keyword + issue number ─────
const body = context.payload.pull_request.body || '';
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\d+)/gi;
const matches = [...body.matchAll(pattern)];
if (matches.length === 0) {
console.log('No issue link found in PR body');
core.setOutput('has-link', 'false');
return;
}
const issues = [...new Set(matches.map(m => `#${m[1]}`))].join(', ');
console.log(`Found issue link(s): ${issues}`);
core.setOutput('has-link', 'true');
- name: Add missing-issue-link label
if: >-
env.ENFORCE_ISSUE_LINK == 'true' &&
steps.check-link.outputs.has-link != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const labelName = 'missing-issue-link';
try {
await github.rest.issues.getLabel({ owner, repo, name: labelName });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
owner, repo, name: labelName, color: 'b76e79',
});
} catch (createErr) {
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [labelName],
});
- name: Clear missing-issue-link and reopen
if: >-
env.ENFORCE_ISSUE_LINK == 'true' &&
steps.check-link.outputs.has-link == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'missing-issue-link',
});
} catch (e) {
if (e.status !== 404) throw e;
}
// Reopen only if this workflow had closed the PR (payload labels
// still reflect pre-removal state).
const labels = context.payload.pull_request.labels.map(l => l.name);
if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
});
console.log(`Reopened PR #${prNumber}`);
}
const MARKER = '<!-- require-issue-link -->';
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(MARKER));
if (stale) {
await github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id });
console.log(`Minimized stale enforcement comment ${stale.id}`);
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
- name: Comment, close, and fail
if: >-
env.ENFORCE_ISSUE_LINK == 'true' &&
steps.check-link.outputs.has-link != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const MARKER = '<!-- require-issue-link -->';
const body = [
MARKER,
'**This PR has been automatically closed** because its description does not reference a tracked issue.',
'',
'Per [CONTRIBUTING.md](https://github.com/' + owner + '/' + repo + '/blob/main/CONTRIBUTING.md), every PR should address a tracked issue. To proceed:',
'',
'1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change.',
'2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to the PR description.',
'3. The PR reopens automatically once the description links an issue.',
'',
'*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',
].join('\n');
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const existing = comments.find(c => c.body && c.body.includes(MARKER));
if (!existing) {
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body,
});
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 requirement comment');
} else {
console.log('Requirement comment already present — skipping');
}
if (context.payload.pull_request.state === 'open') {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'closed',
});
console.log(`Closed PR #${prNumber}`);
}
// Cancel this PR's other in-progress / queued runs — no point
// burning CI on a PR we just closed.
const headSha = context.payload.pull_request.head.sha;
for (const status of ['in_progress', 'queued']) {
const runs = await github.paginate(
github.rest.actions.listWorkflowRunsForRepo,
{ owner, repo, head_sha: headSha, status, per_page: 100 },
);
for (const run of runs) {
if (run.id === context.runId) continue;
try {
await github.rest.actions.cancelWorkflowRun({
owner, repo, run_id: run.id,
});
console.log(`Cancelled ${status} run ${run.id} (${run.name})`);
} catch (err) {
console.log(`Could not cancel run ${run.id}: ${err.message}`);
}
}
}
core.setFailed(
'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").',
);