diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml index c3181d5b2a6..382d5dd70cb 100644 --- a/.github/workflows/require_issue_link.yml +++ b/.github/workflows/require_issue_link.yml @@ -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 = ''; - 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 #`', - '- `Closes #`', - '- `Resolves #`', - '', - '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 #`', + '- `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( @@ -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);