1# Reopen PRs that were auto-closed by require_issue_link.yml when the2# contributor was not assigned to the linked issue. When a maintainer3# assigns the contributor to the issue, this workflow finds matching4# closed PRs, verifies the issue link, and reopens them.5#6# Uses the default GITHUB_TOKEN (not a PAT or app token) so that the7# reopen and label-removal events do NOT re-trigger other workflows.8# GitHub suppresses events created by the default GITHUB_TOKEN within9# workflow runs to prevent infinite loops.1011name: Reopen PR on Issue Assignment1213on:14 issues:15 types: [assigned]1617permissions:18 contents: read1920jobs:21 reopen-linked-prs:22 runs-on: ubuntu-latest23 permissions:24 actions: write25 pull-requests: write2627 steps:28 - name: Find and reopen matching PRs29 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.030 with:31 script: |32 const { owner, repo } = context.repo;33 const issueNumber = context.payload.issue.number;34 const assignee = context.payload.assignee.login;3536 console.log(37 `Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`,38 );3940 const q = [41 `is:pr`,42 `is:closed`,43 `author:${assignee}`,44 `label:missing-issue-link`,45 `repo:${owner}/${repo}`,46 ].join(' ');4748 let data;49 try {50 ({ data } = await github.rest.search.issuesAndPullRequests({51 q,52 per_page: 30,53 }));54 } catch (e) {55 throw new Error(56 `Failed to search for closed PRs to reopen after assigning ${assignee} ` +57 `to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,58 );59 }6061 if (data.total_count === 0) {62 console.log('No matching closed PRs found');63 return;64 }6566 console.log(`Found ${data.total_count} candidate PR(s)`);6768 // Must stay in sync with the identical pattern in require_issue_link.yml69 const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;7071 for (const item of data.items) {72 const prNumber = item.number;73 const body = item.body || '';74 const matches = [...body.matchAll(pattern)];75 const referencedIssues = matches.map(m => parseInt(m[1], 10));7677 if (!referencedIssues.includes(issueNumber)) {78 console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);79 continue;80 }8182 // Skip if already bypassed83 const labels = item.labels.map(l => l.name);84 if (labels.includes('bypass-issue-check')) {85 console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);86 continue;87 }8889 // Reopen first, remove label second — a closed PR that still has90 // missing-issue-link is recoverable; a closed PR with the label91 // stripped is invisible to both workflows.92 try {93 await github.rest.pulls.update({94 owner,95 repo,96 pull_number: prNumber,97 state: 'open',98 });99 console.log(`Reopened PR #${prNumber}`);100 } catch (e) {101 if (e.status === 422) {102 // Head branch deleted — PR is unrecoverable. Notify the103 // contributor so they know to open a new PR.104 core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`);105 try {106 await github.rest.issues.createComment({107 owner,108 repo,109 issue_number: prNumber,110 body:111 `You have been assigned to #${issueNumber}, but this PR could not be ` +112 `reopened because the head branch has been deleted. Please open a new ` +113 `PR referencing the issue.`,114 });115 } catch (commentErr) {116 core.warning(117 `Also failed to post comment on PR #${prNumber}: ${commentErr.message}`,118 );119 }120 continue;121 }122 // Transient errors (rate limit, 5xx) should fail the job so123 // the label is NOT removed and the run can be retried.124 throw e;125 }126127 // Remove missing-issue-link label only after successful reopen128 try {129 await github.rest.issues.removeLabel({130 owner,131 repo,132 issue_number: prNumber,133 name: 'missing-issue-link',134 });135 console.log(`Removed missing-issue-link from PR #${prNumber}`);136 } catch (e) {137 if (e.status !== 404) throw e;138 }139140 // Minimize stale enforcement comment (best-effort;141 // sync w/ require_issue_link.yml minimize blocks)142 try {143 const marker = '<!-- require-issue-link -->';144 const comments = await github.paginate(145 github.rest.issues.listComments,146 { owner, repo, issue_number: prNumber, per_page: 100 },147 );148 const stale = comments.find(c => c.body && c.body.includes(marker));149 if (stale) {150 await github.graphql(`151 mutation($id: ID!) {152 minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {153 minimizedComment { isMinimized }154 }155 }156 `, { id: stale.node_id });157 console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);158 }159 } catch (e) {160 core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);161 }162163 // Re-run the failed require_issue_link check so it picks up the164 // new assignment. The re-run uses the original event payload but165 // fetches live issue data, so the assignment check will pass.166 //167 // Limitation: we look up runs by the PR's current head SHA. If the168 // contributor pushed new commits while the PR was closed, head.sha169 // won't match the SHA of the original failed run and the query will170 // return 0 results. This is acceptable because any push after reopen171 // triggers a fresh require_issue_link run against the new SHA.172 try {173 const { data: pr } = await github.rest.pulls.get({174 owner, repo, pull_number: prNumber,175 });176 const { data: runs } = await github.rest.actions.listWorkflowRuns({177 owner, repo,178 workflow_id: 'require_issue_link.yml',179 head_sha: pr.head.sha,180 status: 'failure',181 per_page: 1,182 });183 if (runs.workflow_runs.length > 0) {184 await github.rest.actions.reRunWorkflowFailedJobs({185 owner, repo,186 run_id: runs.workflow_runs[0].id,187 });188 console.log(`Re-ran failed require_issue_link run ${runs.workflow_runs[0].id} for PR #${prNumber}`);189 } else {190 console.log(`No failed require_issue_link runs found for PR #${prNumber} — skipping re-run`);191 }192 } catch (e) {193 core.warning(`Could not re-run require_issue_link check for PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);194 }195 }
Findings
✓ No findings reported for this file.