# Reopen PRs that were auto-closed by require_issue_link.yml when the # contributor was not assigned to the linked issue. When a maintainer # assigns the contributor to the issue, this workflow finds matching # closed PRs, verifies the issue link, and reopens them. # # Uses the default GITHUB_TOKEN (not a PAT or app token) so that the # reopen and label-removal events do NOT re-trigger other workflows. # GitHub suppresses events created by the default GITHUB_TOKEN within # workflow runs to prevent infinite loops. name: Reopen PR on Issue Assignment on: issues: types: [assigned] permissions: contents: read jobs: reopen-linked-prs: runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Find and reopen matching PRs uses: actions/github-script@v8 with: script: | const { owner, repo } = context.repo; const issueNumber = context.payload.issue.number; const assignee = context.payload.assignee.login; console.log( `Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`, ); const q = [ `is:pr`, `is:closed`, `author:${assignee}`, `label:missing-issue-link`, `repo:${owner}/${repo}`, ].join(' '); let data; try { ({ data } = await github.rest.search.issuesAndPullRequests({ q, per_page: 30, })); } catch (e) { throw new Error( `Failed to search for closed PRs to reopen after assigning ${assignee} ` + `to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`, ); } if (data.total_count === 0) { console.log('No matching closed PRs found'); return; } console.log(`Found ${data.total_count} candidate PR(s)`); // Must stay in sync with the identical pattern in require_issue_link.yml const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi; for (const item of data.items) { const prNumber = item.number; const body = item.body || ''; const matches = [...body.matchAll(pattern)]; const referencedIssues = matches.map(m => parseInt(m[1], 10)); if (!referencedIssues.includes(issueNumber)) { console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`); continue; } // Skip if already bypassed const labels = item.labels.map(l => l.name); if (labels.includes('bypass-issue-check')) { console.log(`PR #${prNumber} already has bypass-issue-check — skipping`); continue; } // Reopen first, remove label second — a closed PR that still has // missing-issue-link is recoverable; a closed PR with the label // stripped is invisible to both workflows. try { await github.rest.pulls.update({ owner, repo, pull_number: prNumber, state: 'open', }); console.log(`Reopened PR #${prNumber}`); } catch (e) { if (e.status === 422) { // Head branch deleted — PR is unrecoverable. Notify the // contributor so they know to open a new PR. core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`); try { await 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.`, }); } catch (commentErr) { core.warning( `Also failed to post comment on PR #${prNumber}: ${commentErr.message}`, ); } continue; } // Transient errors (rate limit, 5xx) should fail the job so // the label is NOT removed and the run can be retried. throw e; } // Remove missing-issue-link label only after successful reopen try { await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: 'missing-issue-link', }); console.log(`Removed missing-issue-link from PR #${prNumber}`); } catch (e) { if (e.status !== 404) throw e; } // Minimize stale enforcement comment (best-effort; // sync w/ require_issue_link.yml minimize blocks) try { const marker = ''; 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) { await github.graphql(` mutation($id: ID!) { minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) { minimizedComment { isMinimized } } } `, { id: stale.node_id }); console.log(`Minimized stale enforcement comment ${stale.id} as outdated`); } } catch (e) { core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`); } }