Repository navigation
docs: clarify tool errors and middleware transformations #3605
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Require external PRs to reference an issue with an auto-close keyword | ||
|
Check warning on line 1 in .github/workflows/require-issue-link.yml
|
||
| # (e.g. "Fixes #123") AND have the PR author assigned to that issue — | ||
| # unless the referenced issue is labeled "prs welcome", which waives the | ||
| # assignment requirement for everyone (the link itself is still required, | ||
| # since that's how the check finds the issue to read the label from). | ||
| # Missing requirements fail the check and receive a "missing-issue-link" | ||
| # label and explanatory comment. Only PRs without a valid issue link are | ||
| # closed; linked PRs stay open while maintainers decide on assignment. | ||
| # | ||
| # Adapted from langchain-ai/langchain's require_issue_link.yml. Differences: | ||
| # - Self-contained: it does NOT depend on a separate labeler workflow | ||
| # applying an "external" label first, so it can run on `opened`. | ||
| # - "External" is determined authoritatively, in-script, from the PR | ||
| # author's repo collaborator permission level — NOT from the event | ||
| # payload's author_association. author_association reports MEMBER only | ||
| # for *public* org members; a maintainer whose org membership is | ||
| # private appears as CONTRIBUTOR/NONE, so gating on it would wrongly | ||
| # enforce against private-member maintainers. getCollaboratorPermission | ||
| # reflects effective write access regardless of membership visibility. | ||
| # - The enforcement path is a single github-script step (the upstream | ||
| # version is split across four, forcing the label/comment/reopen helpers | ||
| # to be duplicated per scope). | ||
| # - Issue assignment events are handled in this same workflow so assigning | ||
| # the linked issue clears waiting PRs and reopens previously gated PRs. | ||
| # | ||
| # Maintainer override: reopen the PR, or remove the "missing-issue-link" | ||
| # label — either applies a sticky "bypass-issue-check" label and reopens. | ||
| name: Require Issue Link | ||
| on: | ||
| pull_request_target: | ||
| # SECURITY: pull_request_target runs with repo write scope against the | ||
| # BASE repo. NEVER check out or execute PR-head code here — it would run | ||
| # with these permissions. This workflow only reads the PR payload and | ||
| # calls the API; it never checks anything out. | ||
| # ready_for_review matters because the job skips drafts: without it a | ||
| # draft opened with no issue link would never be checked when it later | ||
| # becomes reviewable. | ||
| types: [opened, edited, reopened, ready_for_review, labeled, unlabeled] | ||
| issues: | ||
| # Assignment makes waiting PRs compliant, so it needs a separate event | ||
| # path that clears their checks and reopens previously gated PRs. | ||
| types: [assigned] | ||
| # Dry run: when 'false' the check still runs and logs its verdict but makes | ||
| # NO mutations at all (no label, comment, close, reopen, or failure). Flip | ||
| # to 'true' to enforce. | ||
| env: | ||
| ENFORCE_ISSUE_LINK: "true" | ||
| permissions: | ||
| contents: read | ||
| jobs: | ||
| check-issue-link: | ||
| # Cheap pre-filters only. Maintainer detection is deliberately NOT done | ||
| # here: the job-level `if` can't call the API, and author_association is | ||
| # unreliable for private org members (see file header). The job runs, | ||
| # then the script resolves the author's real permission and exits early | ||
| # for maintainers. | ||
| # | ||
| # Gate: only run on pull_request_target events. The workflow also listens | ||
| # to `issues.assigned` (handled by reopen-on-assignment below), and without | ||
| # this guard the job would also fire there — `github.event.pull_request` is | ||
| # null on an issues event, so `...draft == false` coerces to true and the | ||
| # script then dereferences a missing PR and crashes. Beyond the event type, | ||
| # skip drafts, bots, and already-bypassed/trusted PRs, and allow the primary | ||
| # actions plus the one maintainer-override action we care about (removing | ||
| # the missing-issue-link label). | ||
| if: >- | ||
| github.event_name == 'pull_request_target' && | ||
| github.event.pull_request.draft == false && | ||
| !endsWith(github.actor, '[bot]') && | ||
| !contains(github.event.pull_request.labels.*.name, 'trusted-contributor') && | ||
| !contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') && | ||
| ( | ||
| (github.event.action != 'labeled' && github.event.action != 'unlabeled') || | ||
| (github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link') | ||
| ) | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| concurrency: | ||
| group: require-issue-link-${{ github.event.pull_request.number }} | ||
| cancel-in-progress: false | ||
| permissions: | ||
| issues: write | ||
| pull-requests: write | ||
| steps: | ||
| - name: Enforce issue link | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| const pr = context.payload.pull_request; | ||
| const prNumber = pr.number; | ||
| const action = context.payload.action; | ||
| const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; | ||
| const LABEL = 'missing-issue-link'; | ||
| const MARKER = '<!-- require-issue-link -->'; | ||
| // Issue-level label that waives the assignment requirement. | ||
| const OPEN_LABEL = 'prs welcome'; | ||
| // Dry-run guard: every mutating call goes through this so that | ||
| // ENFORCE_ISSUE_LINK=false means strictly read-only. | ||
| async function mutate(description, fn) { | ||
| if (!enforce) { | ||
| console.log(`[dry-run] would ${description}`); | ||
| return; | ||
| } | ||
| await fn(); | ||
| } | ||
| // Authoritative maintainer check. Uses collaborator permission, | ||
| // not org membership or author_association: | ||
| // - GITHUB_TOKEN is an app token and is never an org member, | ||
| // so the org-membership endpoint always 403s. | ||
| // - author_association reports MEMBER only for *public* org | ||
| // members; a private-member maintainer shows as | ||
| // CONTRIBUTOR/NONE. Permission level is visibility- | ||
| // independent and reflects effective access. | ||
| // 404 (not a collaborator) → not a maintainer. Other errors | ||
| // (rate limit, 5xx) MUST throw: silently treating them as | ||
| // "not a maintainer" could wrongly close a maintainer's PR. | ||
| // A throw aborts the script before any close/label call, so the | ||
| // job fails red and the PR is left untouched — the safe direction. | ||
| async function hasWriteAccess(username) { | ||
| if (!username) throw new Error('No username — cannot check permissions'); | ||
| try { | ||
| const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ | ||
| owner, repo, username, | ||
| }); | ||
| const ok = ['admin', 'maintain', 'write'].includes(data.permission); | ||
| console.log(`${username}: ${data.permission} — ${ok ? 'maintainer' : 'not a maintainer'}`); | ||
| return ok; | ||
| } catch (e) { | ||
| if (e.status === 404) { | ||
| console.log(`${username} is not a collaborator — not a maintainer`); | ||
| return false; | ||
| } | ||
| throw new Error( | ||
| `Permission check failed for ${username} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, | ||
| ); | ||
| } | ||
| } | ||
| async function addLabel() { | ||
| await mutate(`label PR #${prNumber} "${LABEL}"`, async () => { | ||
| try { | ||
| await github.rest.issues.getLabel({ owner, repo, name: LABEL }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| try { | ||
| await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b76e79' }); | ||
| } catch (createErr) { | ||
| // 422 = created by a concurrent run between GET and POST. | ||
| if (createErr.status !== 422) throw createErr; | ||
| } | ||
| } | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: prNumber, labels: [LABEL], | ||
| }); | ||
| }); | ||
| } | ||
| async function minimizeStaleComment() { | ||
| try { | ||
| const comments = await github.paginate( | ||
| github.rest.issues.listComments, | ||
| { owner, repo, issue_number: prNumber, per_page: 100 }, | ||
| ); | ||
| const stale = comments.find(c => c.body && c.body.includes(MARKER)); | ||
| if (!stale) return; | ||
| await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(` | ||
| mutation($id: ID!) { | ||
| minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { | ||
| minimizedComment { isMinimized } | ||
| } | ||
| } | ||
| `, { id: stale.node_id })); | ||
| } catch (e) { | ||
| core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); | ||
| } | ||
| } | ||
| // Shared "this PR passes" cleanup: drop the label, reopen, and | ||
| // retire any stale enforcement comment. | ||
| // | ||
| // For the normal pass paths we only reopen if THIS workflow had | ||
| // closed the PR — inferred from the label still being on the | ||
| // payload. The maintainer-override paths pass forceReopen: the | ||
| // `unlabeled` event payload no longer carries the just-removed | ||
| // label, so the heuristic can't see it; without forcing, the | ||
| // advertised "remove the label to bypass" gesture would leave | ||
| // the PR closed. | ||
| async function clearEnforcement(forceReopen = false) { | ||
| await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: prNumber, name: LABEL, | ||
| }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| } | ||
| }); | ||
| const hadLabel = pr.labels.map(l => l.name).includes(LABEL); | ||
| if (pr.state === 'closed' && (forceReopen || hadLabel)) { | ||
| await mutate(`reopen PR #${prNumber}`, async () => { | ||
| await github.rest.pulls.update({ | ||
| owner, repo, pull_number: prNumber, state: 'open', | ||
| }); | ||
| }); | ||
| } | ||
| await minimizeStaleComment(); | ||
| } | ||
| async function applyBypass(reason) { | ||
| console.log(reason); | ||
| await clearEnforcement(true); | ||
| await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => { | ||
| try { | ||
| await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| try { | ||
| await github.rest.issues.createLabel({ | ||
| owner, repo, name: 'bypass-issue-check', color: '0e8a16', | ||
| }); | ||
| } catch (createErr) { | ||
| if (createErr.status !== 422) throw createErr; | ||
| } | ||
| } | ||
| await github.rest.issues.addLabels({ | ||
| owner, repo, issue_number: prNumber, labels: ['bypass-issue-check'], | ||
| }); | ||
| }); | ||
| } | ||
| // ── Maintainer-authored PRs are exempt entirely ──────────────── | ||
| if (await hasWriteAccess(pr.user.login)) { | ||
| console.log(`PR author ${pr.user.login} has write access — exempt`); | ||
| await clearEnforcement(); | ||
| return; | ||
| } | ||
| const sender = context.payload.sender?.login; | ||
| // ── Maintainer override: removed the "missing-issue-link" label ─ | ||
| if (action === 'unlabeled') { | ||
| if (await hasWriteAccess(sender)) { | ||
| await applyBypass(`Maintainer ${sender} removed ${LABEL} from PR #${prNumber} — bypassing`); | ||
| return; | ||
| } | ||
| // Only triage/admin can manage labels, so a non-write actor | ||
| // reaching here is rare (triage role). Fall through to the | ||
| // normal check, which recomputes link + assignment and | ||
| // re-enforces with the correct message if still failing. | ||
| console.log(`Non-maintainer ${sender} removed ${LABEL} — re-checking`); | ||
| } | ||
| // ── Maintainer override: reopened a PR we had closed ─────────── | ||
| if ( | ||
| action === 'reopened' && | ||
| pr.labels.map(l => l.name).includes(LABEL) && | ||
| (await hasWriteAccess(sender)) | ||
| ) { | ||
| await applyBypass(`Maintainer ${sender} reopened PR #${prNumber} — bypassing`); | ||
| return; | ||
| } | ||
| // ── Race guard: re-read live labels ──────────────────────────── | ||
| const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({ | ||
| owner, repo, issue_number: prNumber, | ||
| }); | ||
| const liveNames = liveLabels.map(l => l.name); | ||
| if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) { | ||
| console.log('PR carries trusted-contributor or bypass-issue-check — clearing any prior enforcement'); | ||
| await clearEnforcement(); | ||
| return; | ||
| } | ||
| // ── The actual check: an auto-close keyword + issue number ───── | ||
| const body = pr.body || ''; | ||
| // Match GitHub's auto-close keywords against any reference form | ||
| // that GitHub itself honors: bare `#123`, the `owner/repo#123` | ||
| // shorthand, and the full issue URL. Scope the qualified forms to | ||
| // THIS repo — GitHub only auto-closes same-repo issues, so a | ||
| // cross-repo reference must not be resolved against our numbering. | ||
| const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const pattern = new RegExp( | ||
| '(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' + | ||
| `(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`, | ||
| 'gi', | ||
| ); | ||
| const matches = [...body.matchAll(pattern)]; | ||
| if (matches.length === 0) { | ||
| console.log('No issue link found in PR body'); | ||
| await enforceFailure('no-link'); | ||
| return; | ||
| } | ||
| // The author must be assigned to at least one linked issue. | ||
| // Linked PRs remain open while maintainers decide on assignment; | ||
| // the failing check keeps them gated until that decision. | ||
| // | ||
| // Exception: an issue labeled OPEN_LABEL waives that requirement | ||
| // for everyone. It's how maintainers advertise "the reporter | ||
| // isn't implementing this, we'd take a PR from anyone" without | ||
| // having to assign a specific person up front. Unlike the | ||
| // PR-level `trusted-contributor` / `bypass-issue-check` escapes, | ||
| // this one lives on the *issue* and is set ahead of time. | ||
| const MAX_ISSUES = 5; | ||
| const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))]; | ||
| const numbers = allNumbers.slice(0, MAX_ISSUES); | ||
| if (allNumbers.length > MAX_ISSUES) { | ||
| core.warning(`PR references ${allNumbers.length} issues — checking only the first ${MAX_ISSUES}`); | ||
| } | ||
| const prAuthor = pr.user.login.toLowerCase(); | ||
| let sawRealIssue = false; | ||
| let assignedToAny = false; | ||
| for (const num of numbers) { | ||
| let issue; | ||
| try { | ||
| ({ data: issue } = await github.rest.issues.get({ | ||
| owner, repo, issue_number: num, | ||
| })); | ||
| } catch (e) { | ||
| if (e.status === 404) { | ||
| console.log(`#${num} does not exist — ignoring`); | ||
| continue; | ||
| } | ||
| // Same safe-direction rule as hasWriteAccess: a transient | ||
| // error must not be read as "not assigned" and close the PR. | ||
| throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`); | ||
| } | ||
| sawRealIssue = true; | ||
| // GitHub returns labels as objects here, but the REST schema | ||
| // permits bare strings — normalize both rather than assume. | ||
| const labelNames = (issue.labels || []) | ||
| .map(l => (typeof l === 'string' ? l : l && l.name)) | ||
| .filter(Boolean) | ||
| .map(n => n.toLowerCase()); | ||
| if (labelNames.includes(OPEN_LABEL)) { | ||
| console.log(`#${num} is labeled "${OPEN_LABEL}" — assignment not required`); | ||
| assignedToAny = true; | ||
| break; | ||
| } | ||
| const assignees = (issue.assignees || []).map(a => a.login.toLowerCase()); | ||
| if (assignees.includes(prAuthor)) { | ||
| console.log(`PR author ${pr.user.login} is assigned to #${num}`); | ||
| assignedToAny = true; | ||
| break; | ||
| } | ||
| console.log(`PR author ${pr.user.login} is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`); | ||
| } | ||
| if (!sawRealIssue) { | ||
| console.log('Referenced issue(s) do not exist'); | ||
| await enforceFailure('no-link'); | ||
| return; | ||
| } | ||
| if (!assignedToAny) { | ||
| await enforceFailure('not-assigned'); | ||
| return; | ||
| } | ||
| console.log('Linked and assigned — clearing any prior enforcement'); | ||
| await clearEnforcement(); | ||
| // ── Explain missing requirements and fail ──────────────────────────── | ||
| // `kind`: 'no-link' (no valid issue reference) or 'not-assigned' | ||
| // (referenced an issue, but the author isn't assigned to it). | ||
| async function enforceFailure(kind) { | ||
| await addLabel(); | ||
| const steps = kind === 'no-link' | ||
| ? [ | ||
| `1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`, | ||
| "2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to **this** PR's description — edit it in place, don't open a new PR.", | ||
| ] | ||
| : [ | ||
| "1. A maintainer will review the issue and decide whether to assign you. Please don't comment just to request assignment.", | ||
| ]; | ||
| const commentBody = [ | ||
| MARKER, | ||
| kind === 'no-link' | ||
| ? '**This pull request was closed because it needs a valid issue link.** Please update this PR rather than opening a new one.' | ||
| : '**This pull request is awaiting maintainer assignment.** Linked PRs stay open while maintainers triage the issue; this check remains failing until assignment.', | ||
| '', | ||
| `Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`, | ||
| '', | ||
| ...steps, | ||
| '', | ||
| "Once you're assigned and the link is present, this check is re-run automatically. Previously gate-closed PRs also reopen — no further action needed.", | ||
| '', | ||
| `*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`, | ||
| ].join('\n'); | ||
| const comments = await github.paginate( | ||
| github.rest.issues.listComments, | ||
| { owner, repo, issue_number: prNumber, per_page: 100 }, | ||
| ); | ||
| const existing = comments.find(c => c.body && c.body.includes(MARKER)); | ||
| if (!existing) { | ||
| await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({ | ||
| owner, repo, issue_number: prNumber, body: commentBody, | ||
| })); | ||
| } else if (existing.body !== commentBody) { | ||
| await mutate(`update comment ${existing.id}`, () => github.rest.issues.updateComment({ | ||
| owner, repo, comment_id: existing.id, body: commentBody, | ||
| })); | ||
| } else { | ||
| console.log('Requirement comment already present — skipping'); | ||
| } | ||
| if (kind === 'no-link' && pr.state === 'open') { | ||
| await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({ | ||
| owner, repo, pull_number: prNumber, state: 'closed', | ||
| })); | ||
| } else if ( | ||
| kind === 'not-assigned' && pr.state === 'closed' && | ||
| pr.labels.some(label => label.name === LABEL) | ||
| ) { | ||
| // Adding the missing link moves a gate-closed PR back into | ||
| // the open triage queue. A later human closure takes priority | ||
| // even if the enforcement label was left on the PR. | ||
| const events = await github.paginate( | ||
| github.rest.issues.listEvents, | ||
| { owner, repo, issue_number: prNumber, per_page: 100 }, | ||
| ); | ||
| const lastClosure = events.filter(event => event.event === 'closed').pop(); | ||
| if (lastClosure?.actor?.login === 'github-actions[bot]') { | ||
| await mutate(`reopen linked PR #${prNumber} for triage`, () => github.rest.pulls.update({ | ||
| owner, repo, pull_number: prNumber, state: 'open', | ||
| })); | ||
| } | ||
| } | ||
| if (enforce) { | ||
| core.setFailed( | ||
| kind === 'no-link' | ||
| ? 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").' | ||
| : 'PR author must be assigned to the referenced issue.', | ||
| ); | ||
| } | ||
| } | ||
| reopen-on-assignment: | ||
| if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| concurrency: | ||
| group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }} | ||
| cancel-in-progress: false | ||
| permissions: | ||
| actions: write | ||
| issues: write | ||
| pull-requests: write | ||
| steps: | ||
| - name: Clear assigned PRs | ||
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 | ||
| with: | ||
| script: | | ||
| const { owner, repo } = context.repo; | ||
| const issueNumber = context.payload.issue.number; | ||
| const assignee = context.payload.assignee.login; | ||
| const enforce = process.env.ENFORCE_ISSUE_LINK === 'true'; | ||
| const LABEL = 'missing-issue-link'; | ||
| const MARKER = '<!-- require-issue-link -->'; | ||
| // Match GitHub's auto-close keywords against any reference form | ||
| // that GitHub itself honors: bare `#123`, the `owner/repo#123` | ||
| // shorthand, and the full issue URL. Scope the qualified forms to | ||
| // THIS repo — GitHub only auto-closes same-repo issues, so a | ||
| // cross-repo reference must not be resolved against our numbering. | ||
| const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const pattern = new RegExp( | ||
| '(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' + | ||
| `(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`, | ||
| 'gi', | ||
| ); | ||
| async function mutate(description, fn) { | ||
| if (!enforce) { | ||
| console.log(`[dry-run] would ${description}`); | ||
| return; | ||
| } | ||
| await fn(); | ||
| } | ||
| console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for gated PRs`); | ||
| const q = [ | ||
| 'is:pr', | ||
| 'is:unmerged', | ||
| `author:${assignee}`, | ||
| `label:${LABEL}`, | ||
| `repo:${owner}/${repo}`, | ||
| ].join(' '); | ||
| let search; | ||
| try { | ||
| ({ data: search } = await github.rest.search.issuesAndPullRequests({ | ||
| q, | ||
| per_page: 30, | ||
| })); | ||
| } catch (e) { | ||
| throw new Error( | ||
| `Failed to search gated PRs for ${assignee} after assigning #${issueNumber} ` + | ||
| `(HTTP ${e.status ?? 'unknown'}): ${e.message}`, | ||
| ); | ||
| } | ||
| if (search.total_count === 0) { | ||
| console.log('No matching gated PRs found'); | ||
| return; | ||
| } | ||
| console.log(`Found ${search.total_count} candidate PR(s)`); | ||
| for (const item of search.items) { | ||
| const prNumber = item.number; | ||
| let issue; | ||
| try { | ||
| ({ data: issue } = await github.rest.issues.get({ | ||
| owner, repo, issue_number: prNumber, | ||
| })); | ||
| } catch (e) { | ||
| throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`); | ||
| } | ||
| const labels = (issue.labels || []).map(label => label.name); | ||
| if (labels.includes('bypass-issue-check')) { | ||
| console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); | ||
| continue; | ||
| } | ||
| const body = issue.body || ''; | ||
| const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10)); | ||
| if (!referencedIssues.includes(issueNumber)) { | ||
| console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); | ||
| continue; | ||
| } | ||
| try { | ||
| if (issue.state === 'closed') { | ||
| await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({ | ||
| owner, repo, pull_number: prNumber, state: 'open', | ||
| })); | ||
| } | ||
| } catch (e) { | ||
| if (e.status === 422) { | ||
| core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`); | ||
| await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({ | ||
| owner, | ||
| repo, | ||
| issue_number: prNumber, | ||
| body: | ||
| `You have been assigned to #${issueNumber}, but this PR could not be ` + | ||
| 'reopened because the head branch has been deleted. Please open a new PR ' + | ||
| 'referencing the issue.', | ||
| })); | ||
| continue; | ||
| } | ||
| throw e; | ||
| } | ||
| await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => { | ||
| try { | ||
| await github.rest.issues.removeLabel({ | ||
| owner, repo, issue_number: prNumber, name: LABEL, | ||
| }); | ||
| } catch (e) { | ||
| if (e.status !== 404) throw e; | ||
| } | ||
| }); | ||
| try { | ||
| const comments = await github.paginate( | ||
| github.rest.issues.listComments, | ||
| { owner, repo, issue_number: prNumber, per_page: 100 }, | ||
| ); | ||
| const stale = comments.find(comment => comment.body && comment.body.includes(MARKER)); | ||
| if (stale) { | ||
| await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(` | ||
| mutation($id: ID!) { | ||
| minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { | ||
| minimizedComment { isMinimized } | ||
| } | ||
| } | ||
| `, { id: stale.node_id })); | ||
| } | ||
| } catch (e) { | ||
| core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); | ||
| } | ||
| try { | ||
| const { data: pr } = await github.rest.pulls.get({ | ||
| owner, repo, pull_number: prNumber, | ||
| }); | ||
| const { data: runs } = await github.rest.actions.listWorkflowRuns({ | ||
| owner, | ||
| repo, | ||
| workflow_id: 'require-issue-link.yml', | ||
| head_sha: pr.head.sha, | ||
| status: 'failure', | ||
| per_page: 1, | ||
| }); | ||
| if (runs.workflow_runs.length === 0) { | ||
| console.log(`No failed require-issue-link runs found for PR #${prNumber}`); | ||
| continue; | ||
| } | ||
| await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () => | ||
| github.rest.actions.reRunWorkflowFailedJobs({ | ||
| owner, repo, run_id: runs.workflow_runs[0].id, | ||
| }), | ||
| ); | ||
| } catch (e) { | ||
| core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`); | ||
| } | ||
| } | ||