# Require external PRs to link to an approved issue or discussion using # 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. # # Dependency: tag-external-contributions.yml must run first to apply the # "external" label on new PRs. Both workflows trigger on pull_request_target # opened events; this workflow additionally listens for the "labeled" event # to chain off the external classification. name: Require Issue Link on: pull_request_target: types: [opened, edited, reopened, labeled] permissions: contents: read jobs: check-issue-link: # Run when the "external" label is added, or on edit/reopen if already labeled if: >- (github.event.action == 'labeled' && github.event.label.name == 'external') || (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'external')) runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Check for issue link and assignee id: check-link uses: actions/github-script@v8 with: script: | const body = context.payload.pull_request.body || ''; const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\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'); 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' || 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; await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: ['missing-issue-link'], }); - name: Remove missing-issue-link label 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; try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'missing-issue-link', }); } catch (error) { if (error.status !== 404) throw error; } - name: Post comment and fail 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 = ''; 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 #`', '- `Closes #`', '- `Resolves #`', '', '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( 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 existing comment with new message'); } else { console.log('Comment already exists — skipping'); } 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);