|
| 1 | +name: PR Issue Link & Assignment Check |
| 2 | + |
| 3 | +on: |
| 4 | + pull_request_target: |
| 5 | + types: [opened, edited, reopened, synchronize] |
| 6 | + |
| 7 | +permissions: |
| 8 | + issues: write |
| 9 | + pull-requests: write |
| 10 | + |
| 11 | +jobs: |
| 12 | + check-issue-link: |
| 13 | + name: Verify PR is Linked to an Assigned Issue |
| 14 | + runs-on: ubuntu-latest |
| 15 | + |
| 16 | + steps: |
| 17 | + - name: Check linked issue and assignment |
| 18 | + uses: actions/github-script@v7 |
| 19 | + with: |
| 20 | + github-token: ${{ secrets.GITHUB_TOKEN }} |
| 21 | + script: | |
| 22 | + const { owner, repo } = context.repo; |
| 23 | + const pr = context.payload.pull_request; |
| 24 | + const prNumber = pr.number; |
| 25 | + const prAuthor = pr.user.login; |
| 26 | + const prBody = pr.body ?? ''; |
| 27 | +
|
| 28 | + // ---------------------------------------------------------------- |
| 29 | + // 1. Parse a linked issue number from the PR body. |
| 30 | + // Matches: closes/fixes/resolves #123 (case-insensitive) |
| 31 | + // ---------------------------------------------------------------- |
| 32 | + const LINK_REGEX = |
| 33 | + /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi; |
| 34 | +
|
| 35 | + const issueNumbers = []; |
| 36 | + let match; |
| 37 | + while ((match = LINK_REGEX.exec(prBody)) !== null) { |
| 38 | + issueNumbers.push(parseInt(match[1], 10)); |
| 39 | + } |
| 40 | +
|
| 41 | + const SENTINEL_NO_LINK = '<!-- pr-issue-check: no-link -->'; |
| 42 | + const SENTINEL_NOT_ASSIGNED = '<!-- pr-issue-check: not-assigned -->'; |
| 43 | +
|
| 44 | + // Helper: fetch existing bot comments on this PR |
| 45 | + async function getBotComments() { |
| 46 | + const { data: comments } = await github.rest.issues.listComments({ |
| 47 | + owner, repo, issue_number: prNumber, per_page: 100, |
| 48 | + }); |
| 49 | + return comments.filter(c => c.user?.login === 'github-actions[bot]'); |
| 50 | + } |
| 51 | +
|
| 52 | + // Helper: check whether we already posted a specific sentinel |
| 53 | + async function alreadyPosted(sentinel) { |
| 54 | + const comments = await getBotComments(); |
| 55 | + return comments.some(c => c.body?.includes(sentinel)); |
| 56 | + } |
| 57 | +
|
| 58 | + // ---------------------------------------------------------------- |
| 59 | + // 2. NO linked issue found → warn and stop (do NOT close the PR) |
| 60 | + // ---------------------------------------------------------------- |
| 61 | + if (issueNumbers.length === 0) { |
| 62 | + const already = await alreadyPosted(SENTINEL_NO_LINK); |
| 63 | + if (!already) { |
| 64 | + await github.rest.issues.createComment({ |
| 65 | + owner, repo, issue_number: prNumber, |
| 66 | + body: `👋 Hey @${prAuthor}! Thanks for your contribution! 🎉 |
| 67 | +
|
| 68 | + ${SENTINEL_NO_LINK} |
| 69 | + It looks like this PR isn't linked to any issue yet. |
| 70 | +
|
| 71 | + Please edit your PR description and add a closing keyword so we can track this properly, for example: |
| 72 | +
|
| 73 | + \`\`\` |
| 74 | + Fixes #<issue-number> |
| 75 | + \`\`\` |
| 76 | +
|
| 77 | + > 💡 You can link multiple issues if needed (e.g. \`Fixes #12, Closes #34\`). |
| 78 | + > If you're working on something that doesn't have an issue yet, please [open one first](https://github.com/${owner}/${repo}/issues/new/choose) and then link it here. |
| 79 | +
|
| 80 | + Once you've updated the PR description the check will re-run automatically. 🙌`, |
| 81 | + }); |
| 82 | + } |
| 83 | + return; // stop here — don't close, just inform |
| 84 | + } |
| 85 | +
|
| 86 | + // ---------------------------------------------------------------- |
| 87 | + // 3. Linked issue(s) found — check assignment for each one. |
| 88 | + // We validate all linked issues and act on the first violation. |
| 89 | + // ---------------------------------------------------------------- |
| 90 | + for (const issueNumber of issueNumbers) { |
| 91 | + let issue; |
| 92 | + try { |
| 93 | + const { data } = await github.rest.issues.get({ |
| 94 | + owner, repo, issue_number: issueNumber, |
| 95 | + }); |
| 96 | + issue = data; |
| 97 | + } catch (err) { |
| 98 | + if (err.status === 404) { |
| 99 | + // Linked issue doesn't exist — treat same as no-link |
| 100 | + const already = await alreadyPosted(SENTINEL_NO_LINK); |
| 101 | + if (!already) { |
| 102 | + await github.rest.issues.createComment({ |
| 103 | + owner, repo, issue_number: prNumber, |
| 104 | + body: `👋 Hey @${prAuthor}! |
| 105 | +
|
| 106 | + ${SENTINEL_NO_LINK} |
| 107 | + This PR references issue **#${issueNumber}**, but that issue doesn't seem to exist (or was deleted). |
| 108 | +
|
| 109 | + Please link a valid open issue before this PR can be reviewed. 🙏`, |
| 110 | + }); |
| 111 | + } |
| 112 | + return; |
| 113 | + } |
| 114 | + throw err; |
| 115 | + } |
| 116 | +
|
| 117 | + // Is the PR author assigned to this issue? |
| 118 | + const assignees = issue.assignees.map(a => a.login.toLowerCase()); |
| 119 | + const isAssigned = assignees.includes(prAuthor.toLowerCase()); |
| 120 | +
|
| 121 | + if (!isAssigned) { |
| 122 | + // Close the PR with a polite explanation |
| 123 | + const already = await alreadyPosted(SENTINEL_NOT_ASSIGNED); |
| 124 | + if (!already) { |
| 125 | + await github.rest.issues.createComment({ |
| 126 | + owner, repo, issue_number: prNumber, |
| 127 | + body: `👋 Hey @${prAuthor}! Thanks for your interest in contributing to CommitPulse! 🙏 |
| 128 | +
|
| 129 | + ${SENTINEL_NOT_ASSIGNED} |
| 130 | + Unfortunately, this PR has been **automatically closed** because you are not assigned to the linked issue **[#${issueNumber} — ${issue.title}](${issue.html_url})**. |
| 131 | +
|
| 132 | + To avoid this in the future, please follow these steps: |
| 133 | +
|
| 134 | + 1. **Claim the issue** — Comment \`/claim\` on [#${issueNumber}](${issue.html_url}) if you are the issue author, or ask a maintainer to \`/assign\` you. |
| 135 | + 2. **Wait for confirmation** — The bot will confirm your assignment with a ✅ reply. |
| 136 | + 3. **Then open your PR** — Link the issue with \`Fixes #${issueNumber}\` in your description. |
| 137 | +
|
| 138 | + > 💡 You can be assigned to up to **3** open issues at a time. Check your current assignments before claiming a new one. |
| 139 | +
|
| 140 | + We look forward to your contribution once you're assigned! 🚀`, |
| 141 | + }); |
| 142 | + } |
| 143 | +
|
| 144 | + // Close the PR |
| 145 | + await github.rest.pulls.update({ |
| 146 | + owner, repo, pull_number: prNumber, state: 'closed', |
| 147 | + }); |
| 148 | +
|
| 149 | + return; // stop after first violation |
| 150 | + } |
| 151 | + } |
| 152 | +
|
| 153 | + // ---------------------------------------------------------------- |
| 154 | + // 4. All checks passed — nothing to do. |
| 155 | + // ---------------------------------------------------------------- |
| 156 | + core.info(`✅ PR #${prNumber} by @${prAuthor} is properly linked and assigned.`); |
0 commit comments