diff --git a/.github/workflows/reopen_on_assignment.yml b/.github/workflows/reopen_on_assignment.yml new file mode 100644 index 00000000000..fd0cd2f0ef5 --- /dev/null +++ b/.github/workflows/reopen_on_assignment.yml @@ -0,0 +1,138 @@ +# 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; + } + } diff --git a/.github/workflows/require_issue_link.yml b/.github/workflows/require_issue_link.yml index cb300e8f72b..b72f62dd497 100644 --- a/.github/workflows/require_issue_link.yml +++ b/.github/workflows/require_issue_link.yml @@ -349,7 +349,7 @@ jobs: '', 'External contributors must be assigned to an issue before opening a PR for it. Please:', '1. Comment on the linked issue to request assignment from a maintainer', - '2. Once assigned, edit your PR description and the PR will be reopened automatically', + '2. Once assigned, your PR will be reopened automatically', '', '*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*', ];