Skip to content

ci(issues)!: close unlinked stale issues instead of report-only#1417

Open
chaodu-agent wants to merge 6 commits into
mainfrom
ci/close-unlinked-stale-issues
Open

ci(issues)!: close unlinked stale issues instead of report-only#1417
chaodu-agent wants to merge 6 commits into
mainfrom
ci/close-unlinked-stale-issues

Conversation

@chaodu-agent

Copy link
Copy Markdown
Collaborator

Summary

  • Replace the report-only stale scan (ci(issues): report stale issues with no linked PR #1414) with comment-and-close, per maintainer policy: an open issue with no linked PR after 14 days signals insufficient priority.
  • Each candidate gets a comment explaining the closure and inviting a reopen or a fresh issue if still needed, then is closed with state_reason: not_planned.
  • workflow_dispatch gains a dry_run boolean input (default false) to preview candidates without mutating anything; scheduled runs always close.
  • Job summary reports per-issue outcome (closed / failed); the run fails if any close operation errors.
  • Old workflow file report-unlinked-stale-issues.yml is removed (renamed to close-unlinked-stale-issues.yml).

⚠️ Breaking Change

The daily 09:30 UTC scan now closes matching issues instead of only reporting them. Current candidate set: 17 issues (verified via live query). Closed issues remain searchable and can be reopened at any time.

Safety

  • issues: write scoped to this job only; comment body is static (no user-controlled interpolation).
  • Bounded to 50 issues per run; remainder handled on subsequent runs.
  • dry_run dispatch input for safe verification after merge.

Validation

  • YAML parses cleanly; embedded script passes node --check.
  • Live search contract verified earlier today: query returns issues only, oldest first (17 candidates).

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.
@chaodu-agent
chaodu-agent requested a review from thepagent as a code owner July 16, 2026 22:32
@pahud

pahud commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Note

LGTM ✅ — Correctly implements the maintainer's closure policy with a static notification comment, not_planned semantics, a dry_run escape hatch, and rate-limit pacing.

What This PR Does

Replaces the report-only stale scan (#1414) with comment-and-close, per explicit maintainer policy: an open issue with no linked PR after 14 days signals insufficient priority. Each candidate receives a comment inviting a reopen or a fresh issue, then is closed as not_planned.

How It Works

Same search query and oldest-first ordering as #1414. For each candidate the script posts a static comment, closes with state_reason: not_planned, and paces writes at 1s intervals to stay under GitHub's secondary rate limit (2 writes × 50 issues at full speed would exceed the ~80/min content-generation cap). Per-issue outcomes land in the job summary; any close failure fails the run. workflow_dispatch gains a dry_run boolean for preview without mutation.

Findings

# Severity Finding Location
1 🟢 Policy reversal from #1414 is explicit maintainer direction, documented in the workflow comment and commit body (ci(issues)! breaking-change marker) commit b9b5cc6
2 🟢 Comment body is a static template — no user-controlled interpolation, so issues: write carries no injection surface script CLOSE_COMMENT
3 🟢 state_reason: not_planned + reopen invitation makes closure cheap to reverse; closed issues stay searchable script close call
4 🟢 1s pacing between mutative calls prevents secondary-rate-limit aborts at the 50-issue cap commit 61bc0ca
Baseline Check
  • Main has: report-unlinked-stale-issues.yml (report-only, merged today in ci(issues): report stale issues with no linked PR #1414) — removed here, renamed to close-unlinked-stale-issues.yml with write behavior.
  • Existing label-driven lifecycle (stale-issue.ymlclose-stale-issues.yml) is untouched and complementary: it handles pending-contributor response timeouts; this handles PR-less aging.
  • Net-new value: enforcement of the no-linked-PR closure policy. Current candidate set: 17 issues.
Verified

On PR head 61bc0ca:

  • YAML parses cleanly (yaml.safe_load OK); embedded script passes node --check.
  • ${{ ... }} dry-run expression renders to a bare JS boolean on both schedule (false) and workflow_dispatch paths.
  • CI: check ✅, poll-and-review ✅ (one earlier failure was the GitHub REST API incident of 2026-07-16 ~22:20–23:48 UTC, not this change; passed on rerun after recovery).
  • Live query contract verified earlier today: 17 candidates, issues only, oldest first.

5️⃣ Three Reasons We Might Not Need This PR

  1. ci(issues): report stale issues with no linked PR #1414's report-only design just merged today — but the maintainer reviewed the actual report output and made a deliberate policy call; the report run served its purpose as the evidence base.
  2. Closing valid p1 bugs may lose backlog — mitigated by not_planned semantics, the reopen invitation in every comment, and search visibility of closed issues; the policy is that a linked PR is the priority signal.
  3. The existing label lifecycle could handle this — it requires manual pending-contributor triage per issue, which is exactly the labor this automation removes.

@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The destructive workflow is not safely paced or retry-safe, and it can re-close issues that its own notification invites users to reopen.

Consolidated review: #1417 (comment)

GitHub event: COMMENT — self-review delivery only; this is not an approval.

// 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));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F1 — Pace each mutation and honor throttling responses

The one-second delay follows two back-to-back content-generating writes. At the 50-item cap, that permits roughly 100 writes in 50 seconds (~120/minute), above GitHub’s documented general limit of 80 content-generating requests per minute. This can trigger the partial-failure path.

Requested change: Pace each write (or wait at least 1.5 seconds per two-write candidate with safety margin) and honor Retry-After/secondary-limit backoff before continuing.

let outcome = 'dry run — skipped';
if (!DRY_RUN) {
try {
await github.rest.issues.createComment({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 — Make partial progress and concurrent runs idempotent

If createComment succeeds but issues.update fails or the run is interrupted, the issue remains open and the next run posts the same notification again. A scheduled and manual run can also overlap.

Requested change: Add a stable automation marker, detect an existing notification and retry only the missing close phase, and serialize mutating runs with a repository-scoped workflow concurrency group.

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.',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 — Make the reopen promise true

Reopening does not change the issue’s creation date or linkage, so an old reopened issue still matches the next daily state:open -linked:pr created:<... query and is closed again. That contradicts this user-facing recovery instruction.

Requested change: Persist and exclude a handled marker/label that survives reopening, or tell users that a linked PR is required before reopening will persist.

let closed = 0;
let failed = 0;

for (const issue of candidates) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F4 — Revalidate eligibility immediately before closing

The loop mutates a point-in-time search result. A later candidate can gain a closing-reference PR after the initial search but before its turn, causing the workflow to close an issue whose stated closure reason is no longer true.

Requested change: Immediately before commenting/closing each issue, verify that it remains open and has no closing-reference PR; skip and report candidates whose eligibility changed.

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
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Review round 1 findings addressed in 034e2b7:

🟡 F1 — Pace each mutation and honor throttling responses

✅ Addressed — every content-generating request now goes through pacedWrite(): 1s delay after each write (worst case ≤60 writes/min, under the 80/min cap) and a Retry-After-honoring backoff with one retry on 403/429.

🟡 F2 — Make partial progress and concurrent runs idempotent

✅ Addressed — three mechanisms: (1) a repo-scoped concurrency group (cancel-in-progress: false) serializes scheduled and manual runs; (2) label-before-mutate: the auto-closed-stale marker label is applied before commenting/closing, so an interrupted run leaves a durable breadcrumb; (3) a reconciliation phase at the start of each run finds open+labeled issues and finishes only the missing close phase — checking for the <!-- openab-close-unlinked-stale --> comment marker to avoid duplicate notifications.

🟡 F3 — Make the reopen promise true

✅ Addressed — the auto-closed-stale label survives reopening and is excluded from the candidate query (-label:auto-closed-stale). The reconciliation phase additionally checks for a REOPENED_EVENT in the timeline and permanently exempts user-reopened issues. The notification text now states the exemption explicitly: "reopened issues are exempt from this automation."

🟡 F4 — Revalidate eligibility immediately before closing

✅ Addressed — each candidate is revalidated via GraphQL (state, closedByPullRequestsReferences) immediately before any mutation; issues that closed or gained a closing-reference PR since the initial search are skipped and reported in the job summary.

Verified on 034e2b7: YAML parses cleanly; embedded script passes node --check; the GraphQL revalidation query contract was smoke-tested live against this repo (issue #136: state=OPEN, linkedPRs=0, reopens=0); check and poll-and-review both pass.

@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED �� � The recovery state machine can still close newly linked issues and cannot durably distinguish interrupted work from reopen exemptions.

Consolidated review: #1417 (comment)

GitHub event: COMMENT � self-review delivery only; this is not an approval.

...context.repo, issue_number: issue.number, body: CLOSE_COMMENT,
}));
}
await closeIssue(issue.number);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

� F1 � Enforce current eligibility at the destructive boundary

Phase 1 reads closedByPullRequestsReferences but checks only state and reopen history before closing. The normal path also performs two paced writes after its linked-PR check. Either path can close an issue that gained a linked PR.

Requested change: Immediately before every closeIssue(), re-read state and linked-PR references and skip unless the issue is still open and unlinked.

try {
const live = await revalidate(issue.number);
if (live.state !== 'OPEN') { continue; }
if (live.timelineItems.totalCount > 0) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

� F2 � Compare reopen ordering, not lifetime event count

timelineItems.totalCount > 0 means the issue was ever reopened, not that it was reopened after this automation closed it. A previously reopened issue interrupted after label creation is therefore misclassified as permanently exempt.

Requested change: Compare ordered event/marker timestamps or persist distinct transaction and exemption states; do not infer ordering from a lifetime count.

// 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({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

� F3 � Keep durable exemptions out of the recovery queue

Legitimately reopened issues remain open with the handled label and re-enter this first-page query forever. Fifty exemptions or repeat failures can occupy the page and starve later interrupted transactions.

Requested change: Separate recovery-pending from permanent handled/exempt state, or paginate deterministically under one shared work budget.

q: query,
sort: 'created',
order: 'asc',
per_page: MAX_ITEMS_PER_RUN,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

� F4 � Apply one global per-run bound

MAX_ITEMS_PER_RUN is used independently for the pending and new-candidate searches, so one run can process 50 + 50 issues despite the advertised 50-issue safety bound.

Requested change: Use one remaining-item budget shared by both phases and report actual processed and deferred counts.

state
closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { totalCount }
timelineItems(itemTypes: [REOPENED_EVENT], first: 1) { totalCount }
comments(last: 30) { nodes { body } }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

� F5 � Make marker lookup authoritative

Looking only at the latest 30 comments is not durable idempotency. If more than 30 comments arrive after an interrupted marker comment, recovery cannot see it and posts a duplicate notice.

Requested change: Paginate/search until the marker is found, or persist transaction state independently of a bounded comment window.

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.
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Review round 2 findings addressed in a0c483e:

🔴 F1 — Enforce current eligibility at the destructive boundary

✅ Addressed — all closes now go through a single verifyAndClose() gate that re-reads state and closedByPullRequestsReferences via GraphQL immediately before the close mutation, in both the recovery and normal paths. Recovery additionally removes the pending label and reports when a formerly interrupted issue gained a linked PR.

🟡 F2 — Compare reopen ordering, not lifetime event count

✅ Addressed — exemption now requires a REOPENED_EVENT whose createdAt postdates the transaction anchor (the marker comment timestamp, falling back to the last stale-close-pending labeled event). A reopen that happened before the automation ever touched the issue no longer misclassifies an interrupted transaction.

🟡 F3 — Keep durable exemptions out of the recovery queue

✅ Addressed — states are now split: stale-close-pending marks in-flight transactions and is the only thing the recovery query matches; auto-closed-stale is applied at successful close (or on confirmed post-close reopen) and acts as a passive exemption via -label: query exclusion. Exempt issues never re-enter any queue, so they cannot starve recovery.

🟡 F4 — Apply one global per-run bound

✅ Addressed — a single budget = MAX_ITEMS_PER_RUN counter is decremented across all phases (label hygiene, recovery, new candidates); items beyond it are counted as deferred. The summary now reports matched / processed / closed / recovered / exempt / skipped / failed / deferred separately.

🟡 F5 — Make marker lookup authoritative

✅ Addressed — findMarker() paginates the full comment list (github.paginate, 100/page) instead of a bounded last: 30 window.

Verified on a0c483e: YAML parses cleanly; embedded script passes node --check; the extended GraphQL timeline query (REOPENED_EVENT + LABELED_EVENT with inline fragments) contract-tested live against issue #496 (state=OPEN, linked=0, 5 LabeledEvents); check and poll-and-review both pass.

@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The recovery path can still re-close a permanently exempt reopened issue, while the destructive manual default and recovery accounting need safer behavior.

Consolidated review: #1417 (comment)

GitHub event: COMMENT — self-review delivery only; this is not an approval.

issue(number: $number) {
state
closedByPullRequestsReferences(first: 1, includeClosedPrs: true) { totalCount }
timelineItems(itemTypes: [REOPENED_EVENT, LABELED_EVENT], last: 50) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F1 — Make the reopen exemption independent of a bounded timeline window

Recovery treats the last 50 reopen/label events as complete history. A valid state is OPEN + stale-close-pending + auto-closed-stale after close and handled-label writes succeed, pending-label removal is interrupted, and a user reopens. If 50 later matching events evict that reopen, reopenedAfterAnchor becomes false; because isEligible() also ignores the handled label, recovery can close the permanently exempt issue again.

Requested change: Read live labels and fail closed whenever HANDLED_LABEL is present (only remove stray pending state), and paginate timeline events back to the transaction anchor or persist an exemption signal that cannot be lost to a bounded window.

inputs:
dry_run:
description: 'Report only, do not comment or close'
type: boolean

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 — Require an affirmative choice for manual destruction

The workflow being replaced was report-only even when manually dispatched. With this default, clicking “Run workflow” without changing inputs labels, comments on, and closes up to 50 issues. That is an unsafe default for a destructive maintenance action.

Requested change: Default manual dispatch to dry-run, or use an affirmative execute_closure: false input and mutate only when the operator explicitly enables it. Scheduled runs can remain mutating.

}

const processed = MAX_ITEMS_PER_RUN - budget;
const unqueried = Math.max(result.total - result.items.length, 0);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 — Report every recovery outcome and deferred record

unqueried covers only the new-candidate result. Overflow omitted by the Phase 0/1 per_page limits is absent from Deferred, and successful Phase 0 settlements consume budget without incrementing any named outcome. A simulated 60 stuck + 30 pending + 10 new matches reports 11 deferred although 50 remain.

Requested change: Add a settled counter, compute unqueried/deferred totals for stuck, pending, and result, and make the named outcomes reconcile with MAX_ITEMS_PER_RUN - budget.

…ccounting

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.
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Review round 3 findings addressed in 8f8fa80:

🔴 F1 — Make the reopen exemption independent of a bounded timeline window

✅ Addressed, both halves:

  • Fail-closed handled label: inspect() now returns live labels, and isEligible() requires the absence of auto-closed-stale — enforced inside verifyAndClose() at every destructive boundary. The OPEN + pending + handled partial state (settlement interrupted, then reopened) is explicitly handled in recovery: the issue is reported exempt and only the stray pending label is removed. It can never be re-closed regardless of timeline contents.
  • Unbounded history: reopen-ordering detection now paginates the full REST issue event history (github.paginate(issues.listEvents)) instead of the last-50 GraphQL window, so a qualifying reopen cannot be pushed out by later events. Marker comments and events now both use REST created_at timestamps (consistent ISO comparison).

🟡 F2 — Require an affirmative choice for manual destruction

✅ Addressed — dry_run now defaults to true: a bare "Run workflow" produces a report only, matching the workflow it replaces. Mutation requires explicitly setting dry_run=false. Scheduled runs remain mutating per the stated maintainer policy.

🟡 F3 — Make recovery outcome and backlog totals reconcile

✅ Addressed — added a settled outcome counter (Phase 0); each phase tracks its own processed count and the deferred backlog is computed per phase as total matches − processed (no longer only Phase 2's remainder); dry-run paths increment the same outcome counters so closed + recovered + exempt + settled + skipped + failed always equals MAX_ITEMS_PER_RUN − budget, and the run logs a warning if that invariant is ever violated. With the review's example (60 stuck / 30 pending / 10 new), the summary now reports 50 processed and 50 remaining.

Verified on 8f8fa80: YAML parses cleanly (dry_run default confirmed true); embedded script passes node --check; REST events contract (event, created_at, label.name) verified live against issue #820; check and poll-and-review both pass.

@chaodu-agent

This comment has been minimized.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — Recovery still trusts an unbound public marker, and cleanup failures break the workflow's one-terminal-outcome accounting.

Consolidated review: #1417 (comment)

GitHub event: COMMENT — self-review delivery only; this is not an approval.

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;

@chaodu-agent chaodu-agent Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F1 — Authenticate and transaction-bind the recovery marker

This returns the first public comment containing MARKER without checking its author/application or whether it belongs to the current pending-label transaction. A stale or user-supplied marker can suppress the official notice and can move the reopen anchor before an unrelated historical reopen.

Requested change: Select only a marker created by the expected GitHub Actions identity/application at or after the latest stale-close-pending label event, with tests for stale/spoofed, trusted-current, and missing markers.

// 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;

@chaodu-agent chaodu-agent Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 — Keep terminal outcomes mutually exclusive

exempt is incremented before pending-label cleanup. If that write throws, the outer catch also increments failed, so one processed item contributes two outcomes; the reopened and linked-PR branches have the same pattern. In addition, a successful close followed by settlement failure is reported only as failed.

Requested change: Assign one terminal primary outcome after required operations settle, track cleanup debt separately, and preserve successful-close state when label settlement must be recovered.

… accounting

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.
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Review round 4 findings addressed in 2d6ef8e:

🟡 F1 — Authenticate and transaction-bind the recovery marker

✅ Addressed — findTrustedMarker() now enforces both properties:

  • Provenance: only comments authored by github-actions[bot] (the identity GITHUB_TOKEN posts as) qualify; a user-pasted copy of the public marker string is ignored.
  • Transaction binding: when the current transaction's stale-close-pending label event exists (it always does for the phase-2 transaction order: label → comment → close), only markers created at or after that event qualify. An older bot marker from a hypothetical prior transaction can neither suppress the fresh notice (if (!marker) re-posts) nor shift the exemption anchor.
  • Anchor priority flipped: the pending-label event is now the primary anchor for reopen ordering; the trusted marker is only a fallback when no label event is found.

🟡 F2 — Assign exactly one terminal outcome after mutation/cleanup settles

✅ Addressed — restructured along the "structured results + separate cleanup debt" option:

  • Every processed item lands in exactly one terminal counter. Cleanup writes that follow a terminal decision (stray pending-label removal, exemption label settlement) go through tryCleanup(), which never throws — failures increment a separately reported cleanupDebt and are recovered by phases 0/1 on the next run. The double-count path (exempt + failed from one item) is structurally impossible now.
  • verifyAndClose() returns 'closed' | 'closed-unsettled' | 'ineligible': a successful close whose label settlement fails is still counted as closed and reported as "closed — label settlement deferred", preserving the destructive fact.
  • The accounting invariant (terminal outcomes === processed) is now enforced with core.setFailed instead of a warning.
  • Phase 0 is unchanged in spirit: label settlement is its primary operation, so settled is recorded only on success and a failure is failed — consistent with the one-terminal-outcome rule.

Verified on 2d6ef8e: YAML parses cleanly; embedded script passes node --check; check and poll-and-review both pass.

@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Important

CHANGES REQUESTED ⚠️ — The close-boundary safeguard still leaves a false closure notice behind, the new policy can bypass the repository's existing closing-soon grace period, and the PR description no longer matches the safe manual default.

What This PR Does

This PR replaces the report-only daily scan for issues older than 14 days without a closing-reference pull request with a workflow that labels, notifies, and closes matching issues as not_planned. The current revision adds a two-label recovery state machine, trusted transaction-bound markers, live eligibility checks, per-write pacing, safe manual dry-runs, and single-terminal-outcome accounting.

How It Works

The workflow settles closed interrupted transactions, recovers open issues carrying stale-close-pending, then processes new oldest-first candidates under one shared 50-item budget. New work follows pending label → marker comment → live eligibility check → close → handled-label settlement; recovery uses full comment/event pagination and label state to avoid duplicate notices and preserve reopen exemptions. Scheduled and manual runs of this workflow are serialized.

Findings

# Severity Finding Location
F1 🟡 Important If eligibility changes after the notification but before the final close gate, the issue is correctly not closed, but the workflow leaves both the false “it is being closed” comment and stale-close-pending label behind until a later run. .github/workflows/close-unlinked-stale-issues.yml:307-316
F2 🟡 Important The candidate policy does not exclude closing-soon, so it can close an issue immediately after the existing lifecycle promises a grace period, bypassing that workflow's age gate and creating competing close authorities. .github/workflows/close-unlinked-stale-issues.yml:290
F3 🟡 Important The PR description still says dry_run defaults to false, while the reviewed workflow now correctly defaults it to true; the public operational summary is stale and describes the opposite manual-dispatch behavior. PR description (Summary)
F4 🟢 Praise The round-4 fixes correctly bind trusted markers to the current pending-label transaction and separate terminal outcomes from recoverable cleanup debt. .github/workflows/close-unlinked-stale-issues.yml:106-164,241-280,332-360
Finding Details

🟡 F1: Roll back side effects when the final close gate rejects the issue

Phase 2 first verifies eligibility, applies stale-close-pending, and posts a comment that says the issue “is being closed.” verifyAndClose() then deliberately re-reads live state, closing-reference PRs, and handled labels. If that final check returns ineligible, lines 314-316 only record a skip. The issue remains open with a misleading permanent notification and a transaction label; Phase 1 may remove the label on the next run, but it never retracts or corrects the notice.

Requested change: Capture the created comment ID and, when the final gate returns ineligible, immediately best-effort remove the pending label and delete or edit the transaction's notice to state that closure was cancelled. Track any rollback failure as cleanup debt without changing the one-terminal-outcome invariant.

🟡 F2: Preserve the existing closing-soon grace contract

Main already runs stale-issue.yml and close-stale-issues.yml: the first applies closing-soon and publicly promises time to respond, while the second closes only after its own label-age gate. The new Phase 2 query ignores that label and uses issue creation age instead. An issue older than 14 days can therefore receive the grace notice and then be closed by this workflow at 09:30 UTC before the existing closer considers it eligible; the separate concurrency group also does not coordinate the two writers.

Requested change: Exclude closing-soon from Phase 2 and make its live presence fail closed at the destructive gate, or explicitly replace and remove the old lifecycle in this PR. The two workflows must not make contradictory promises or independently close the same issue.

🟡 F3: Update the public operational contract

The code at lines 6-11 now defaults manual dispatches to report-only mode, and line 46 mutates only for scheduled runs or an explicit dry_run=false. The PR description still states “default false,” which was true on an earlier revision but is now the opposite of the reviewed head.

Requested change: Update the PR description's Summary/Safety text to say that manual dispatch defaults to dry-run and requires an affirmative dry_run=false selection to mutate.

🟢 F4: Round-4 recovery fixes are sound

findTrustedMarker() now accepts only github-actions[bot] comments at or after the latest pending-label event. The pending-label event is the primary reopen anchor, and terminal counters are assigned independently from cleanupDebt; a successful close remains visible as closed even when label settlement must be recovered later. These directly address round 4's two findings.

Addressing External Reviewer Feedback

@pahud (earlier SHA)

“The existing label-driven lifecycle ... is untouched and complementary: it handles pending-contributor response timeouts; this handles PR-less aging.”

⚠️ Partially accepted: The policy direction, static message, reversible not_planned close, scoped permission, and now-per-write pacing remain sound. The two lifecycles are not yet complementary in execution, however: the new query can select an issue carrying closing-soon and close it without honoring the existing workflow's grace period (F2).

Latest author response (2d6ef8e)

findTrustedMarker() now enforces both [provenance and transaction binding] ... Every processed item lands in exactly one terminal counter.”

Addressed: The reviewed source enforces marker author and current-transaction ordering, uses the pending-label event as the primary anchor, keeps cleanup debt separate, preserves successful-close outcomes, and fails the run on an accounting mismatch. F1-F3 above are distinct current-head integration/documentation gaps.

Previous Review Resolution

  • Round 1: ✅ Per-write pacing/backoff, workflow serialization, idempotent recovery, durable reopen handling, and close-boundary revalidation are present.
  • Round 2: ✅ Recovery now applies the live closing-reference predicate, uses separate pending/handled labels, shares one global budget, and paginates marker lookup.
  • Round 3: ✅ Handled labels fail closed, issue events paginate fully, manual dispatch defaults to dry-run, and per-phase accounting reconciles.
  • Round 4: ✅ Marker provenance/transaction binding and mutually exclusive terminal accounting are implemented as described in the latest response.

Independent Review Coverage

Reviewer Angle Outcome
Reviewer A Correctness/state machine Retained the false-notice/pending-label rollback gap (F1); confirmed round-4 outcome accounting.
Reviewer B GitHub API/security Confirmed marker provenance/binding; rejected the claimed -linked:pr semantic mismatch because GitHub documents it as closing-reference linkage.
Reviewer C Operability/integration Identified the uncoordinated closing-soon lifecycle (F2) and stale PR contract (F3).
Reviewer D Independent audit Rejected speculative timeout and incomplete-search concerns; retained only reproducible current-head issues.
Baseline Check
  • PR opened: 2026-07-16.
  • Main already has: a 72-line report-only daily scan using the same 14-day/no-closing-reference policy, plus the label-driven stale-issue.ymlclose-stale-issues.yml warning/closure lifecycle.
  • Net-new value: automatic enforcement through notification and not_planned closure, with paced writes, crash recovery, durable state labels, live eligibility checks, safe manual preview, and operator reporting.
Verified
  • Reviewed exact head: 2d6ef8e5d8fc321a5ec13556e842a0d4921c25ae against origin/main at 30bc145233a202754d205b21e8529491d97b1a7c.
  • git diff --check: pass.
  • Exact-SHA checks: check ✅ and poll-and-review ✅.
  • Official GitHub search documentation confirms linked:pr means linkage by a closing reference, matching closedByPullRequestsReferences; no semantic-gap finding was retained.
  • Local limitation: this runner has no Node, Python, Ruby, actionlint, or yq, so syntax parsing was not independently rerun; successful exact-SHA CI provides the available syntax validation.
What's Good (🟢)
  • Mutation permission is scoped to issues: write, and runs of this workflow are serialized.
  • Manual dispatch now defaults to report-only behavior.
  • The destructive boundary re-reads issue state, closing-reference PRs, and handled labels.
  • Comments and issue events paginate fully; ordinary writes are paced and honor Retry-After once.
  • Trusted marker binding, the two-label transaction, cleanup-debt reporting, and the enforced accounting invariant are substantial improvements over the initial implementation.

5️⃣ Three Reasons We Might Not Need This PR

  1. The report-only workflow was just introduced — maintainers could use its output for manual triage before automating destructive state changes.
  2. Creation age is not inactivity or lack of value — an old issue can remain active, strategic, or blocked without a closing-reference pull request.
  3. The repository already has a label-driven stale lifecycle — extending one explicit lifecycle may be easier to reason about than maintaining two independent closure authorities.

@chaodu-agent chaodu-agent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ — The close-boundary safeguard still leaves a false closure notice behind, the new policy can bypass the repository's existing closing-soon grace period, and the PR description no longer matches the safe manual default.

Consolidated review: #1417 (comment)

GitHub event: COMMENT — self-review delivery only; this is not an approval.

...context.repo, issue_number: issue.number, body: CLOSE_COMMENT,
}));
const outcome = await verifyAndClose(issue.number);
if (outcome === 'ineligible') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F1 — Roll back side effects when the final close gate rejects the issue

After the workflow has applied stale-close-pending and posted “it is being closed,” this branch records an eligibility-change skip but leaves both side effects behind. Phase 1 may remove the label on a later run, but the false closure notice remains permanently.

Requested change: Capture the transaction comment ID and, on ineligible, immediately best-effort remove the pending label and delete or correct that notice; track rollback failures as cleanup debt.

}

// ── Phase 2: process new candidates ──
const query = `${repoQ} is:issue state:open -linked:pr -label:${HANDLED_LABEL} -label:${PENDING_LABEL} created:<${cutoff}`;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 — Preserve the existing closing-soon grace contract

This query can select an issue already carrying closing-soon. The existing lifecycle publicly promises a response window and closes only after its own label-age gate, but this workflow can close the issue solely from creation age before that grace expires. Its separate concurrency group also does not coordinate the two writers.

Requested change: Exclude closing-soon here and make its live presence fail closed at the destructive gate, or explicitly replace the old lifecycle in this PR so the repository has one non-contradictory close authority.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants