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.