From b9b5cc684ef1c708f877f1036837a7489c67135d Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:32:07 -0400 Subject: [PATCH 1/8] ci(issues)!: close unlinked stale issues instead of report-only Maintainer decision: an open issue with no linked PR after 14 days signals insufficient priority. Replace the report-only triage scan with comment-and-close (state_reason: not_planned). - Comment tells the reporter to reopen or file a new issue if needed - workflow_dispatch gains a dry_run input for safe verification - Job summary reports per-issue outcome; run fails if any close errors - Permissions raised to issues: write accordingly BREAKING CHANGE: the daily scan now closes matching issues instead of only reporting them. --- .../workflows/close-unlinked-stale-issues.yml | 109 ++++++++++++++++++ .../report-unlinked-stale-issues.yml | 72 ------------ 2 files changed, 109 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/close-unlinked-stale-issues.yml delete mode 100644 .github/workflows/report-unlinked-stale-issues.yml diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml new file mode 100644 index 000000000..c60baaca3 --- /dev/null +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -0,0 +1,109 @@ +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, do not comment or close' + type: boolean + default: false + +jobs: + close-unlinked-stale: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + issues: write + steps: + - uses: actions/github-script@v7 + with: + script: | + const STALE_DAYS = 14; + const MAX_ITEMS_PER_RUN = 50; + 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. Closing is cheap to reverse: reopen or file a new + // issue if it still matters. + const CLOSE_COMMENT = [ + `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, feel free to reopen it or file a new issue with updated context — a linked PR is the strongest signal that the work matters.', + ].join('\n'); + + const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open -linked:pr created:<${cutoff}`; + core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); + + const { data: result } = await github.rest.search.issuesAndPullRequests({ + q: query, + sort: 'created', + order: 'asc', + per_page: MAX_ITEMS_PER_RUN, + }); + + const candidates = result.items.filter((item) => !item.pull_request); + const escapeCell = (value) => String(value) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' '); + + const rows = []; + let closed = 0; + let failed = 0; + + for (const issue of candidates) { + let outcome = 'dry run — skipped'; + if (!DRY_RUN) { + try { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: issue.number, + body: CLOSE_COMMENT, + }); + await github.rest.issues.update({ + ...context.repo, + issue_number: issue.number, + state: 'closed', + state_reason: 'not_planned', + }); + outcome = 'closed'; + closed += 1; + } catch (error) { + outcome = `failed: ${error.message}`; + failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + } + } + rows.push(`| [#${issue.number}](${issue.html_url}) | ${escapeCell(issue.title)} | ${issue.created_at.slice(0, 10)} | ${escapeCell(outcome)} |`); + } + + 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.`, + `${DRY_RUN ? 'Dry run — no changes made to' : `Closed **${closed}** of`} the oldest **${candidates.length}** candidate(s).${failed > 0 ? ` **${failed}** failed.` : ''}${remaining > 0 ? ` **${remaining}** additional candidate(s) will be handled on subsequent runs.` : ''}`, + '', + '| Issue | Title | Created | Outcome |', + '|---|---|---|---|', + ...rows, + ]; + + await core.summary + .addHeading('Unlinked stale issue closure report') + .addRaw(summary.join('\n')) + .write(); + + core.info(`${DRY_RUN ? 'dry run: would close' : 'closed'} ${DRY_RUN ? candidates.length : closed} of ${result.total_count} candidate issue(s)`); + if (failed > 0) { + core.setFailed(`${failed} issue(s) could not be closed`); + } 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`); From 61bc0ca667377f603217e85f48a1dc0166d0b5f2 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:50:20 -0400 Subject: [PATCH 2/8] ci(issues): pace close operations to respect secondary rate limits --- .github/workflows/close-unlinked-stale-issues.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index c60baaca3..c04b8c49b 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -78,6 +78,10 @@ jobs: failed += 1; core.warning(`#${issue.number}: ${error.message}`); } + // Pace mutative requests: GitHub's secondary rate limit caps + // content-generating requests (~80/min); 2 writes per issue + // at full speed would exceed it at the 50-issue cap. + await new Promise((resolve) => setTimeout(resolve, 1000)); } rows.push(`| [#${issue.number}](${issue.html_url}) | ${escapeCell(issue.title)} | ${issue.created_at.slice(0, 10)} | ${escapeCell(outcome)} |`); } From 034e2b70947c6d11f4dfd48368b77b772b78a3a6 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:03:13 -0400 Subject: [PATCH 3/8] fix(ci): make stale-issue closure paced, idempotent, and reopen-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 1 on #1417: - F1: pace every content-generating write (1s) and honor Retry-After backoff on 403/429 throttling responses - F2: label-before-mutate with an auto-closed-stale marker label, a reconciliation phase that finishes interrupted closes without duplicate comments, and a workflow concurrency group serializing scheduled and manual runs - F3: the marker label survives reopening and is excluded from the candidate query, so user-reopened issues are permanently exempt — the notification text now states this - F4: revalidate each candidate via GraphQL immediately before mutating; skip issues that closed or gained a closing-reference PR since the initial search --- .../workflows/close-unlinked-stale-issues.yml | 199 +++++++++++++----- 1 file changed, 152 insertions(+), 47 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index c04b8c49b..c92e67fef 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -10,10 +10,16 @@ on: type: boolean default: false +# 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: 10 + timeout-minutes: 15 permissions: issues: write steps: @@ -22,83 +28,182 @@ jobs: script: | const STALE_DAYS = 14; const MAX_ITEMS_PER_RUN = 50; + const HANDLED_LABEL = 'auto-closed-stale'; + const MARKER = ''; 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. Closing is cheap to reverse: reopen or file a new - // issue if it still matters. + // comment. The HANDLED_LABEL survives reopening, so a human + // reopening an issue permanently exempts it from this automation. 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, feel free to reopen it or file a new issue with updated context — a linked PR is the strongest signal that the work matters.', + 'If this is still needed, feel free to reopen it — reopened issues are exempt from this automation — or file a new issue with updated context. A linked PR is the strongest signal that the work matters.', ].join('\n'); - const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open -linked:pr created:<${cutoff}`; - core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + + // 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); + } + }; + + // Revalidate a candidate immediately before mutating: the search + // result is point-in-time, and an issue can be closed or gain a + // closing-reference PR while earlier candidates are processed. + const revalidate = 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 } + timelineItems(itemTypes: [REOPENED_EVENT], first: 1) { totalCount } + comments(last: 30) { nodes { body } } + } + } + }`, { ...context.repo, owner: context.repo.owner, repo: context.repo.repo, number }); + return repository.issue; + }; + + const closeIssue = async (number) => { + await pacedWrite(() => github.rest.issues.update({ + ...context.repo, + issue_number: number, + state: 'closed', + state_reason: 'not_planned', + })); + }; + + const rows = []; + let closed = 0; + let failed = 0; + const escapeCell = (value) => String(value) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\r?\n/g, ' '); + const report = (issue, outcome) => rows.push( + `| [#${issue.number}](${issue.html_url ?? `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issue.number}`}) | ${escapeCell(issue.title ?? '')} | ${escapeCell(outcome)} |`, + ); + + // Ensure the marker label exists (no-op if it already does). + if (!DRY_RUN) { + try { + await github.rest.issues.createLabel({ + ...context.repo, + name: HANDLED_LABEL, + color: 'ededed', + description: 'Closed by stale automation; survives reopening as an exemption marker', + }); + } catch (error) { + if (error.status !== 422) throw error; + } + } + // ── Phase 1: reconcile previously handled issues that are open ── + // label present + open means either (a) a human reopened it → + // permanently exempt, or (b) a previous run was interrupted + // between labeling and closing → finish the close phase. + const { data: pending } = await github.rest.search.issuesAndPullRequests({ + q: `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open label:${HANDLED_LABEL}`, + per_page: MAX_ITEMS_PER_RUN, + }); + for (const issue of pending.items.filter((item) => !item.pull_request)) { + try { + const live = await revalidate(issue.number); + if (live.state !== 'OPEN') { continue; } + if (live.timelineItems.totalCount > 0) { + report(issue, 'reopened by a user — exempt'); + continue; + } + if (DRY_RUN) { report(issue, 'dry run — would recover close'); continue; } + if (!live.comments.nodes.some((c) => c.body.includes(MARKER))) { + await pacedWrite(() => github.rest.issues.createComment({ + ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, + })); + } + await closeIssue(issue.number); + closed += 1; + report(issue, 'closed (recovered from interrupted run)'); + } catch (error) { + failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue, `failed: ${error.message}`); + } + } + + // ── Phase 2: close new candidates ── + const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open -linked:pr -label:${HANDLED_LABEL} created:<${cutoff}`; + core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); const { data: result } = await github.rest.search.issuesAndPullRequests({ q: query, sort: 'created', order: 'asc', per_page: MAX_ITEMS_PER_RUN, }); - const candidates = result.items.filter((item) => !item.pull_request); - const escapeCell = (value) => String(value) - .replace(/\\/g, '\\\\') - .replace(/\|/g, '\\|') - .replace(/\r?\n/g, ' '); - - const rows = []; - let closed = 0; - let failed = 0; for (const issue of candidates) { - let outcome = 'dry run — skipped'; - if (!DRY_RUN) { - try { - await github.rest.issues.createComment({ - ...context.repo, - issue_number: issue.number, - body: CLOSE_COMMENT, - }); - await github.rest.issues.update({ - ...context.repo, - issue_number: issue.number, - state: 'closed', - state_reason: 'not_planned', - }); - outcome = 'closed'; - closed += 1; - } catch (error) { - outcome = `failed: ${error.message}`; - failed += 1; - core.warning(`#${issue.number}: ${error.message}`); + try { + const live = await revalidate(issue.number); + if (live.state !== 'OPEN') { + report(issue, 'skipped — already closed'); + continue; + } + if (live.closedByPullRequestsReferences.totalCount > 0) { + report(issue, 'skipped — gained a linked PR'); + continue; } - // Pace mutative requests: GitHub's secondary rate limit caps - // content-generating requests (~80/min); 2 writes per issue - // at full speed would exceed it at the 50-issue cap. - await new Promise((resolve) => setTimeout(resolve, 1000)); + if (DRY_RUN) { report(issue, 'dry run — would close'); continue; } + // Label first: if interrupted, phase 1 finishes the close on + // the next run instead of posting a duplicate notification. + await pacedWrite(() => github.rest.issues.addLabels({ + ...context.repo, issue_number: issue.number, labels: [HANDLED_LABEL], + })); + await pacedWrite(() => github.rest.issues.createComment({ + ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, + })); + await closeIssue(issue.number); + closed += 1; + report(issue, 'closed'); + } catch (error) { + failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue, `failed: ${error.message}`); } - rows.push(`| [#${issue.number}](${issue.html_url}) | ${escapeCell(issue.title)} | ${issue.created_at.slice(0, 10)} | ${escapeCell(outcome)} |`); } if (rows.length === 0) { - rows.push('| — | No candidates found | — | — |'); + 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.`, - `${DRY_RUN ? 'Dry run — no changes made to' : `Closed **${closed}** of`} the oldest **${candidates.length}** candidate(s).${failed > 0 ? ` **${failed}** failed.` : ''}${remaining > 0 ? ` **${remaining}** additional candidate(s) will be handled on subsequent runs.` : ''}`, + `Found **${result.total_count}** candidate(s); processed the oldest **${candidates.length}** plus **${pending.total_count}** pending reconciliation.`, + `${DRY_RUN ? 'Dry run — no changes made.' : `Closed **${closed}**.`}${failed > 0 ? ` **${failed}** failed.` : ''}${remaining > 0 ? ` **${remaining}** additional candidate(s) will be handled on subsequent runs.` : ''}`, '', - '| Issue | Title | Created | Outcome |', - '|---|---|---|---|', + '| Issue | Title | Outcome |', + '|---|---|---|', ...rows, ]; @@ -107,7 +212,7 @@ jobs: .addRaw(summary.join('\n')) .write(); - core.info(`${DRY_RUN ? 'dry run: would close' : 'closed'} ${DRY_RUN ? candidates.length : closed} of ${result.total_count} candidate issue(s)`); + core.info(`${DRY_RUN ? 'dry run complete' : `closed ${closed} issue(s)`}; ${failed} failure(s)`); if (failed > 0) { - core.setFailed(`${failed} issue(s) could not be closed`); + core.setFailed(`${failed} issue(s) could not be processed`); } From a0c483e0247451e86211f670ed6d097a13bd8264 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:17:30 -0400 Subject: [PATCH 4/8] fix(ci): two-label state machine for safe stale-issue closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 2 on #1417: - F1 (critical): every close goes through verifyAndClose(), which re-reads state and closedByPullRequestsReferences at the destructive boundary in both recovery and normal paths - F2: reopen exemption decided by event ordering — a REOPENED_EVENT must postdate the transaction anchor (marker comment, else the pending-label event) — not by lifetime reopen count - F3: split states: stale-close-pending marks in-flight transactions (recovery queue contains only interrupted work); auto-closed-stale is applied at successful close and acts as a passive query-excluded exemption when a human reopens — exemptions never occupy the queue - F4: one global MAX_ITEMS_PER_RUN budget shared across all phases; summary distinguishes matched / processed / closed / recovered / exempt / skipped / failed / deferred - F5: marker lookup paginates all comments instead of a bounded window Also adds phase 0 label hygiene for issues closed mid-transaction. --- .../workflows/close-unlinked-stale-issues.yml | 246 ++++++++++++------ 1 file changed, 166 insertions(+), 80 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index c92e67fef..e197b12d6 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -27,7 +27,14 @@ jobs: with: script: | const STALE_DAYS = 14; - const MAX_ITEMS_PER_RUN = 50; + 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. If a human later + // reopens the issue it stays open+handled: permanently exempt + // via query exclusion, never re-entering any queue. + const PENDING_LABEL = 'stale-close-pending'; const HANDLED_LABEL = 'auto-closed-stale'; const MARKER = ''; const DRY_RUN = ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false }}; @@ -36,8 +43,7 @@ jobs: // Maintainer policy: an open issue with no linked PR after // STALE_DAYS signals insufficient priority — close it with a - // comment. The HANDLED_LABEL survives reopening, so a human - // reopening an issue permanently exempts it from this automation. + // comment. Reopening is a 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.`, @@ -46,6 +52,7 @@ jobs: ].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 @@ -66,128 +73,206 @@ jobs: } }; - // Revalidate a candidate immediately before mutating: the search - // result is point-in-time, and an issue can be closed or gain a - // closing-reference PR while earlier candidates are processed. - const revalidate = async (number) => { + // Live read of the fields every destructive decision depends on. + 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 } - timelineItems(itemTypes: [REOPENED_EVENT], first: 1) { totalCount } - comments(last: 30) { nodes { body } } + timelineItems(itemTypes: [REOPENED_EVENT, LABELED_EVENT], last: 50) { + nodes { + __typename + ... on ReopenedEvent { createdAt } + ... on LabeledEvent { createdAt label { name } } + } + } } } - }`, { ...context.repo, owner: context.repo.owner, repo: context.repo.repo, number }); + }`, { owner: context.repo.owner, repo: context.repo.repo, number }); return repository.issue; }; + const isEligible = (live) => + live.state === 'OPEN' && live.closedByPullRequestsReferences.totalCount === 0; - const closeIssue = async (number) => { + // Authoritative marker lookup: paginate ALL comments — a bounded + // window can miss the marker under later comment activity. + const findMarker = async (number) => { + 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)) ?? null; + }; + + // Close only through this gate: re-verify eligibility at the + // destructive boundary, then close and settle labels. + const verifyAndClose = async (number) => { + const live = await inspect(number); + if (!isEligible(live)) return false; await pacedWrite(() => github.rest.issues.update({ - ...context.repo, - issue_number: number, - state: 'closed', - state_reason: 'not_planned', + ...context.repo, issue_number: number, + state: 'closed', state_reason: 'not_planned', })); + await settleLabels(number); + return true; + }; + const settleLabels = async (number) => { + await pacedWrite(() => github.rest.issues.addLabels({ + ...context.repo, issue_number: number, labels: [HANDLED_LABEL], + })); + await pacedWrite(async () => { + try { + await github.rest.issues.removeLabel({ + ...context.repo, issue_number: number, name: PENDING_LABEL, + }); + } catch (error) { + if (error.status !== 404) throw error; + } + }); }; const rows = []; - let closed = 0; - let failed = 0; + const counts = { closed: 0, recovered: 0, exempt: 0, skipped: 0, failed: 0, deferred: 0 }; const escapeCell = (value) => String(value) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|') .replace(/\r?\n/g, ' '); - const report = (issue, outcome) => rows.push( - `| [#${issue.number}](${issue.html_url ?? `https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${issue.number}`}) | ${escapeCell(issue.title ?? '')} | ${escapeCell(outcome)} |`, + const report = (number, title, outcome) => rows.push( + `| [#${number}](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/${number}) | ${escapeCell(title ?? '')} | ${escapeCell(outcome)} |`, ); - // Ensure the marker label exists (no-op if it already does). + // 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.min(budget, MAX_ITEMS_PER_RUN) || 1, ...extra, + }); + return { total: data.total_count, items: data.items.filter((i) => !i.pull_request) }; + }; + 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) { counts.deferred += 1; continue; } + budget -= 1; + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — would settle labels'); continue; } try { - await github.rest.issues.createLabel({ - ...context.repo, - name: HANDLED_LABEL, - color: 'ededed', - description: 'Closed by stale automation; survives reopening as an exemption marker', - }); + await settleLabels(issue.number); + report(issue.number, issue.title, 'labels settled (was closed mid-transaction)'); } catch (error) { - if (error.status !== 422) throw error; + counts.failed += 1; + core.warning(`#${issue.number}: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); } } - // ── Phase 1: reconcile previously handled issues that are open ── - // label present + open means either (a) a human reopened it → - // permanently exempt, or (b) a previous run was interrupted - // between labeling and closing → finish the close phase. - const { data: pending } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open label:${HANDLED_LABEL}`, - per_page: MAX_ITEMS_PER_RUN, - }); - for (const issue of pending.items.filter((item) => !item.pull_request)) { + // ── 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) { counts.deferred += 1; continue; } + budget -= 1; try { - const live = await revalidate(issue.number); - if (live.state !== 'OPEN') { continue; } - if (live.timelineItems.totalCount > 0) { - report(issue, 'reopened by a user — exempt'); + const live = await inspect(issue.number); + if (live.state !== 'OPEN') { counts.skipped += 1; report(issue.number, issue.title, 'skipped — already closed'); continue; } + + // Reopen ordering (not lifetime count): exempt only if a + // reopen happened AFTER this transaction's anchor — the + // marker comment, or failing that, the pending-label event. + const marker = await findMarker(issue.number); + const events = live.timelineItems.nodes; + const anchor = marker?.created_at + ?? events.filter((e) => e.__typename === 'LabeledEvent' && e.label?.name === PENDING_LABEL).map((e) => e.createdAt).pop(); + const reopenedAfterAnchor = anchor && events.some( + (e) => e.__typename === 'ReopenedEvent' && e.createdAt > anchor, + ); + if (reopenedAfterAnchor) { + counts.exempt += 1; + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — reopened after close, would mark exempt'); continue; } + await settleLabels(issue.number); // open+handled = durable exemption + report(issue.number, issue.title, 'reopened by a user — permanently exempt'); + continue; + } + if (!isEligible(live)) { + counts.skipped += 1; + if (!DRY_RUN) { + await pacedWrite(() => github.rest.issues.removeLabel({ + ...context.repo, issue_number: issue.number, name: PENDING_LABEL, + }).catch((e) => { if (e.status !== 404) throw e; })); + } + report(issue.number, issue.title, 'skipped — gained a linked PR; pending label removed'); continue; } - if (DRY_RUN) { report(issue, 'dry run — would recover close'); continue; } - if (!live.comments.nodes.some((c) => c.body.includes(MARKER))) { + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — would recover close'); continue; } + if (!marker) { await pacedWrite(() => github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, })); } - await closeIssue(issue.number); - closed += 1; - report(issue, 'closed (recovered from interrupted run)'); + if (await verifyAndClose(issue.number)) { + counts.recovered += 1; + report(issue.number, issue.title, 'closed (recovered from interrupted run)'); + } else { + counts.skipped += 1; + report(issue.number, issue.title, 'skipped at close boundary — eligibility changed'); + } } catch (error) { - failed += 1; + counts.failed += 1; core.warning(`#${issue.number}: ${error.message}`); - report(issue, `failed: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); } } - // ── Phase 2: close new candidates ── - const query = `repo:${context.repo.owner}/${context.repo.repo} is:issue state:open -linked:pr -label:${HANDLED_LABEL} created:<${cutoff}`; + // ── Phase 2: process new candidates ── + const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} created:<${cutoff}`; core.info(`query: ${query}${DRY_RUN ? ' (dry run)' : ''}`); - const { data: result } = await github.rest.search.issuesAndPullRequests({ - q: query, - sort: 'created', - order: 'asc', - per_page: MAX_ITEMS_PER_RUN, - }); - const candidates = result.items.filter((item) => !item.pull_request); - - for (const issue of candidates) { + const result = await searchIssues(query, { sort: 'created', order: 'asc' }); + for (const issue of result.items) { + if (budget <= 0) { counts.deferred += 1; continue; } + budget -= 1; try { - const live = await revalidate(issue.number); - if (live.state !== 'OPEN') { - report(issue, 'skipped — already closed'); + 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'); continue; } - if (live.closedByPullRequestsReferences.totalCount > 0) { - report(issue, 'skipped — gained a linked PR'); - continue; - } - if (DRY_RUN) { report(issue, 'dry run — would close'); continue; } - // Label first: if interrupted, phase 1 finishes the close on - // the next run instead of posting a duplicate notification. + if (DRY_RUN) { 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: [HANDLED_LABEL], + ...context.repo, issue_number: issue.number, labels: [PENDING_LABEL], })); await pacedWrite(() => github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, })); - await closeIssue(issue.number); - closed += 1; - report(issue, 'closed'); + if (await verifyAndClose(issue.number)) { + counts.closed += 1; + report(issue.number, issue.title, 'closed'); + } else { + counts.skipped += 1; + report(issue.number, issue.title, 'skipped at close boundary — eligibility changed (recovery will re-check)'); + } } catch (error) { - failed += 1; + counts.failed += 1; core.warning(`#${issue.number}: ${error.message}`); - report(issue, `failed: ${error.message}`); + report(issue.number, issue.title, `failed: ${error.message}`); } } @@ -195,12 +280,13 @@ jobs: rows.push('| — | No candidates found | — |'); } - const remaining = Math.max(result.total_count - candidates.length, 0); + const processed = MAX_ITEMS_PER_RUN - budget; + const unqueried = Math.max(result.total - result.items.length, 0); const summary = [ `Query: \`${query}\``, '', - `Found **${result.total_count}** candidate(s); processed the oldest **${candidates.length}** plus **${pending.total_count}** pending reconciliation.`, - `${DRY_RUN ? 'Dry run — no changes made.' : `Closed **${closed}**.`}${failed > 0 ? ` **${failed}** failed.` : ''}${remaining > 0 ? ` **${remaining}** additional candidate(s) will be handled on subsequent runs.` : ''}`, + `Matches: **${result.total}** new candidate(s), **${pending.total}** pending recovery, **${stuck.total}** label-settlement.`, + `Processed **${processed}** (budget ${MAX_ITEMS_PER_RUN})${DRY_RUN ? ' — dry run, no changes made' : ''}: closed **${counts.closed}**, recovered **${counts.recovered}**, exempt **${counts.exempt}**, skipped **${counts.skipped}**, failed **${counts.failed}**. Deferred to next run: **${counts.deferred + unqueried}**.`, '', '| Issue | Title | Outcome |', '|---|---|---|', @@ -212,7 +298,7 @@ jobs: .addRaw(summary.join('\n')) .write(); - core.info(`${DRY_RUN ? 'dry run complete' : `closed ${closed} issue(s)`}; ${failed} failure(s)`); - if (failed > 0) { - core.setFailed(`${failed} issue(s) could not be processed`); + core.info(`processed ${processed}; closed ${counts.closed + counts.recovered}; failed ${counts.failed}`); + if (counts.failed > 0) { + core.setFailed(`${counts.failed} issue(s) could not be processed`); } From 8f8fa808c2679c9d71300a68b851ddde8fe4231a Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:56:09 -0400 Subject: [PATCH 5/8] fix(ci): fail-closed handled label, safe manual default, reconciled accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 3 on #1417: - F1 (critical): inspect() now returns live labels and isEligible() fails closed on auto-closed-stale in every destructive gate — an OPEN+PENDING+HANDLED issue only has its stray pending label removed, never re-closed. Reopen-ordering detection now paginates the FULL REST event history instead of a bounded 50-node GraphQL window. - F2: workflow_dispatch dry_run now defaults to true — a bare manual 'Run workflow' is report-only; mutation requires explicitly setting dry_run=false. Scheduled runs remain mutating per policy. - F3: added settled outcome counter; per-phase processed counters and deferred backlog computed from each phase's total matches; run logs warn if named outcomes fail to reconcile with the consumed budget. --- .../workflows/close-unlinked-stale-issues.yml | 136 +++++++++++------- 1 file changed, 81 insertions(+), 55 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index e197b12d6..affe7715c 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -6,9 +6,9 @@ on: workflow_dispatch: inputs: dry_run: - description: 'Report only, do not comment or close' + description: 'Report only (default). Set to false to actually comment and close.' type: boolean - default: false + default: true # Serialize mutating runs: a scheduled run and a manual dispatch must not # interleave comment/close operations on the same candidates. @@ -31,12 +31,15 @@ jobs: // 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. If a human later - // reopens the issue it stays open+handled: permanently exempt - // via query exclusion, never re-entering any queue. + // 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 MARKER = ''; + // 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); @@ -73,7 +76,8 @@ jobs: } }; - // Live read of the fields every destructive decision depends on. + // 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!) { @@ -81,20 +85,20 @@ jobs: issue(number: $number) { state closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { totalCount } - timelineItems(itemTypes: [REOPENED_EVENT, LABELED_EVENT], last: 50) { - nodes { - __typename - ... on ReopenedEvent { createdAt } - ... on LabeledEvent { createdAt label { name } } - } - } + labels(first: 100) { nodes { name } } } } }`, { owner: context.repo.owner, repo: context.repo.repo, number }); - return repository.issue; + 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, or already carries the handled label. const isEligible = (live) => - live.state === 'OPEN' && live.closedByPullRequestsReferences.totalCount === 0; + live.state === 'OPEN' + && live.closedByPullRequestsReferences.totalCount === 0 + && !live.labelNames.includes(HANDLED_LABEL); // Authoritative marker lookup: paginate ALL comments — a bounded // window can miss the marker under later comment activity. @@ -104,9 +108,25 @@ jobs: }); return comments.find((c) => c.body?.includes(MARKER)) ?? 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 }, + ); - // Close only through this gate: re-verify eligibility at the - // destructive boundary, then close and settle labels. + 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); + }; + // Close only through this gate: re-verify eligibility (including + // the fail-closed handled label) at the destructive boundary. const verifyAndClose = async (number) => { const live = await inspect(number); if (!isEligible(live)) return false; @@ -117,23 +137,9 @@ jobs: await settleLabels(number); return true; }; - const settleLabels = async (number) => { - await pacedWrite(() => github.rest.issues.addLabels({ - ...context.repo, issue_number: number, labels: [HANDLED_LABEL], - })); - await pacedWrite(async () => { - try { - await github.rest.issues.removeLabel({ - ...context.repo, issue_number: number, name: PENDING_LABEL, - }); - } catch (error) { - if (error.status !== 404) throw error; - } - }); - }; const rows = []; - const counts = { closed: 0, recovered: 0, exempt: 0, skipped: 0, failed: 0, deferred: 0 }; + const counts = { closed: 0, recovered: 0, exempt: 0, settled: 0, skipped: 0, failed: 0 }; const escapeCell = (value) => String(value) .replace(/\\/g, '\\\\') .replace(/\|/g, '\\|') @@ -160,9 +166,9 @@ jobs: const searchIssues = async (q, extra = {}) => { const { data } = await github.rest.search.issuesAndPullRequests({ - q, per_page: Math.min(budget, MAX_ITEMS_PER_RUN) || 1, ...extra, + 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) }; + return { total: data.total_count, items: data.items.filter((i) => !i.pull_request), processed: 0 }; }; const repoQ = `repo:${context.repo.owner}/${context.repo.repo}`; @@ -170,11 +176,13 @@ jobs: // (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) { counts.deferred += 1; continue; } + if (budget <= 0) break; budget -= 1; - if (DRY_RUN) { report(issue.number, issue.title, 'dry run — would settle labels'); continue; } + 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); + counts.settled += 1; report(issue.number, issue.title, 'labels settled (was closed mid-transaction)'); } catch (error) { counts.failed += 1; @@ -186,21 +194,34 @@ jobs: // ── 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) { counts.deferred += 1; continue; } + 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; } - // Reopen ordering (not lifetime count): exempt only if a - // reopen happened AFTER this transaction's anchor — the - // marker comment, or failing that, the pending-label event. + // 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; + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — handled label present, would remove stray pending label (exempt)'); continue; } + await removePendingLabel(issue.number); + report(issue.number, issue.title, 'exempt — handled label present; stray pending label removed'); + continue; + } + + // Reopen ordering over the FULL event history (paginated): + // exempt only if a reopen postdates this transaction's anchor + // — the marker comment, else the pending-label event. const marker = await findMarker(issue.number); - const events = live.timelineItems.nodes; + const events = await listAllEvents(issue.number); const anchor = marker?.created_at - ?? events.filter((e) => e.__typename === 'LabeledEvent' && e.label?.name === PENDING_LABEL).map((e) => e.createdAt).pop(); + ?? events.filter((e) => e.event === 'labeled' && e.label?.name === PENDING_LABEL) + .map((e) => e.created_at).pop(); const reopenedAfterAnchor = anchor && events.some( - (e) => e.__typename === 'ReopenedEvent' && e.createdAt > anchor, + (e) => e.event === 'reopened' && e.created_at > anchor, ); if (reopenedAfterAnchor) { counts.exempt += 1; @@ -211,15 +232,11 @@ jobs: } if (!isEligible(live)) { counts.skipped += 1; - if (!DRY_RUN) { - await pacedWrite(() => github.rest.issues.removeLabel({ - ...context.repo, issue_number: issue.number, name: PENDING_LABEL, - }).catch((e) => { if (e.status !== 404) throw e; })); - } + if (!DRY_RUN) await removePendingLabel(issue.number); report(issue.number, issue.title, 'skipped — gained a linked PR; pending label removed'); continue; } - if (DRY_RUN) { report(issue.number, issue.title, 'dry run — would recover close'); continue; } + if (DRY_RUN) { counts.recovered += 1; report(issue.number, issue.title, 'dry run — would recover close'); continue; } if (!marker) { await pacedWrite(() => github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, @@ -244,16 +261,17 @@ jobs: 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) { counts.deferred += 1; continue; } + 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'); + report(issue.number, issue.title, live.state !== 'OPEN' ? 'skipped — already closed' : 'skipped — gained a linked PR or handled label'); continue; } - if (DRY_RUN) { report(issue.number, issue.title, 'dry run — would close'); 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({ @@ -280,13 +298,18 @@ jobs: rows.push('| — | No candidates found | — |'); } + // Reconciling accounting: every budget decrement lands in exactly + // one named outcome; per-phase backlog = matches minus processed. const processed = MAX_ITEMS_PER_RUN - budget; - const unqueried = Math.max(result.total - result.items.length, 0); + 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}** (budget ${MAX_ITEMS_PER_RUN})${DRY_RUN ? ' — dry run, no changes made' : ''}: closed **${counts.closed}**, recovered **${counts.recovered}**, exempt **${counts.exempt}**, skipped **${counts.skipped}**, failed **${counts.failed}**. Deferred to next run: **${counts.deferred + unqueried}**.`, + `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}**. Remaining for next run: **${deferred}**.`, '', '| Issue | Title | Outcome |', '|---|---|---|', @@ -298,7 +321,10 @@ jobs: .addRaw(summary.join('\n')) .write(); - core.info(`processed ${processed}; closed ${counts.closed + counts.recovered}; failed ${counts.failed}`); + core.info(`processed ${processed}; outcomes ${outcomeSum}; deferred ${deferred}`); + if (outcomeSum !== processed) { + core.warning(`accounting mismatch: processed ${processed} but outcomes sum to ${outcomeSum}`); + } if (counts.failed > 0) { core.setFailed(`${counts.failed} issue(s) could not be processed`); } From 2d6ef8e5d8fc321a5ec13556e842a0d4921c25ae Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:22:16 -0400 Subject: [PATCH 6/8] fix(ci): trusted transaction-bound marker and single-terminal-outcome accounting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review round 4 on #1417: - F1: findTrustedMarker() accepts only comments authored by github-actions[bot] and, when the transaction's pending-label event exists, only markers at or after it — a stale or user-pasted marker string can neither suppress the official notice nor shift the reopen-exemption anchor. The pending-label event is now the primary anchor; the trusted marker is a fallback only. - F2: every processed item lands in exactly one terminal outcome. Cleanup writes after a terminal decision go through tryCleanup(), which never throws and accumulates a separately reported cleanupDebt (recovered by phases 0/1 next run). verifyAndClose() returns closed/closed-unsettled/ineligible so a successful close with failed label settlement is still reported as closed. The accounting invariant (outcomes == processed) now fails the run instead of warning. --- .../workflows/close-unlinked-stale-issues.yml | 111 +++++++++++------- 1 file changed, 71 insertions(+), 40 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index affe7715c..fbb80ca3b 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -38,6 +38,9 @@ jobs: const PENDING_LABEL = 'stale-close-pending'; const HANDLED_LABEL = 'auto-closed-stale'; 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 }}; @@ -100,13 +103,19 @@ jobs: && live.closedByPullRequestsReferences.totalCount === 0 && !live.labelNames.includes(HANDLED_LABEL); - // Authoritative marker lookup: paginate ALL comments — a bounded - // window can miss the marker under later comment activity. - const findMarker = async (number) => { + // 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)) ?? null; + 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( @@ -125,17 +134,34 @@ jobs: })); 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; + } + }; // 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 false; + if (!isEligible(live)) return 'ineligible'; await pacedWrite(() => github.rest.issues.update({ ...context.repo, issue_number: number, state: 'closed', state_reason: 'not_planned', })); - await settleLabels(number); - return true; + const settled = await tryCleanup(number, 'settle labels', () => settleLabels(number)); + return settled ? 'closed' : 'closed-unsettled'; }; const rows = []; @@ -181,7 +207,7 @@ jobs: 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); + 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) { @@ -205,35 +231,38 @@ jobs: // 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; - if (DRY_RUN) { report(issue.number, issue.title, 'dry run — handled label present, would remove stray pending label (exempt)'); continue; } - await removePendingLabel(issue.number); - report(issue.number, issue.title, 'exempt — handled label present; stray pending label removed'); + 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; } - // Reopen ordering over the FULL event history (paginated): - // exempt only if a reopen postdates this transaction's anchor - // — the marker comment, else the pending-label event. - const marker = await findMarker(issue.number); + // 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 anchor = marker?.created_at - ?? events.filter((e) => e.event === 'labeled' && e.label?.name === PENDING_LABEL) - .map((e) => e.created_at).pop(); + const labelAnchor = events + .filter((e) => e.event === 'labeled' && e.label?.name === PENDING_LABEL) + .map((e) => e.created_at).pop(); + const 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; + 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; } - await settleLabels(issue.number); // open+handled = durable exemption - report(issue.number, issue.title, 'reopened by a user — permanently exempt'); + 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; - if (!DRY_RUN) await removePendingLabel(issue.number); - report(issue.number, issue.title, 'skipped — gained a linked PR; pending label removed'); + counts.skipped += 1; // terminal decision needs no mutation + if (DRY_RUN) { report(issue.number, issue.title, 'dry run — gained a linked PR, would remove pending label'); continue; } + const ok = await tryCleanup(issue.number, 'remove pending label', () => removePendingLabel(issue.number)); + report(issue.number, issue.title, `skipped — gained a linked PR${ok ? '; pending label removed' : '; pending-label cleanup deferred'}`); continue; } if (DRY_RUN) { counts.recovered += 1; report(issue.number, issue.title, 'dry run — would recover close'); continue; } @@ -242,12 +271,13 @@ jobs: ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, })); } - if (await verifyAndClose(issue.number)) { - counts.recovered += 1; - report(issue.number, issue.title, 'closed (recovered from interrupted run)'); - } else { + const outcome = await verifyAndClose(issue.number); + if (outcome === 'ineligible') { counts.skipped += 1; report(issue.number, issue.title, 'skipped at close boundary — eligibility changed'); + } 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; @@ -280,12 +310,13 @@ jobs: await pacedWrite(() => github.rest.issues.createComment({ ...context.repo, issue_number: issue.number, body: CLOSE_COMMENT, })); - if (await verifyAndClose(issue.number)) { - counts.closed += 1; - report(issue.number, issue.title, 'closed'); - } else { + const outcome = await verifyAndClose(issue.number); + if (outcome === 'ineligible') { counts.skipped += 1; report(issue.number, issue.title, 'skipped at close boundary — eligibility changed (recovery will re-check)'); + } else { + counts.closed += 1; + report(issue.number, issue.title, `closed${outcome === 'closed-unsettled' ? ' — label settlement deferred' : ''}`); } } catch (error) { counts.failed += 1; @@ -298,8 +329,9 @@ jobs: rows.push('| — | No candidates found | — |'); } - // Reconciling accounting: every budget decrement lands in exactly - // one named outcome; per-phase backlog = matches minus processed. + // 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) @@ -309,7 +341,7 @@ jobs: `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}**. Remaining for next run: **${deferred}**.`, + `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 |', '|---|---|---|', @@ -321,10 +353,9 @@ jobs: .addRaw(summary.join('\n')) .write(); - core.info(`processed ${processed}; outcomes ${outcomeSum}; deferred ${deferred}`); + core.info(`processed ${processed}; outcomes ${outcomeSum}; cleanup debt ${cleanupDebt}; deferred ${deferred}`); if (outcomeSum !== processed) { - core.warning(`accounting mismatch: processed ${processed} but outcomes sum to ${outcomeSum}`); - } - if (counts.failed > 0) { + 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`); } From d422005e49511d808f8d13d1a635dc6aca659701 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:14:54 +0000 Subject: [PATCH 7/8] fix(ci): roll back cancelled stale closes --- .../workflows/close-unlinked-stale-issues.yml | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index fbb80ca3b..aa9a0cf28 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -37,6 +37,7 @@ jobs: // 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). @@ -97,11 +98,13 @@ jobs: return issue; }; // Fail closed: never destroy an issue that is closed, has a - // closing-reference PR, or already carries the handled label. + // 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(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 @@ -148,6 +151,25 @@ jobs: 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 @@ -246,7 +268,7 @@ jobs: const labelAnchor = events .filter((e) => e.event === 'labeled' && e.label?.name === PENDING_LABEL) .map((e) => e.created_at).pop(); - const marker = await findTrustedMarker(issue.number, labelAnchor); + 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, @@ -260,21 +282,23 @@ jobs: } if (!isEligible(live)) { counts.skipped += 1; // terminal decision needs no mutation - if (DRY_RUN) { report(issue.number, issue.title, 'dry run — gained a linked PR, would remove pending label'); continue; } - const ok = await tryCleanup(issue.number, 'remove pending label', () => removePendingLabel(issue.number)); - report(issue.number, issue.title, `skipped — gained a linked PR${ok ? '; pending label removed' : '; pending-label cleanup deferred'}`); + 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) { - await pacedWrite(() => github.rest.issues.createComment({ + 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; - report(issue.number, issue.title, 'skipped at close boundary — eligibility changed'); + 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' : ''}`); @@ -287,7 +311,7 @@ jobs: } // ── Phase 2: process new candidates ── - const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} created:<${cutoff}`; + 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) { @@ -298,7 +322,7 @@ jobs: 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 or handled label'); + 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; } @@ -307,13 +331,14 @@ jobs: await pacedWrite(() => github.rest.issues.addLabels({ ...context.repo, issue_number: issue.number, labels: [PENDING_LABEL], })); - await pacedWrite(() => github.rest.issues.createComment({ + 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; - report(issue.number, issue.title, 'skipped at close boundary — eligibility changed (recovery will re-check)'); + 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' : ''}`); From 9345a52d885794a58258f049fe5846662aec7042 Mon Sep 17 00:00:00 2001 From: chaodu-agent <274062505+chaodu-agent@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:37:10 +0000 Subject: [PATCH 8/8] fix(ci): direct stale follow-ups to new issues --- .github/workflows/close-unlinked-stale-issues.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-unlinked-stale-issues.yml b/.github/workflows/close-unlinked-stale-issues.yml index aa9a0cf28..3988610b5 100644 --- a/.github/workflows/close-unlinked-stale-issues.yml +++ b/.github/workflows/close-unlinked-stale-issues.yml @@ -50,12 +50,13 @@ jobs: // Maintainer policy: an open issue with no linked PR after // STALE_DAYS signals insufficient priority — close it with a - // comment. Reopening is a durable exemption. + // 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, feel free to reopen it — reopened issues are exempt from this automation — or file a new issue with updated context. A linked PR is the strongest signal that the work matters.', + '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));