.github/workflows/require_issue_link.yml YAML 468 lines View on github.com → Search inside
1# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and2# the PR author to be assigned to that issue. On failure the PR is3# labeled "missing-issue-link", commented on, and closed.4#5# Maintainer override: an org member can reopen the PR or remove6# "missing-issue-link"  both add "bypass-issue-check" and reopen.7#8# Dependency: pr_labeler.yml must apply the "external" label first. This9# workflow does NOT trigger on "opened" (new PRs have no labels yet, so the10# gate would always skip).1112name: Require Issue Link1314on:15  pull_request_target:16    # NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.17    # Doing so would allow attackers to execute arbitrary code in the context of your repository.18    types: [edited, reopened, labeled, unlabeled]1920# ──────────────────────────────────────────────────────────────────────────────21# Enforcement gate: set to 'true' to activate the issue link requirement.22# When 'false', the workflow still runs the check logic (useful for dry-run23# visibility) but will NOT label, comment, close, or fail PRs.24# ──────────────────────────────────────────────────────────────────────────────25env:26  ENFORCE_ISSUE_LINK: "true"2728permissions:29  contents: read3031jobs:32  check-issue-link:33    # Run when the "external" label is added, on edit/reopen if already labeled,34    # or when "missing-issue-link" is removed (triggers maintainer override check).35    # Skip entirely when the PR already carries "trusted-contributor" or36    # "bypass-issue-check".37    if: >-38      !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&39      !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&40      (41        (github.event.action == 'labeled' && github.event.label.name == 'external') ||42        (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) ||43        (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external'))44      )45    runs-on: ubuntu-latest46    permissions:47      actions: write48      pull-requests: write4950    steps:51      - name: Check for issue link and assignee52        id: check-link53        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.054        with:55          script: |56            const { owner, repo } = context.repo;57            const prNumber = context.payload.pull_request.number;58            const action = context.payload.action;5960            // ── Helper: ensure a label exists, then add it to the PR ────────61            async function ensureAndAddLabel(labelName, color) {62              try {63                await github.rest.issues.getLabel({ owner, repo, name: labelName });64              } catch (e) {65                if (e.status !== 404) throw e;66                try {67                  await github.rest.issues.createLabel({ owner, repo, name: labelName, color });68                } catch (createErr) {69                  // 422 = label was created by a concurrent run between our70                  // GET and POST — safe to ignore.71                  if (createErr.status !== 422) throw createErr;72                }73              }74              await github.rest.issues.addLabels({75                owner, repo, issue_number: prNumber, labels: [labelName],76              });77            }7879            // ── Helper: check if the user who triggered this event (reopened80            // the PR / removed the label) has write+ access on the repo ───81            // Uses the repo collaborator permission endpoint instead of the82            // org membership endpoint. The org endpoint requires the caller83            // to be an org member, which GITHUB_TOKEN (an app installation84            // token) never is — so it always returns 403.85            async function senderIsOrgMember() {86              const sender = context.payload.sender?.login;87              if (!sender) {88                throw new Error('Event has no sender — cannot check permissions');89              }90              try {91                const { data } = await github.rest.repos.getCollaboratorPermissionLevel({92                  owner, repo, username: sender,93                });94                const perm = data.permission;95                if (['admin', 'maintain', 'write'].includes(perm)) {96                  console.log(`${sender} has ${perm} permission — treating as maintainer`);97                  return { isMember: true, login: sender };98                }99                console.log(`${sender} has ${perm} permission — not a maintainer`);100                return { isMember: false, login: sender };101              } catch (e) {102                if (e.status === 404) {103                  console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`);104                  return { isMember: false, login: sender };105                }106                const status = e.status ?? 'unknown';107                throw new Error(108                  `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`,109                );110              }111            }112113            // ── Helper: apply maintainer bypass (shared by both override paths) ──114            async function applyMaintainerBypass(reason) {115              console.log(reason);116117              // Remove missing-issue-link if present118              try {119                await github.rest.issues.removeLabel({120                  owner, repo, issue_number: prNumber, name: 'missing-issue-link',121                });122              } catch (e) {123                if (e.status !== 404) throw e;124              }125126              // Reopen before adding bypass label — a failed reopen is more127              // actionable than a closed PR with a bypass label stuck on it.128              if (context.payload.pull_request.state === 'closed') {129                try {130                  await github.rest.pulls.update({131                    owner, repo, pull_number: prNumber, state: 'open',132                  });133                  console.log(`Reopened PR #${prNumber}`);134                } catch (e) {135                  // 422 if head branch deleted; 403 if permissions insufficient.136                  // Bypass labels still apply — maintainer can reopen manually.137                  core.warning(138                    `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +139                    `Bypass labels were applied — a maintainer may need to reopen manually.`,140                  );141                }142              }143144              // Add bypass-issue-check so future triggers skip enforcement145              await ensureAndAddLabel('bypass-issue-check', '0e8a16');146147              // Minimize stale enforcement comment (best-effort; must not148              // abort bypass — sync w/ reopen_on_assignment.yml & step below)149              try {150                const marker = '<!-- require-issue-link -->';151                const comments = await github.paginate(152                  github.rest.issues.listComments,153                  { owner, repo, issue_number: prNumber, per_page: 100 },154                );155                const stale = comments.find(c => c.body && c.body.includes(marker));156                if (stale) {157                  await github.graphql(`158                    mutation($id: ID!) {159                      minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {160                        minimizedComment { isMinimized }161                      }162                    }163                  `, { id: stale.node_id });164                  console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);165                }166              } catch (e) {167                core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);168              }169170              core.setOutput('has-link', 'true');171              core.setOutput('is-assigned', 'true');172            }173174            // ── Maintainer override: removed "missing-issue-link" label ─────175            if (action === 'unlabeled') {176              const { isMember, login } = await senderIsOrgMember();177              if (isMember) {178                await applyMaintainerBypass(179                  `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`,180                );181                return;182              }183              // Non-member removed the label — re-add it defensively and184              // set failure outputs so downstream steps (comment, close) fire.185              // NOTE: addLabels fires a "labeled" event, but the job-level gate186              // only matches labeled events for "external", so no re-trigger.187              console.log(`Non-member ${login} removed missing-issue-link — re-adding`);188              try {189                await ensureAndAddLabel('missing-issue-link', 'b76e79');190              } catch (e) {191                core.warning(192                  `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +193                  `Downstream step will retry.`,194                );195              }196              core.setOutput('has-link', 'false');197              core.setOutput('is-assigned', 'false');198              return;199            }200201            // ── Maintainer override: reopened PR with "missing-issue-link" ──202            const prLabels = context.payload.pull_request.labels.map(l => l.name);203            if (action === 'reopened' && prLabels.includes('missing-issue-link')) {204              const { isMember, login } = await senderIsOrgMember();205              if (isMember) {206                await applyMaintainerBypass(207                  `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`,208                );209                return;210              }211              console.log(`Non-member ${login} reopened PR — proceeding with check`);212            }213214            // ── Fetch live labels (race guard) ──────────────────────────────215            const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({216              owner, repo, issue_number: prNumber,217            });218            const liveNames = liveLabels.map(l => l.name);219            if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {220              console.log('PR has trusted-contributor or bypass-issue-check label — bypassing');221              core.setOutput('has-link', 'true');222              core.setOutput('is-assigned', 'true');223              return;224            }225226            const body = context.payload.pull_request.body || '';227            const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;228            const matches = [...body.matchAll(pattern)];229230            if (matches.length === 0) {231              console.log('No issue link found in PR body');232              core.setOutput('has-link', 'false');233              core.setOutput('is-assigned', 'false');234              return;235            }236237            const issues = matches.map(m => `#${m[1]}`).join(', ');238            console.log(`Found issue link(s): ${issues}`);239            core.setOutput('has-link', 'true');240241            // Check whether the PR author is assigned to at least one linked issue242            const prAuthor = context.payload.pull_request.user.login;243            const MAX_ISSUES = 5;244            const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];245            const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES);246            if (allIssueNumbers.length > MAX_ISSUES) {247              core.warning(248                `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`,249              );250            }251252            let assignedToAny = false;253            for (const num of issueNumbers) {254              try {255                const { data: issue } = await github.rest.issues.get({256                  owner, repo, issue_number: num,257                });258                const assignees = issue.assignees.map(a => a.login.toLowerCase());259                if (assignees.includes(prAuthor.toLowerCase())) {260                  console.log(`PR author "${prAuthor}" is assigned to #${num}`);261                  assignedToAny = true;262                  break;263                } else {264                  console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);265                }266              } catch (error) {267                if (error.status === 404) {268                  console.log(`Issue #${num} not found — skipping`);269                } else {270                  // Non-404 errors (rate limit, server error) must not be271                  // silently skipped — they could cause false enforcement272                  // (closing a legitimate PR whose assignment can't be verified).273                  throw new Error(274                    `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`,275                  );276                }277              }278            }279280            core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');281282      - name: Add missing-issue-link label283        if: >-284          env.ENFORCE_ISSUE_LINK == 'true' &&285          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')286        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0287        with:288          script: |289            const { owner, repo } = context.repo;290            const prNumber = context.payload.pull_request.number;291            const labelName = 'missing-issue-link';292293            // Ensure the label exists (no checkout/shared helper available)294            try {295              await github.rest.issues.getLabel({ owner, repo, name: labelName });296            } catch (e) {297              if (e.status !== 404) throw e;298              try {299                await github.rest.issues.createLabel({300                  owner, repo, name: labelName, color: 'b76e79',301                });302              } catch (createErr) {303                if (createErr.status !== 422) throw createErr;304              }305            }306307            await github.rest.issues.addLabels({308              owner, repo, issue_number: prNumber, labels: [labelName],309            });310311      - name: Remove missing-issue-link label and reopen PR312        if: >-313          env.ENFORCE_ISSUE_LINK == 'true' &&314          steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'315        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0316        with:317          script: |318            const { owner, repo } = context.repo;319            const prNumber = context.payload.pull_request.number;320            try {321              await github.rest.issues.removeLabel({322                owner, repo, issue_number: prNumber, name: 'missing-issue-link',323              });324            } catch (error) {325              if (error.status !== 404) throw error;326            }327328            // Reopen if this workflow previously closed the PR. We check the329            // event payload labels (not live labels) because we already removed330            // missing-issue-link above; the payload still reflects pre-step state.331            const labels = context.payload.pull_request.labels.map(l => l.name);332            if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {333              await github.rest.pulls.update({334                owner,335                repo,336                pull_number: prNumber,337                state: 'open',338              });339              console.log(`Reopened PR #${prNumber}`);340            }341342            // Minimize stale enforcement comment (best-effort;343            // sync w/ applyMaintainerBypass above & reopen_on_assignment.yml)344            try {345              const marker = '<!-- require-issue-link -->';346              const comments = await github.paginate(347                github.rest.issues.listComments,348                { owner, repo, issue_number: prNumber, per_page: 100 },349              );350              const stale = comments.find(c => c.body && c.body.includes(marker));351              if (stale) {352                await github.graphql(`353                  mutation($id: ID!) {354                    minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {355                      minimizedComment { isMinimized }356                    }357                  }358                `, { id: stale.node_id });359                console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);360              }361            } catch (e) {362              core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);363            }364365      - name: Post comment, close PR, and fail366        if: >-367          env.ENFORCE_ISSUE_LINK == 'true' &&368          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')369        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0370        with:371          script: |372            const { owner, repo } = context.repo;373            const prNumber = context.payload.pull_request.number;374            const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';375            const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';376            const marker = '<!-- require-issue-link -->';377378            let lines;379            if (!hasLink) {380              lines = [381                marker,382                '**This PR has been automatically closed** because it does not link to an approved issue.',383                '',384                'All external contributions must reference an approved issue or discussion. Please:',385                '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',386                '2. Wait for a maintainer to approve and assign you',387                '3. Add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to your PR description and the PR will be reopened automatically',388                '',389                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',390              ];391            } else {392              lines = [393                marker,394                '**This PR has been automatically closed** because you are not assigned to the linked issue.',395                '',396                'Opening a PR is **not** an indication that it will be accepted. This process exists so maintainers can confirm a change is aligned with the project direction *before* you invest time implementing it. Please:',397                '1. Comment on the linked issue explaining the approach you would like to take and why — include enough detail for a maintainer to evaluate the design. Do **not** post a drive-by "please assign me" comment with no substance; those will be ignored.',398                '2. Wait for a maintainer to assign you. Once assigned, your PR will be reopened automatically.',399                '',400                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',401              ];402            }403404            const body = lines.join('\n');405406            // Deduplicate: check for existing comment with the marker407            const comments = await github.paginate(408              github.rest.issues.listComments,409              { owner, repo, issue_number: prNumber, per_page: 100 },410            );411            const existing = comments.find(c => c.body && c.body.includes(marker));412413            if (!existing) {414              await github.rest.issues.createComment({415                owner,416                repo,417                issue_number: prNumber,418                body,419              });420              console.log('Posted requirement comment');421            } else if (existing.body !== body) {422              await github.rest.issues.updateComment({423                owner,424                repo,425                comment_id: existing.id,426                body,427              });428              console.log('Updated existing comment with new message');429            } else {430              console.log('Comment already exists — skipping');431            }432433            // Close the PR434            if (context.payload.pull_request.state === 'open') {435              await github.rest.pulls.update({436                owner,437                repo,438                pull_number: prNumber,439                state: 'closed',440              });441              console.log(`Closed PR #${prNumber}`);442            }443444            // Cancel all other in-progress and queued workflow runs for this PR445            const headSha = context.payload.pull_request.head.sha;446            for (const status of ['in_progress', 'queued']) {447              const runs = await github.paginate(448                github.rest.actions.listWorkflowRunsForRepo,449                { owner, repo, head_sha: headSha, status, per_page: 100 },450              );451              for (const run of runs) {452                if (run.id === context.runId) continue;453                try {454                  await github.rest.actions.cancelWorkflowRun({455                    owner, repo, run_id: run.id,456                  });457                  console.log(`Cancelled ${status} run ${run.id} (${run.name})`);458                } catch (err) {459                  console.log(`Could not cancel run ${run.id}: ${err.message}`);460                }461              }462            }463464            const reason = !hasLink465              ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'466              : 'PR author must be assigned to the linked issue.';467            core.setFailed(reason);

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.