.github/workflows/require_issue_link.yml YAML 469 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      github.repository_owner == 'langchain-ai' &&39      !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&40      !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&41      (42        (github.event.action == 'labeled' && github.event.label.name == 'external') ||43        (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) ||44        (github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external'))45      )46    runs-on: ubuntu-latest47    permissions:48      actions: write49      pull-requests: write5051    steps:52      - name: Check for issue link and assignee53        id: check-link54        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.055        with:56          script: |57            const { owner, repo } = context.repo;58            const prNumber = context.payload.pull_request.number;59            const action = context.payload.action;6061            // ── Helper: ensure a label exists, then add it to the PR ────────62            async function ensureAndAddLabel(labelName, color) {63              try {64                await github.rest.issues.getLabel({ owner, repo, name: labelName });65              } catch (e) {66                if (e.status !== 404) throw e;67                try {68                  await github.rest.issues.createLabel({ owner, repo, name: labelName, color });69                } catch (createErr) {70                  // 422 = label was created by a concurrent run between our71                  // GET and POST — safe to ignore.72                  if (createErr.status !== 422) throw createErr;73                }74              }75              await github.rest.issues.addLabels({76                owner, repo, issue_number: prNumber, labels: [labelName],77              });78            }7980            // ── Helper: check if the user who triggered this event (reopened81            // the PR / removed the label) has write+ access on the repo ───82            // Uses the repo collaborator permission endpoint instead of the83            // org membership endpoint. The org endpoint requires the caller84            // to be an org member, which GITHUB_TOKEN (an app installation85            // token) never is — so it always returns 403.86            async function senderIsOrgMember() {87              const sender = context.payload.sender?.login;88              if (!sender) {89                throw new Error('Event has no sender — cannot check permissions');90              }91              try {92                const { data } = await github.rest.repos.getCollaboratorPermissionLevel({93                  owner, repo, username: sender,94                });95                const perm = data.permission;96                if (['admin', 'maintain', 'write'].includes(perm)) {97                  console.log(`${sender} has ${perm} permission — treating as maintainer`);98                  return { isMember: true, login: sender };99                }100                console.log(`${sender} has ${perm} permission — not a maintainer`);101                return { isMember: false, login: sender };102              } catch (e) {103                if (e.status === 404) {104                  console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`);105                  return { isMember: false, login: sender };106                }107                const status = e.status ?? 'unknown';108                throw new Error(109                  `Permission check failed for ${sender} (HTTP ${status}): ${e.message}`,110                );111              }112            }113114            // ── Helper: apply maintainer bypass (shared by both override paths) ──115            async function applyMaintainerBypass(reason) {116              console.log(reason);117118              // Remove missing-issue-link if present119              try {120                await github.rest.issues.removeLabel({121                  owner, repo, issue_number: prNumber, name: 'missing-issue-link',122                });123              } catch (e) {124                if (e.status !== 404) throw e;125              }126127              // Reopen before adding bypass label — a failed reopen is more128              // actionable than a closed PR with a bypass label stuck on it.129              if (context.payload.pull_request.state === 'closed') {130                try {131                  await github.rest.pulls.update({132                    owner, repo, pull_number: prNumber, state: 'open',133                  });134                  console.log(`Reopened PR #${prNumber}`);135                } catch (e) {136                  // 422 if head branch deleted; 403 if permissions insufficient.137                  // Bypass labels still apply — maintainer can reopen manually.138                  core.warning(139                    `Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +140                    `Bypass labels were applied — a maintainer may need to reopen manually.`,141                  );142                }143              }144145              // Add bypass-issue-check so future triggers skip enforcement146              await ensureAndAddLabel('bypass-issue-check', '0e8a16');147148              // Minimize stale enforcement comment (best-effort; must not149              // abort bypass — sync w/ reopen_on_assignment.yml & step below)150              try {151                const marker = '<!-- require-issue-link -->';152                const comments = await github.paginate(153                  github.rest.issues.listComments,154                  { owner, repo, issue_number: prNumber, per_page: 100 },155                );156                const stale = comments.find(c => c.body && c.body.includes(marker));157                if (stale) {158                  await github.graphql(`159                    mutation($id: ID!) {160                      minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {161                        minimizedComment { isMinimized }162                      }163                    }164                  `, { id: stale.node_id });165                  console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);166                }167              } catch (e) {168                core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);169              }170171              core.setOutput('has-link', 'true');172              core.setOutput('is-assigned', 'true');173            }174175            // ── Maintainer override: removed "missing-issue-link" label ─────176            if (action === 'unlabeled') {177              const { isMember, login } = await senderIsOrgMember();178              if (isMember) {179                await applyMaintainerBypass(180                  `Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`,181                );182                return;183              }184              // Non-member removed the label — re-add it defensively and185              // set failure outputs so downstream steps (comment, close) fire.186              // NOTE: addLabels fires a "labeled" event, but the job-level gate187              // only matches labeled events for "external", so no re-trigger.188              console.log(`Non-member ${login} removed missing-issue-link — re-adding`);189              try {190                await ensureAndAddLabel('missing-issue-link', 'b76e79');191              } catch (e) {192                core.warning(193                  `Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +194                  `Downstream step will retry.`,195                );196              }197              core.setOutput('has-link', 'false');198              core.setOutput('is-assigned', 'false');199              return;200            }201202            // ── Maintainer override: reopened PR with "missing-issue-link" ──203            const prLabels = context.payload.pull_request.labels.map(l => l.name);204            if (action === 'reopened' && prLabels.includes('missing-issue-link')) {205              const { isMember, login } = await senderIsOrgMember();206              if (isMember) {207                await applyMaintainerBypass(208                  `Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`,209                );210                return;211              }212              console.log(`Non-member ${login} reopened PR — proceeding with check`);213            }214215            // ── Fetch live labels (race guard) ──────────────────────────────216            const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({217              owner, repo, issue_number: prNumber,218            });219            const liveNames = liveLabels.map(l => l.name);220            if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {221              console.log('PR has trusted-contributor or bypass-issue-check label — bypassing');222              core.setOutput('has-link', 'true');223              core.setOutput('is-assigned', 'true');224              return;225            }226227            const body = context.payload.pull_request.body || '';228            const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;229            const matches = [...body.matchAll(pattern)];230231            if (matches.length === 0) {232              console.log('No issue link found in PR body');233              core.setOutput('has-link', 'false');234              core.setOutput('is-assigned', 'false');235              return;236            }237238            const issues = matches.map(m => `#${m[1]}`).join(', ');239            console.log(`Found issue link(s): ${issues}`);240            core.setOutput('has-link', 'true');241242            // Check whether the PR author is assigned to at least one linked issue243            const prAuthor = context.payload.pull_request.user.login;244            const MAX_ISSUES = 5;245            const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];246            const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES);247            if (allIssueNumbers.length > MAX_ISSUES) {248              core.warning(249                `PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`,250              );251            }252253            let assignedToAny = false;254            for (const num of issueNumbers) {255              try {256                const { data: issue } = await github.rest.issues.get({257                  owner, repo, issue_number: num,258                });259                const assignees = issue.assignees.map(a => a.login.toLowerCase());260                if (assignees.includes(prAuthor.toLowerCase())) {261                  console.log(`PR author "${prAuthor}" is assigned to #${num}`);262                  assignedToAny = true;263                  break;264                } else {265                  console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);266                }267              } catch (error) {268                if (error.status === 404) {269                  console.log(`Issue #${num} not found — skipping`);270                } else {271                  // Non-404 errors (rate limit, server error) must not be272                  // silently skipped — they could cause false enforcement273                  // (closing a legitimate PR whose assignment can't be verified).274                  throw new Error(275                    `Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`,276                  );277                }278              }279            }280281            core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');282283      - name: Add missing-issue-link label284        if: >-285          env.ENFORCE_ISSUE_LINK == 'true' &&286          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')287        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0288        with:289          script: |290            const { owner, repo } = context.repo;291            const prNumber = context.payload.pull_request.number;292            const labelName = 'missing-issue-link';293294            // Ensure the label exists (no checkout/shared helper available)295            try {296              await github.rest.issues.getLabel({ owner, repo, name: labelName });297            } catch (e) {298              if (e.status !== 404) throw e;299              try {300                await github.rest.issues.createLabel({301                  owner, repo, name: labelName, color: 'b76e79',302                });303              } catch (createErr) {304                if (createErr.status !== 422) throw createErr;305              }306            }307308            await github.rest.issues.addLabels({309              owner, repo, issue_number: prNumber, labels: [labelName],310            });311312      - name: Remove missing-issue-link label and reopen PR313        if: >-314          env.ENFORCE_ISSUE_LINK == 'true' &&315          steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'316        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0317        with:318          script: |319            const { owner, repo } = context.repo;320            const prNumber = context.payload.pull_request.number;321            try {322              await github.rest.issues.removeLabel({323                owner, repo, issue_number: prNumber, name: 'missing-issue-link',324              });325            } catch (error) {326              if (error.status !== 404) throw error;327            }328329            // Reopen if this workflow previously closed the PR. We check the330            // event payload labels (not live labels) because we already removed331            // missing-issue-link above; the payload still reflects pre-step state.332            const labels = context.payload.pull_request.labels.map(l => l.name);333            if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {334              await github.rest.pulls.update({335                owner,336                repo,337                pull_number: prNumber,338                state: 'open',339              });340              console.log(`Reopened PR #${prNumber}`);341            }342343            // Minimize stale enforcement comment (best-effort;344            // sync w/ applyMaintainerBypass above & reopen_on_assignment.yml)345            try {346              const marker = '<!-- require-issue-link -->';347              const comments = await github.paginate(348                github.rest.issues.listComments,349                { owner, repo, issue_number: prNumber, per_page: 100 },350              );351              const stale = comments.find(c => c.body && c.body.includes(marker));352              if (stale) {353                await github.graphql(`354                  mutation($id: ID!) {355                    minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {356                      minimizedComment { isMinimized }357                    }358                  }359                `, { id: stale.node_id });360                console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);361              }362            } catch (e) {363              core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);364            }365366      - name: Post comment, close PR, and fail367        if: >-368          env.ENFORCE_ISSUE_LINK == 'true' &&369          (steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')370        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0371        with:372          script: |373            const { owner, repo } = context.repo;374            const prNumber = context.payload.pull_request.number;375            const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';376            const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';377            const marker = '<!-- require-issue-link -->';378379            let lines;380            if (!hasLink) {381              lines = [382                marker,383                '**This PR has been automatically closed** because it does not link to an approved issue.',384                '',385                'All external contributions must reference an approved issue or discussion. Opening a PR before maintainer approval and assignment is discouraged. Please:',386                '1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',387                '2. Wait for a maintainer to approve the approach and assign you',388                '3. After assignment, open a PR. If this PR was opened early, add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to the description and it can be reopened automatically',389                '',390                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',391              ];392            } else {393              lines = [394                marker,395                '**This PR has been automatically closed** because you are not assigned to the linked issue.',396                '',397                'Opening a PR before assignment is discouraged and 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* contributors invest time implementing it. Please:',398                '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.',399                '2. Wait for a maintainer to approve the approach and assign you. Once assigned, this PR can be reopened automatically.',400                '',401                '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',402              ];403            }404405            const body = lines.join('\n');406407            // Deduplicate: check for existing comment with the marker408            const comments = await github.paginate(409              github.rest.issues.listComments,410              { owner, repo, issue_number: prNumber, per_page: 100 },411            );412            const existing = comments.find(c => c.body && c.body.includes(marker));413414            if (!existing) {415              await github.rest.issues.createComment({416                owner,417                repo,418                issue_number: prNumber,419                body,420              });421              console.log('Posted requirement comment');422            } else if (existing.body !== body) {423              await github.rest.issues.updateComment({424                owner,425                repo,426                comment_id: existing.id,427                body,428              });429              console.log('Updated existing comment with new message');430            } else {431              console.log('Comment already exists — skipping');432            }433434            // Close the PR435            if (context.payload.pull_request.state === 'open') {436              await github.rest.pulls.update({437                owner,438                repo,439                pull_number: prNumber,440                state: 'closed',441              });442              console.log(`Closed PR #${prNumber}`);443            }444445            // Cancel all other in-progress and queued workflow runs for this PR446            const headSha = context.payload.pull_request.head.sha;447            for (const status of ['in_progress', 'queued']) {448              const runs = await github.paginate(449                github.rest.actions.listWorkflowRunsForRepo,450                { owner, repo, head_sha: headSha, status, per_page: 100 },451              );452              for (const run of runs) {453                if (run.id === context.runId) continue;454                try {455                  await github.rest.actions.cancelWorkflowRun({456                    owner, repo, run_id: run.id,457                  });458                  console.log(`Cancelled ${status} run ${run.id} (${run.name})`);459                } catch (err) {460                  console.log(`Could not cancel run ${run.id}: ${err.message}`);461                }462              }463            }464465            const reason = !hasLink466              ? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'467              : 'PR author must be assigned to the linked issue.';468            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.