diff --git a/.github/workflows/reopen-on-issue-assignment.yml b/.github/workflows/reopen-on-issue-assignment.yml new file mode 100644 index 000000000..54fe8ed4e --- /dev/null +++ b/.github/workflows/reopen-on-issue-assignment.yml @@ -0,0 +1,286 @@ +# When the linked issue is assigned to a PR author, reopen any closed PR +# that this workflow's sibling, `require-issue-link.yml`, closed for the +# `not-assigned` failure mode. This is the happy-path continuation of the +# enforcement workflow: a contributor referenced a real issue but wasn't +# assigned to it; the natural maintainer response is to assign them, and +# the PR should resume without requiring a separate PR-side action. +# +# Out of scope: PRs closed for the `no-link` failure mode (no auto-close +# keyword in body). No signal connects an arbitrary issue assignment to +# such a PR. Those require a PR edit to retrigger. +# +# Coupling with `require-issue-link.yml` — keep in sync if changed there: +# • the `missing-issue-link` label name (LABEL constant) +# • the `bypass-issue-check` label name +# • the `` comment marker (MARKER constant) +# • the auto-close keyword regex +# +# SECURITY: `issues` events run with the BASE repo token. This workflow +# never checks out or executes PR-head code; it only reads the event +# payload and calls the API. + +name: Reopen on Issue Assignment + +on: + issues: + types: [assigned] + +# Dry run: when "false" the check still runs and logs its decisions but +# makes NO mutations. Mirror of the sibling workflow's flag so the pair +# can be flipped together. +env: + ENFORCE_ISSUE_LINK: "true" + +permissions: + contents: read + +concurrency: + # Serialize per (issue, assignee): rapid re-assignments don't race. + # Different issues / different assignees run in parallel. + group: reopen-on-issue-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }} + cancel-in-progress: false + +jobs: + reopen: + # Skip when the assignment is on a PR (in the GitHub API a PR is an + # issue; the payload has `pull_request` set when the issue is in fact + # a PR — we only care about real issue assignments). Skip bots. + if: >- + github.event.assignee != null && + !github.event.issue.pull_request && + !endsWith(github.event.assignee.login, '[bot]') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # pull-requests: write — reopen, remove label, post the + # deleted-branch comment, minimize the stale enforcement comment + # (all PR-as-issue API endpoints; require pull-requests scope). + # issues: read — GraphQL issue.timelineItems on the assigned issue. + # actions: write — re-run the original failed enforce workflow so + # the PR's red check run flips to green, instead of leaving the + # old run red alongside the new event-driven green check run. + pull-requests: write + issues: read + actions: write + + steps: + - name: Reopen PRs newly compliant after issue assignment + 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'; + + // ── Contracts shared with require-issue-link.yml ────────────── + const LABEL = 'missing-issue-link'; + const BYPASS_LABEL = 'bypass-issue-check'; + const MARKER = ''; + // Identical regex to the enforce path: kept byte-for-byte the + // same so the two paths can't disagree about what counts as + // an auto-close link. + const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\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 closed PRs to reopen` + ); + + // ── Discover candidates via the issue's timeline ───────────── + // CROSS_REFERENCED_EVENT + body regex re-check is preferred + // over the Search API: no indexing lag (Search can take ~30s + // to reflect a freshly-closed PR), and the response gives + // state + body + labels + author in a single round trip. + const query = ` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + timelineItems(itemTypes: [CROSS_REFERENCED_EVENT], first: 50) { + nodes { + ... on CrossReferencedEvent { + source { + __typename + ... on PullRequest { + number + state + body + author { login } + labels(first: 30) { nodes { name } } + } + } + } + } + } + } + } + } + `; + + let timeline; + try { + const result = await github.graphql(query, { + owner, repo, number: issueNumber, + }); + timeline = result?.repository?.issue?.timelineItems?.nodes ?? []; + } catch (e) { + throw new Error( + `Cannot fetch timeline for #${issueNumber} ` + + `(HTTP ${e.status ?? 'unknown'}): ${e.message}` + ); + } + + // Dedupe + filter to closed (not merged) PRs the sibling + // workflow closed for *this specific* issue, authored by the + // newly-assigned user, and not already bypassed. + const assigneeLower = assignee.toLowerCase(); + const seen = new Set(); + const candidates = []; + for (const node of timeline) { + const src = node?.source; + if (!src || src.__typename !== 'PullRequest') continue; + // state is OPEN | CLOSED | MERGED — only act on CLOSED + // (never resurrect a merged PR). + if (src.state !== 'CLOSED') continue; + if (seen.has(src.number)) continue; + seen.add(src.number); + + const labels = (src.labels?.nodes ?? []).map(l => l.name); + if (!labels.includes(LABEL)) continue; + if (labels.includes(BYPASS_LABEL)) { + console.log(`PR #${src.number} already bypassed — skipping`); + continue; + } + + const authorLogin = src.author?.login?.toLowerCase(); + if (!authorLogin || authorLogin !== assigneeLower) continue; + + // Re-apply the enforce path's regex against the PR body to + // confirm an auto-close link to *this* issue (rather than a + // cross-reference from a comment or commit mention). + const referenced = [...(src.body ?? '').matchAll(pattern)] + .map(m => parseInt(m[1], 10)); + if (!referenced.includes(issueNumber)) continue; + + candidates.push(src.number); + } + + if (candidates.length === 0) { + console.log('No matching closed PRs found'); + return; + } + + console.log(`Reopening PR(s): ${candidates.join(', ')}`); + + for (const prNumber of candidates) { + // ── Reopen the PR (handle deleted head branch) ────────────── + let reopened = false; + try { + await mutate(`reopen PR #${prNumber}`, () => + github.rest.pulls.update({ + owner, repo, pull_number: prNumber, state: 'open', + }), + ); + reopened = true; + } 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; + } + + // ── Remove the enforcement label ──────────────────────────── + 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; + } + }); + + // ── Minimize the stale enforcement comment ────────────────── + // listComments paginated: a long-running PR can accumulate + // many comments and per_page caps at 100. + 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) { + 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}` + ); + } + + // ── Re-run the failed enforce run so the red check turns ─── + // green on this same head SHA, instead of leaving the + // original failure visible alongside the new event-driven + // run that the reopen itself triggers. + if (!reopened) continue; + 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}` + ); + } + } diff --git a/.github/workflows/require-issue-link.yml b/.github/workflows/require-issue-link.yml index 0e7d6e68b..23ccb418f 100644 --- a/.github/workflows/require-issue-link.yml +++ b/.github/workflows/require-issue-link.yml @@ -17,8 +17,12 @@ # - 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 reopens previously closed PRs automatically. +# - The issue-assignment side (reopening previously closed PRs once the +# linked issue is assigned to the author) lives in the sibling workflow +# `reopen-on-issue-assignment.yml` to keep this file focused on +# enforcement. The two files share the `missing-issue-link` / +# `bypass-issue-check` label names, the `` +# comment marker, and the auto-close keyword regex — keep them in sync. # # Maintainer override: reopen the PR, or remove the "missing-issue-link" # label — either applies a sticky "bypass-issue-check" label and reopens. @@ -35,10 +39,6 @@ on: # 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 is what makes a previously closed "not assigned" PR compliant, - # so it needs a separate event path that finds and reopens matching 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 @@ -395,167 +395,3 @@ jobs: ); } } - - 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: Reopen linked 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 = ''; - const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*#(\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 closed PRs to reopen`); - - const q = [ - 'is:pr', - 'is:closed', - `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 closed PRs for ${assignee} after assigning #${issueNumber} ` + - `(HTTP ${e.status ?? 'unknown'}): ${e.message}`, - ); - } - - if (search.total_count === 0) { - console.log('No matching closed 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 { - 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}`); - } - }