diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml new file mode 100644 index 000000000..3988610b5 --- /dev/null +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -0,0 +1,387 @@ +name: Close stale issues with no linked PR + +on: + schedule: + - cron: '30 9 * * *' # daily at 09:30 UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Report only (default). Set to false to actually comment and close.' + type: boolean + default: true + +# Serialize mutating runs: a scheduled run and a manual dispatch must not +# interleave comment/close operations on the same candidates. +concurrency: + group: close-unlinked-stale-issues + cancel-in-progress: false + +jobs: + close-unlinked-stale: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const STALE_DAYS = 14; + const MAX_ITEMS_PER_RUN = 50; // global budget shared by all phases + // Two-label state machine: + // PENDING_LABEL — transaction in progress; open+pending = a run + // was interrupted mid-close and needs recovery. + // HANDLED_LABEL — applied at successful close. An OPEN issue + // carrying it (reopened by a human, or settlement interrupted) + // is permanently exempt: every destructive gate fails closed + // on this label, and the candidate query excludes it. + const PENDING_LABEL = 'stale-close-pending'; + const HANDLED_LABEL = 'auto-closed-stale'; + const CLOSING_LABEL = 'closing-soon'; + const MARKER = ''; + // The GITHUB_TOKEN posts comments as this identity; markers from + // any other author are untrusted (the marker string is public). + const MARKER_AUTHOR = 'github-actions[bot]'; + // Manual dispatch defaults to dry-run; only scheduled runs (or an + // explicit dry_run=false dispatch) mutate. + const DRY_RUN = ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}; + const cutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000) + .toISOString().slice(0, 10); + + // Maintainer policy: an open issue with no linked PR after + // STALE_DAYS signals insufficient priority — close it with a + // comment directing any follow-up to a new, referencing issue. + // Reopening remains a defensive durable exemption. + const CLOSE_COMMENT = [ + MARKER, + `This issue has been open for more than ${STALE_DAYS} days with no linked pull request, so it is being closed as not planned.`, + '', + 'If this is still needed, please open a new issue with updated context and reference this closed issue instead of reopening it. A linked PR is the strongest signal that the work matters.', + ].join('\n'); + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + let budget = MAX_ITEMS_PER_RUN; + + // Pace every content-generating request to stay under GitHub's + // secondary rate limit (~80/min), and honor Retry-After on + // throttling responses (403/429) with a single retry. + const pacedWrite = async (fn) => { + try { + return await fn(); + } catch (error) { + if (error.status === 403 || error.status === 429) { + const retryAfter = Number(error.response?.headers?.['retry-after']) || 60; + core.warning(`throttled, backing off ${retryAfter}s`); + await sleep(retryAfter * 1000); + return await fn(); + } + throw error; + } finally { + await sleep(1000); + } + }; + + // Live read of the fields every destructive decision depends on, + // including current labels so HANDLED_LABEL can fail closed. + const inspect = async (number) => { + const { repository } = await github.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + state + closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { totalCount } + labels(first: 100) { nodes { name } } + } + } + }`, { owner: context.repo.owner, repo: context.repo.repo, number }); + const issue = repository.issue; + issue.labelNames = issue.labels.nodes.map((l) => l.name); + return issue; + }; + // Fail closed: never destroy an issue that is closed, has a + // closing-reference PR, already carries the handled label, or is + // in the repository's existing closing-soon grace lifecycle. + const isEligible = (live) => + live.state === 'OPEN' + && live.closedByPullRequestsReferences.totalCount === 0 + && !live.labelNames.includes(HANDLED_LABEL) + && !live.labelNames.includes(CLOSING_LABEL); + + // Trusted, transaction-bound marker lookup over ALL comments: + // only comments authored by the workflow identity count, and when + // a transaction anchor is known the marker must not predate it — + // an old or user-pasted marker string must neither suppress the + // official notice nor shift the reopen-exemption anchor. + const findTrustedMarker = async (number, notBefore) => { + const comments = await github.paginate(github.rest.issues.listComments, { + ...context.repo, issue_number: number, per_page: 100, + }); + return comments.find((c) => + c.body?.includes(MARKER) + && c.user?.login === MARKER_AUTHOR + && (!notBefore || c.created_at >= notBefore)) ?? null; + }; + // Full event history (paginated) — never a bounded window. + const listAllEvents = async (number) => github.paginate( + github.rest.issues.listEvents, + { ...context.repo, issue_number: number, per_page: 100 }, + ); + + const removePendingLabel = async (number) => { + await pacedWrite(() => github.rest.issues.removeLabel({ + ...context.repo, issue_number: number, name: PENDING_LABEL, + }).catch((e) => { if (e.status !== 404) throw e; })); + }; + const settleLabels = async (number) => { + await pacedWrite(() => github.rest.issues.addLabels({ + ...context.repo, issue_number: number, labels: [HANDLED_LABEL], + })); + await removePendingLabel(number); + }; + // Cleanup debt: label-hygiene failures after a terminal outcome + // has been decided. Tracked separately from the mutually + // exclusive terminal counters; phase 0/1 recover it next run. + let cleanupDebt = 0; + const tryCleanup = async (number, what, fn) => { + try { + await fn(); + return true; + } catch (error) { + cleanupDebt += 1; + core.warning(`#${number} cleanup (${what}): ${error.message}`); + return false; + } + }; + // A final eligibility recheck can reject an issue after the + // transaction label and notification were written. Roll both + // side effects back immediately so an open issue is not left with + // a false closure notice. Failures remain recoverable cleanup debt. + const rollbackCancelledClose = async (number, commentId) => { + // Keep PENDING_LABEL as the durable retry handle until the false + // notice is gone. If notice deletion fails, phase 1 retries both. + const noticeRemoved = !commentId || await tryCleanup( + number, 'remove notice after cancelled close', + () => pacedWrite(() => github.rest.issues.deleteComment({ + ...context.repo, comment_id: commentId, + }).catch((e) => { if (e.status !== 404) throw e; })), + ); + const pendingRemoved = noticeRemoved && await tryCleanup( + number, 'remove pending label after cancelled close', + () => removePendingLabel(number), + ); + return noticeRemoved && pendingRemoved; + }; + // Close only through this gate: re-verify eligibility (including + // the fail-closed handled label) at the destructive boundary. + // Returns 'ineligible' | 'closed' | 'closed-unsettled'. A label + // settlement failure after a successful close is cleanup debt, + // not a failure — the close DID happen and phase 0 settles later. + const verifyAndClose = async (number) => { + const live = await inspect(number); + if (!isEligible(live)) return 'ineligible'; + await pacedWrite(() => github.rest.issues.update({ + ...context.repo, issue_number: number, + state: 'closed', state_reason: 'not_planned', + })); + const settled = await tryCleanup(number, 'settle labels', () => settleLabels(number)); + return settled ? 'closed' : 'closed-unsettled'; + }; + + const rows = []; + const counts = { closed: 0, recovered: 0, exempt: 0, settled: 0, skipped: 0, failed: 0 }; + const escapeCell = (value) => String(value) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' '); + const report = (number, title, outcome) => rows.push( + `| [#${number}](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${number}) | ${escapeCell(title ?? '')} | ${escapeCell(outcome)} |`, + ); + + // Ensure state labels exist (422 = already exists). + if (!DRY_RUN) { + for (const [name, description] of [ + [PENDING_LABEL, 'Stale-close transaction in progress; recovered on next run if interrupted'], + [HANDLED_LABEL, 'Closed by stale automation; reopening grants a permanent exemption'], + ]) { + try { + await github.rest.issues.createLabel({ + ...context.repo, name, color: 'ededed', description, + }); + } catch (error) { + if (error.status !== 422) throw error; + } + } + } + + const searchIssues = async (q, extra = {}) => { + const { data } = await github.rest.search.issuesAndPullRequests({ + q, per_page: Math.max(Math.min(budget, MAX_ITEMS_PER_RUN), 1), ...extra, + }); + return { total: data.total_count, items: data.items.filter((i) => !i.pull_request), processed: 0 }; + }; + const repoQ = `repo:${context.repo.owner}/${context.repo.repo}`; + + // ── Phase 0: label hygiene — closed issues stuck in pending ── + // (interrupted between close and label settlement; non-destructive) + const stuck = await searchIssues(`${repoQ} is:issue state:closed label:${PENDING_LABEL}`); + for (const issue of stuck.items) { + if (budget <= 0) break; + budget -= 1; + stuck.processed += 1; + if (DRY_RUN) { counts.settled += 1; report(issue.number, issue.title, 'dry run — would settle labels'); continue; } + try { + await settleLabels(issue.number); // primary operation: outcome recorded only on success + counts.settled += 1; + report(issue.number, issue.title, 'labels settled (was closed mid-transaction)'); + } catch (error) { + counts.failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); + } + } + + // ── Phase 1: recover interrupted transactions ── + const pending = await searchIssues(`${repoQ} is:issue state:open label:${PENDING_LABEL}`); + for (const issue of pending.items) { + if (budget <= 0) break; + budget -= 1; + pending.processed += 1; + try { + const live = await inspect(issue.number); + if (live.state !== 'OPEN') { counts.skipped += 1; report(issue.number, issue.title, 'skipped — already closed'); continue; } + + // Fail closed: an OPEN issue already carrying the handled + // label (reopened by a human, or settlement interrupted) is + // permanently exempt — only tidy up the stray pending label. + if (live.labelNames.includes(HANDLED_LABEL)) { + counts.exempt += 1; // terminal decision needs no mutation + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — handled label present, exempt'); continue; } + const ok = await tryCleanup(issue.number, 'remove pending label', () => removePendingLabel(issue.number)); + report(issue.number, issue.title, `exempt — handled label present${ok ? '; stray pending label removed' : '; pending-label cleanup deferred'}`); + continue; + } + + // Transaction anchor: the latest pending-label event when one + // exists; a trusted marker bound to that anchor otherwise. + // Exempt only if a reopen postdates the anchor, over the FULL + // paginated event history. + const events = await listAllEvents(issue.number); + const labelAnchor = events + .filter((e) => e.event === 'labeled' && e.label?.name === PENDING_LABEL) + .map((e) => e.created_at).pop(); + let marker = await findTrustedMarker(issue.number, labelAnchor); + const anchor = labelAnchor ?? marker?.created_at; + const reopenedAfterAnchor = anchor && events.some( + (e) => e.event === 'reopened' && e.created_at > anchor, + ); + if (reopenedAfterAnchor) { + counts.exempt += 1; // terminal decision needs no mutation + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — reopened after close, would mark exempt'); continue; } + const ok = await tryCleanup(issue.number, 'settle exemption labels', () => settleLabels(issue.number)); + report(issue.number, issue.title, `reopened by a user — permanently exempt${ok ? '' : ' (label settlement deferred)'}`); + continue; + } + if (!isEligible(live)) { + counts.skipped += 1; // terminal decision needs no mutation + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — became ineligible, would roll back pending transaction'); continue; } + const rolledBack = await rollbackCancelledClose(issue.number, marker?.id); + report(issue.number, issue.title, `skipped — gained a linked PR or entered closing-soon grace; ${rolledBack ? 'transaction rolled back' : 'rollback cleanup deferred'}`); + continue; + } + if (DRY_RUN) { counts.recovered += 1; report(issue.number, issue.title, 'dry run — would recover close'); continue; } + if (!marker) { + const { data } = await pacedWrite(() => github.rest.issues.createComment({ + ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, + })); + marker = data; + } + const outcome = await verifyAndClose(issue.number); + if (outcome === 'ineligible') { + counts.skipped += 1; + const rolledBack = await rollbackCancelledClose(issue.number, marker?.id); + report(issue.number, issue.title, `skipped at close boundary — eligibility changed; ${rolledBack ? 'transaction rolled back' : 'rollback cleanup deferred'}`); + } else { + counts.recovered += 1; + report(issue.number, issue.title, `closed (recovered from interrupted run)${outcome === 'closed-unsettled' ? ' — label settlement deferred' : ''}`); + } + } catch (error) { + counts.failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); + } + } + + // ── Phase 2: process new candidates ── + const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} -label:${CLOSING_LABEL} created:<${cutoff}`; + core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); + const result = await searchIssues(query, { sort: 'created', order: 'asc' }); + for (const issue of result.items) { + if (budget <= 0) break; + budget -= 1; + result.processed += 1; + try { + const live = await inspect(issue.number); + if (!isEligible(live)) { + counts.skipped += 1; + report(issue.number, issue.title, live.state !== 'OPEN' ? 'skipped — already closed' : 'skipped — gained a linked PR, handled label, or closing-soon grace'); + continue; + } + if (DRY_RUN) { counts.closed += 1; report(issue.number, issue.title, 'dry run — would close'); continue; } + // Transaction: pending label → notification → verified close. + // Any interruption leaves open+pending for phase-1 recovery. + await pacedWrite(() => github.rest.issues.addLabels({ + ...context.repo, issue_number: issue.number, labels: [PENDING_LABEL], + })); + const { data: closeNotice } = await pacedWrite(() => github.rest.issues.createComment({ + ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, + })); + const outcome = await verifyAndClose(issue.number); + if (outcome === 'ineligible') { + counts.skipped += 1; + const rolledBack = await rollbackCancelledClose(issue.number, closeNotice.id); + report(issue.number, issue.title, `skipped at close boundary — eligibility changed; ${rolledBack ? 'transaction rolled back' : 'rollback cleanup deferred'}`); + } else { + counts.closed += 1; + report(issue.number, issue.title, `closed${outcome === 'closed-unsettled' ? ' — label settlement deferred' : ''}`); + } + } catch (error) { + counts.failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); + } + } + + if (rows.length === 0) { + rows.push('| — | No candidates found | — |'); + } + + // Accounting invariant: every budget decrement lands in exactly + // one terminal outcome; cleanup debt is tracked separately. + // Per-phase backlog = matches minus processed. + const processed = MAX_ITEMS_PER_RUN - budget; + const outcomeSum = Object.values(counts).reduce((a, b) => a + b, 0); + const deferred = Math.max(stuck.total - stuck.processed, 0) + + Math.max(pending.total - pending.processed, 0) + + Math.max(result.total - result.processed, 0); + const summary = [ + `Query: \`${query}\``, + '', + `Matches: **${result.total}** new candidate(s), **${pending.total}** pending recovery, **${stuck.total}** label-settlement.`, + `Processed **${processed}** of budget ${MAX_ITEMS_PER_RUN}${DRY_RUN ? ' — **dry run, no changes made**' : ''}: closed **${counts.closed}**, recovered **${counts.recovered}**, exempt **${counts.exempt}**, settled **${counts.settled}**, skipped **${counts.skipped}**, failed **${counts.failed}**. Cleanup deferred to next run: **${cleanupDebt}**. Remaining backlog: **${deferred}**.`, + '', + '| Issue | Title | Outcome |', + '|---|---|---|', + ...rows, + ]; + + await core.summary + .addHeading('Unlinked stale issue closure report') + .addRaw(summary.join('\n')) + .write(); + + core.info(`processed ${processed}; outcomes ${outcomeSum}; cleanup debt ${cleanupDebt}; deferred ${deferred}`); + if (outcomeSum !== processed) { + core.setFailed(`accounting invariant violated: processed ${processed} but terminal outcomes sum to ${outcomeSum}`); + } else if (counts.failed > 0) { + core.setFailed(`${counts.failed} issue(s) could not be processed`); + } diff --git a/.github/workflows/report-unlinked-stale-issues.yml b/.github/workflows/report-unlinked-stale-issues.yml deleted file mode 100644 index 2b0d5ed04..000000000 --- a/.github/workflows/report-unlinked-stale-issues.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Report stale issues with no linked PR - -on: - schedule: - - cron: '30 9 * * *' # daily at 09:30 UTC - workflow_dispatch: {} - -jobs: - report-unlinked-stale: - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - issues: read - steps: - - uses: actions/github-script@v7 - with: - script: | - const STALE_DAYS = 14; - const MAX_REPORT_ITEMS = 50; - const cutoff = new Date(Date.now() - STALE_DAYS * 24 * 60 * 60 * 1000) - .toISOString().slice(0, 10); - - // This query is a triage signal only. Issue age and the absence of - // a closing-reference PR do not authorize changing issue state. - const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open -linked:pr created:<${cutoff}`; - core.info(`query: ${query}`); - - const { data: result } = await github.rest.search.issuesAndPullRequests({ - q: query, - sort: 'created', - order: 'asc', - per_page: MAX_REPORT_ITEMS, - }); - - const candidates = result.items.slice(0, MAX_REPORT_ITEMS); - const escapeCell = (value) => String(value) - .replace(/\\/g, '\\\\') - .replace(/\|/g, '\\|') - .replace(/\r?\n/g, ' '); - - const rows = candidates.map((issue) => { - const labels = issue.labels - .map((label) => typeof label === 'string' ? label : label.name) - .filter(Boolean) - .join(', ') || '—'; - return `| [#${issue.number}](${issue.html_url}) | ${escapeCell(issue.title)} | ${issue.created_at.slice(0, 10)} | ${issue.updated_at.slice(0, 10)} | ${escapeCell(labels)} |`; - }); - - if (rows.length === 0) { - rows.push('| — | No candidates found | — | — | — |'); - } - - const remaining = Math.max(result.total_count - candidates.length, 0); - const summary = [ - `Query: \`${query}\``, - '', - `Found **${result.total_count}** open issue(s) created more than ${STALE_DAYS} days ago with no closing-reference PR.`, - `Showing the oldest **${candidates.length}** candidate(s).${remaining > 0 ? ` **${remaining}** additional candidate(s) are not shown.` : ''}`, - '', - '> This workflow is report-only. It does not comment on, label, or close issues.', - '', - '| Issue | Title | Created | Updated | Labels |', - '|---|---|---|---|---|', - ...rows, - ]; - - await core.summary - .addHeading('Unlinked stale issue triage report') - .addRaw(summary.join('\n')) - .write(); - - core.info(`reported ${candidates.length} of ${result.total_count} candidate issue(s); no issue state was changed`);