ci: split reopen-on-issue-assignment into its own workflow file

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
strawgate 2026-05-20 08:48:31 -05:00
commit a4488c5c2c
2 changed files with 292 additions and 170 deletions

View file

@ -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 `<!-- require-issue-link -->` 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 = '<!-- require-issue-link -->';
// 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}`
);
}
}

View file

@ -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 `<!-- require-issue-link -->`
# 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 = '<!-- require-issue-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`);
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}`);
}
}