From a6bd2f4cabc9055c652836078c8ee10615e6b00b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:14:24 +0000 Subject: [PATCH 1/2] feat(pm): a scheduled backup of the board, so an account suspension destroys no record (#17390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fleet accounts were suspended in two months and the appeals are unanswered after ten days. #17374 F3 measured what a suspension destroys: every issue, pull request and comment the account authored — while every branch and commit survives, because those belong to the repository rather than to a user. The board IS the fleet's state by rule, so one suspension erases state nothing else holds; six cards are already gone, one of them a p1 security decision. `scripts/pm/board-snapshot.mjs` reads the board over repo-scoped REST with whatever token the caller supplies and writes one JSON file per number, its comment thread as JSONL, a pull request's reviews beside it, and a manifest carrying the run stamp, the `since` used, counts by state, the board's own `open_issues_count` read in the same run, the request count and a resume cursor. Runs are incremental from the previous manifest and idempotent: a re-run over an unchanged board writes nothing at all. Two properties are load-bearing and neither is visible on a clean tree. The page walk never trusts `Link: rel="next"` and ends only on a short page, and it re-anchors `since` on the last row's VALUE rather than incrementing an offset — an item updated mid-walk shifts every row behind it one slot, and a blind `page += 1` skips the row at the boundary permanently, its own `updated_at` never having moved. The other is the rate discipline: a refusal stops the run, writes the cursor and exits non-zero with the reset time, and nothing here retries, because a loop against a spent budget starves every other automated caller in the repository for the rest of the hour. `--restore ` prints, and only prints, the recreate payload for a destroyed card: the provenance header a rebuilt record must carry, the archived body, the labels to re-apply and the comment thread as a second block. Posting stays a seat's act, and the script has no write path to GitHub in any mode — two self-test cases read its own source and hold that structurally, which is the mechanical half of the one-board rule the archive lives under. `.github/workflows/board-snapshot.yml` is the standing caller, at the half-state patrol's cadence, running as `github-actions[bot]` into the orphan branch `board-archive`. It never writes to `main` and holds no scope that would let it write to any issue. Self-test: 70 cases across 8 batteries, offline, no network and no token, wired into the required lint job as `check:pm-board-snapshot`. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude --- .github/workflows/board-snapshot.yml | 217 +++++ .github/workflows/lint.yml | 21 + package.json | 1 + scripts/pm/board-snapshot.mjs | 1343 ++++++++++++++++++++++++++ 4 files changed, 1582 insertions(+) create mode 100644 .github/workflows/board-snapshot.yml create mode 100644 scripts/pm/board-snapshot.mjs diff --git a/.github/workflows/board-snapshot.yml b/.github/workflows/board-snapshot.yml new file mode 100644 index 0000000000..8e384196eb --- /dev/null +++ b/.github/workflows/board-snapshot.yml @@ -0,0 +1,217 @@ +name: Board Snapshot + +# The standing caller for `scripts/pm/board-snapshot.mjs` (#17390). +# +# ## Why a scheduled backup, and why it lands on a branch +# +# Three fleet accounts were suspended in two months and the appeals are +# unanswered. #17374 F3 measured what a suspension destroys: every issue, pull +# request and comment the account authored — while every branch and commit +# survives, because those belong to the repository rather than to a user. The +# board IS the fleet's state by rule (pm-dispatch SKILL.md, 全体座位的不变量: +# 「GitHub 之外永不维护任何跟踪状态」), so today one suspension erases state that +# nothing else holds; six cards, one of them a p1 security decision, are gone. +# +# The maintainer's answer, verbatim and untranslated, in chat on 2026-09-10: +# 「或者用 ci 写个定时任务备份?」 — this file is that scheduled task. It runs as +# `github-actions[bot]`, which is not a fleet account and cannot be suspended +# with one, and it writes to an ORPHAN BRANCH of this same repository: no second +# repo, no second credential, and a branch is precisely the thing a suspension +# leaves standing. +# +# ## An archive is not a tracker — the one-board rule still holds +# +# ⛔ Nothing reads `board-archive` for state. Not a seat, not a patrol, not a +# gate, not a query; every reading of the board still goes to GitHub. The +# archive answers exactly one question, after a loss: what did the record say? +# The script it runs has NO write path to GitHub in any mode — two of its +# self-test cases read its own source and hold that structurally — and +# `--restore` PRINTS a recreate payload for a seat to post, or for nobody to. +# +# ## Budget, and why a run may stop before it is finished +# +# `GITHUB_TOKEN` is limited to 1,000 requests per hour PER REPOSITORY, and this +# job is not that budget's only caller (the half-state patrol and the closed-card +# sweep share it). A first full snapshot of this board — several thousand +# numbers, each with a comment thread — does not fit one run and must not try, so +# the script stops at its own `--max-requests` ceiling, writes a resume cursor +# into the manifest and exits 0. The next scheduled run continues from that +# cursor. Four runs a day walk the backlog in a few days without ever exceeding +# the budget, and a steady-state incremental run costs a few hundred requests. +# +# ⛔ On a real rate-limit refusal the script does NOT retry: it stops, writes the +# cursor and exits non-zero with the reset time. A loop against a spent budget +# starves every other automated caller in the repository for the rest of the hour. +# +# ## No `pull_request` trigger, deliberately +# +# The sibling patrol proves itself on a real runner at PR time. This one does +# not, because its live run reads the whole update window and would spend the +# repository's shared request budget on every push to every branch. The half CI +# can hold for free is the offline one, and it is held: `check:pm-board-snapshot` +# runs the script's 70-case `--self-test` inside the required `Lint & Repo Gates` +# job, which has no paths filter (`check:self-test-wired` is what requires that +# step to exist). This job runs the same self-test again before it touches the +# archive, so a broken tool fails loudly instead of committing a broken backup. +# +# ## Adopting this in a sibling repo +# +# Copy FOUR files, unchanged: `scripts/pm/board-snapshot.mjs`, +# `scripts/pm/check-half-states.mjs` (the archiver imports its proxy-re-exec plan +# and its repo resolver), `scripts/invoked-as.mjs` (imported by both) and this +# file. A repo that has already adopted the half-state patrol has the middle two. +# ⛔ Do not shorten this list from memory — the imports decide it, not this +# comment, and the patrol's own adopt list was wrong for months in exactly that +# way, which installed a dead sweeper that failed before its first predicate. +# +# There is nothing to configure. The board archived is `github.repository`, +# passed explicitly below, and the script REFUSES its resolver's default rather +# than falling back to one: a copy of this file quietly archiving the repo it was +# copied FROM would produce a complete, well-formed, green archive of the wrong +# board. + +on: + schedule: + # The half-state patrol's cadence, deliberately: four times a day, six hours + # apart, at :37 past the hour — offset from the top of the hour where the + # hourly triage Routine runs, so the two do not contend for the same minute + # of the shared request budget. Six-hourly is the loss window this accepts: + # a card created and destroyed inside one interval was never archived, and + # nothing cheaper than a webhook closes that, which is a different card. + - cron: '37 1,7,13,19 * * *' + workflow_dispatch: {} + +# Least privilege. `contents: write` is for the archive BRANCH and nothing else; +# the two read scopes are what the snapshot reads. ⛔ This job never writes to +# `main` and never writes to any issue, pull request, label or comment — there is +# no scope here that would let it, which is the mechanical half of the one-board +# rule stated above. +permissions: + contents: write + issues: read + pull-requests: read + +# One archiver at a time. Two runs racing the same branch would have the loser +# push a snapshot built from a stale checkout, silently discarding the winner's. +concurrency: + group: board-snapshot + cancel-in-progress: false + +jobs: + archive: + name: Snapshot the board to the archive branch + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: '22' + + # No `pnpm install`: the archiver imports only `node:` builtins, global + # `fetch`, and two repo-local modules — no npm dependency, so installing + # the workspace here would buy nothing and would give a scheduled backup a + # lockfile it could fail on. + - name: The tool's own cases, before it touches the archive + run: node scripts/pm/board-snapshot.mjs --self-test + + # The archive branch is an ORPHAN: it shares no history with `main`, so a + # checkout of it carries the archive and nothing else. First run creates it + # locally; the push below is what publishes it. + - name: Check out or create the archive branch + run: | + set -euo pipefail + git fetch --depth=1 origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true + if git rev-parse --verify --quiet refs/remotes/origin/board-archive > /dev/null; then + git worktree add archive -b board-archive refs/remotes/origin/board-archive + echo "archive branch exists; checked out at $(git -C archive rev-parse --short HEAD)" + else + git worktree add --detach archive HEAD + git -C archive checkout --orphan board-archive + git -C archive rm -rf --quiet . + # An orphan that still carries main's tree would commit the whole + # repository into the archive branch on its first run, so this is + # asserted rather than assumed. + tracked=$(git -C archive ls-files | wc -l) + if [ "$tracked" != "0" ]; then + echo "::error::the new orphan branch still tracks $tracked file(s) from main; refusing to seed the archive with them." + exit 1 + fi + echo "archive branch created as an orphan (first run)" + fi + + - name: Run the snapshot + id: snapshot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # WHICH board this run archives: the repo this workflow is installed + # in, always. A copy of this file cannot end up archiving the repo it + # was copied FROM, and the script refuses to guess when this is unset. + PM_SWEEP_REPO: ${{ github.repository }} + run: | + set +e + node scripts/pm/board-snapshot.mjs --out=archive/board \ + > "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err" + code=$? + set -e + # Captured with NO pipe in between. `cmd | tail` reports the PIPE's + # status — `tail` essentially never fails, so a clean snapshot and a + # failed count check would both read as 0, and this script's exit + # register (0 / 2 / 3 / 4) is the whole point of having one. + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + echo "board-snapshot exited $code" + cat "$RUNNER_TEMP/snapshot.md" || true + cat "$RUNNER_TEMP/snapshot.err" >&2 || true + + # Land the truth, THEN raise the alarm: whatever the snapshot managed to + # read is committed before the run is allowed to go red, so a failing count + # check never costs the records the same run archived. + - name: Commit and push the archive + id: commit + run: | + set -euo pipefail + if [ -z "$(git -C archive status --porcelain)" ]; then + echo "committed=none" >> "$GITHUB_OUTPUT" + echo "no change on the board since the last run — nothing committed." + exit 0 + fi + git -C archive add -A + files=$(git -C archive diff --cached --name-only | wc -l) + git -C archive \ + -c user.name='github-actions[bot]' \ + -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ + commit -q -m "board snapshot $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -m "run ${{ github.run_id }} · ${files} file(s) changed" + git -C archive push origin board-archive + echo "committed=${files}" >> "$GITHUB_OUTPUT" + echo "pushed ${files} changed file(s) to board-archive" + + - name: Publish the run to the summary + if: always() + run: | + { + echo "### Board snapshot — exit ${{ steps.snapshot.outputs.exit_code }}" + echo + echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}" + echo + echo '```' + cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)' + echo '```' + echo + echo '
stderr' + echo + echo '```' + cat "$RUNNER_TEMP/snapshot.err" 2>/dev/null || true + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Fail the run if the snapshot did not read the board + if: steps.snapshot.outputs.exit_code != '0' + run: | + echo "::error::board-snapshot exited ${{ steps.snapshot.outputs.exit_code }} — 2 is a failed count check (the archive and the board disagree about how many open issues exist), 3 is a prerequisite the runner did not meet, 4 is a rate-limit stop with a resume cursor written. Whatever was read HAS been committed; see this run's summary." + exit 1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3eb4cc6796..f6bfd098bd 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1123,6 +1123,27 @@ jobs: - name: Stamped-post helper self-test run: pnpm check:pm-post-stamped + # Board-snapshot archiver self-test (#17390) — same family and the same + # split as every step around it. The LIVE run reads the whole board over + # REST and commits the result to an orphan branch, so it is not a thing + # CI runs on a pull request; the offline half is what CI can hold, and + # `.github/workflows/board-snapshot.yml` is the only caller of the other. + # + # What the self-test instruments is the set of rules a clean tree cannot + # exercise: the page walk's value re-anchor (blind `page += 1` skips a row + # that shifted forward mid-walk, permanently and with no symptom), the + # resume cursor an interrupted run leaves, the idempotence that keeps a + # no-op run from committing, the count check's three verdicts — including + # `pending`, which exists so "could not check" never renders as "checked + # and clean" — and the provenance sentence a rebuilt record must carry. + # Two of its cases are STRUCTURAL and read this file's own source: that + # the archiver has no write path to GitHub in any mode (the one-board + # rule, mechanically) and that it carries no retry loop against a spent + # rate limit. Weakening any of them leaves every ordinary run just as + # green, which is the shape `check:self-test-wired` requires this step for. + - name: Board-snapshot archiver self-test + run: pnpm check:pm-board-snapshot + # Changeset-deadline census self-test (#16850) — same family and the same # split as every step around it. The LIVE census reads the shared board # over the API and is REPORT-ONLY by ruling: #16850 takes option 1 (detect diff --git a/package.json b/package.json index 098455aed3..3e5bfb0a97 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "check:pm-widening-tells": "node scripts/pm/check-widening-tells.mjs --self-test", "check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test", "check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test", + "check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test", "check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", diff --git a/scripts/pm/board-snapshot.mjs b/scripts/pm/board-snapshot.mjs new file mode 100644 index 0000000000..34a531e384 --- /dev/null +++ b/scripts/pm/board-snapshot.mjs @@ -0,0 +1,1343 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * board-snapshot — a scheduled, read-only backup of the board (#17390). + * + * node scripts/pm/board-snapshot.mjs --out=archive/board # snapshot (incremental) + * node scripts/pm/board-snapshot.mjs --out=archive/board --full + * node scripts/pm/board-snapshot.mjs --out=archive/board --restore=17297 + * node scripts/pm/board-snapshot.mjs --self-test # offline, no network, no token + * + * ## Why this exists, measured rather than supposed + * + * Three fleet accounts were suspended in two months. #17374 F3 measured what a + * suspension destroys: every issue, pull request and comment the account + * authored — while every branch and commit survives, because those belong to + * the repository and not to a user. The board IS the fleet's state by rule + * (pm-dispatch SKILL.md, 全体座位的不变量: 「GitHub 之外永不维护任何跟踪状态」), + * so one suspension erases state that nothing else holds. Six cards — #17297 + * (a p1 security decision), #17128, #17150, #17276, #17313, #17318 — are gone. + * + * ## The one-board rule this obeys, and how it obeys it + * + * An ARCHIVE IS NOT A TRACKER. Nothing reads this snapshot for state: no seat, + * no patrol, no gate, no query. The board stays the single source of truth and + * every reading of it still goes to GitHub. What this tool produces is a + * write-once backup that answers exactly one question, after a loss: what did + * the record say? That is why: + * + * - it has NO write path to GitHub at all — no POST, no PATCH, no DELETE, in + * any mode. `--restore` PRINTS a payload; a seat posts it, or nobody does; + * - the archive lives on an orphan branch (`board-archive`) of this same + * repository — no second repo, no second credential, and it is a branch, so + * it survives the suspension that destroys the records; + * - the manifest records what the run READ, never what any seat should do. + * + * ## Layout — one file per number, so a diff reads like a board + * + * /issues/.json the issue or pull request record (stable key order) + * /comments/.jsonl its comment thread, one JSON object per line, id-ordered + * /reviews/.jsonl pull requests only: reviews and review comments, + * each line discriminated by its `record` field + * /manifest.json the run stamp, the `since` used, counts by state, + * the board's own `open_issues_count` read in the + * same run, the request count, and the resume cursor + * /gone.json hand-written: numbers known to be destroyed or + * transferred, so the count check below can stay a + * real alarm instead of a permanent red + * + * Every record is built key by key in a fixed order and every list is sorted, so + * two runs over an unchanged board produce byte-identical files and the branch's + * history is a diff of what actually changed on the board. + * + * ## Incremental, and the two things that makes exact + * + * A run reads `since` from the previous manifest (`next_since`, or `resume.since` + * when the previous run stopped early) and asks the listing endpoint for + * everything updated at or after it. The first run has no manifest and is full. + * + * **The page walk never trusts `Link: rel="next"`.** The REST channel table + * records the measurement: cursor-following stopped at 102 rows where the page + * walk found 287 and 448, and the enumerations that were wrong looked exactly + * like the ones that were right. So this walks `&page=N` until a SHORT page + * (fewer rows than `per_page`) ends it, and nothing else ends it. + * + * **The walk re-anchors on a VALUE, never on an offset.** Ascending by + * `updated_at`, an item that is updated mid-walk moves to the end of the + * ordering and shifts every row behind it one slot forward — so the row that + * was about to be the first of page k+1 lands at the last slot of page k, which + * this run has already read past. Blind `page += 1` skips it PERMANENTLY: its + * own `updated_at` never moved, so no later `since` window contains it either. + * `nextWalkStep()` therefore sets `since` to the last row's `updated_at` and + * returns to page 1 whenever that value advances, and increments the page only + * when a whole page shares one timestamp (which is the only case an offset is + * needed for). `since` is inclusive, so the boundary rows are re-read and + * re-written identically — an over-read, which is the safe direction. + * + * ## Rate discipline — stop with a cursor, never retry in a loop + * + * The workflow's `GITHUB_TOKEN` is budgeted at 1,000 requests/hour PER + * REPOSITORY. A first full snapshot of this board (~600 open issues and several + * thousand closed ones, each with a comment thread) does not fit one run and + * must not try: `--max-requests` stops the run cleanly, writes the manifest with + * a `resume` cursor and exits 0, and the next scheduled run continues from that + * cursor. Four runs a day walk the backlog in a few days without ever exceeding + * the budget. + * + * A real rate-limit signal from GitHub (403/429 with the remaining count at + * zero) is different and is NOT a planned pause: the run stops, writes the same + * resume cursor, prints the reset time and exits `EXIT_RATE_LIMITED`. ⛔ It + * never sleeps and never retries the request — a loop against a zero budget is + * how one repository's automation starves every other caller of that budget. + * + * ## The count check, and why a mismatch is red rather than a warning + * + * `rest-channel.md`: an enumeration without a count check is not a reading. So + * every run censuses the archive on disk and compares the open-issue count with + * the board's own `open_issues_count` minus the open pull requests, both read in + * the same run. + * + * SHORTFALL (the archive holds fewer) the enumeration missed cards. This is + * the failure the rule exists for. + * SURPLUS (the archive holds more) the board no longer carries records + * this archive does — the destruction + * signature this tool was built for. + * `--restore` prints them back; a number + * deliberately left destroyed (or moved + * to another repo) goes in `gone.json`, + * which is the ONLY way to quiet it. + * + * Both are non-zero exits. A partial archive (a first snapshot still resuming) + * makes the check `pending` instead — the census is not yet a reading, and + * reporting `pending` is what keeps "could not check" apart from "checked and + * clean" (#4690). + * + * ## Heartbeat + * + * ⚠️ Unlike the half-state patrol, this run does NOT refresh a timestamp when + * nothing changed — idempotence is the card's requirement and a per-run no-op + * commit would bury the real diffs. The liveness signal is therefore the Actions + * run history and each run's job summary, NOT the archive. A reader asking "is + * the backup alive?" reads the workflow, never the branch. + * + * ## Adopting this in a sibling repo + * + * Copy FOUR files, unchanged — this one, `.github/workflows/board-snapshot.yml`, + * `scripts/pm/check-half-states.mjs` (the proxy re-exec plan and the repo + * resolver are imported from it, so a three-file copy installs an archiver that + * cannot start) and `scripts/invoked-as.mjs` (imported by both). A repo that has + * already adopted the half-state patrol has the last two. ⛔ Do not shorten this + * list from memory: the imports below decide it, not this comment. + * + * There is no configuration. The board archived is `github.repository`, passed + * explicitly by the workflow — and this script REFUSES the default repository + * its resolver would otherwise fall back to, because a copy that quietly + * archived the repo it was copied FROM would produce a complete, well-formed, + * green archive of the wrong board. + */ + +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; +import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, labelNames, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs'; + +const SELF_PATH = fileURLToPath(import.meta.url); +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 1; +export const EXIT_COUNT_MISMATCH = 2; +// 3 is EXIT_PREREQUISITE_NOT_MET, imported so the register is one register. +export const EXIT_RATE_LIMITED = 4; + +/** + * The re-exec guard, per script rather than shared with its neighbours: two + * scripts sharing one guard name means the first one's re-exec silently + * disarms the second's when they run in the same process tree. + */ +const PROXY_REARM_GUARD = 'OS_BOARD_SNAPSHOT_PROXY_REARMED'; + +/** The archive format. Bump it when a record's shape changes; the manifest carries it. */ +export const ARCHIVE_SCHEMA = 1; + +/** Rows per listing page. GitHub's ceiling, and the short-page test's threshold. */ +export const PER_PAGE = 100; + +/** + * The per-run request budget. The workflow token's limit is 1,000/hour per + * repository and this run is not the only caller of it (the half-state patrol + * and the closed-card sweep share the same budget), so the default leaves room. + * + * ⛔ Raising this is not a tuning decision. A run that exceeds the budget does + * not fail alone: it takes every other automated caller in the repository down + * with it for the rest of the hour. + */ +export const DEFAULT_MAX_REQUESTS = 800; + +/** Where a run writes when `--out` is not given. */ +export const DEFAULT_OUT_DIR = 'board'; + +export const MANIFEST_NAME = 'manifest.json'; +export const GONE_LEDGER_NAME = 'gone.json'; + +// --------------------------------------------------------------------------- +// Pure core — every function below is offline and is what `--self-test` pins. +// --------------------------------------------------------------------------- + +/** One JSON document, pretty-printed and newline-terminated. Key order is the caller's. */ +export function stableJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +/** JSONL: one record per line, in the order given, newline-terminated. */ +export function stableJsonl(records) { + if (!records?.length) return ''; + return `${records.map((r) => JSON.stringify(r)).join('\n')}\n`; +} + +/** + * An actor, reduced to what survives the account being destroyed: the login is + * the only handle a rebuilt record can name, and it is exactly what GitHub stops + * answering for once the account is gone. + */ +export function actorRecord(user) { + if (!user) return null; + return { login: user.login ?? null, id: user.id ?? null, type: user.type ?? null }; +} + +/** + * The archived shape of one issue or pull request. + * + * ⚠️ `closed_by` is DECLARED ABSENT rather than silently missing: the listing + * endpoint does not carry it (only `GET /issues/{n}` does), and buying it would + * cost one extra request per archived number — which is the whole budget, spent + * on a field no restore needs. The key is present and `null` so a reader can + * tell "not carried by this archive" from "nobody closed it". + */ +export function issueRecord(raw) { + const isPull = Boolean(raw.pull_request); + return { + number: raw.number, + kind: isPull ? 'pull_request' : 'issue', + title: raw.title ?? null, + state: raw.state ?? null, + state_reason: raw.state_reason ?? null, + draft: isPull ? Boolean(raw.draft) : null, + author: actorRecord(raw.user), + labels: labelNames(raw).filter(Boolean).slice().sort(), + assignees: (raw.assignees ?? []).map((a) => a?.login).filter(Boolean).slice().sort(), + milestone: raw.milestone ? { number: raw.milestone.number ?? null, title: raw.milestone.title ?? null, state: raw.milestone.state ?? null } : null, + type: raw.type?.name ?? null, + created_at: raw.created_at ?? null, + updated_at: raw.updated_at ?? null, + closed_at: raw.closed_at ?? null, + closed_by: actorRecord(raw.closed_by), + merged_at: isPull ? (raw.pull_request?.merged_at ?? null) : null, + comments: raw.comments ?? 0, + locked: Boolean(raw.locked), + html_url: raw.html_url ?? null, + body: raw.body ?? null, + }; +} + +/** The archived shape of one issue comment. */ +export function commentRecord(raw) { + return { + id: raw.id, + author: actorRecord(raw.user), + created_at: raw.created_at ?? null, + updated_at: raw.updated_at ?? null, + html_url: raw.html_url ?? null, + body: raw.body ?? null, + }; +} + +/** + * The archived shape of one review or review comment. The `record` field is the + * discriminator: both families share one file per number, and a reader must + * never have to infer which one a line is from the keys it happens to carry. + */ +export function reviewRecord(raw, kind) { + return { + record: kind, + id: raw.id, + author: actorRecord(raw.user), + state: raw.state ?? null, + path: raw.path ?? null, + created_at: raw.created_at ?? raw.submitted_at ?? null, + updated_at: raw.updated_at ?? null, + html_url: raw.html_url ?? null, + body: raw.body ?? null, + }; +} + +/** Records sorted by id, so a re-fetch in a different order is not a diff. */ +export function byId(records) { + return records.slice().sort((a, b) => (a.id ?? 0) - (b.id ?? 0)); +} + +/** The archive paths one number owns. */ +export function issuePath(outDir, number) { + return join(outDir, 'issues', `${number}.json`); +} +export function commentsPath(outDir, number) { + return join(outDir, 'comments', `${number}.jsonl`); +} +export function reviewsPath(outDir, number) { + return join(outDir, 'reviews', `${number}.jsonl`); +} + +/** + * One page of the board, oldest update first. + * + * `state=all` is the point — a closed card is exactly what a suspension + * destroys, and every census, patrol and mutex query on the live board forces + * `state:open`, so the closed half has no other reader at all. + */ +export function listingPath(repo, { since = null, page = 1, perPage = PER_PAGE } = {}) { + const query = [ + 'state=all', + 'sort=updated', + 'direction=asc', + ...(since ? [`since=${encodeURIComponent(since)}`] : []), + `per_page=${perPage}`, + `page=${page}`, + ].join('&'); + return `/repos/${repo}/issues?${query}`; +} + +/** One page of the open pull requests — the right-hand side of the count check. */ +export function openPullsPath(repo, { page = 1, perPage = PER_PAGE } = {}) { + return `/repos/${repo}/pulls?state=open&per_page=${perPage}&page=${page}`; +} + +/** + * Where the walk goes after a page. The completeness rule and the anti-skip + * rule, in one place, offline, so `--self-test` can hold both. + * + * `done` is decided by the SHORT PAGE and by nothing else — never by a `Link` + * header, never by "the rows look old enough". + */ +export function nextWalkStep({ batchLength, lastUpdatedAt, since, page, perPage = PER_PAGE }) { + if (batchLength < perPage) { + return { done: true, since: lastUpdatedAt ?? since, page, reanchored: false }; + } + if (lastUpdatedAt && lastUpdatedAt !== since) { + // Re-anchor on the VALUE. Page 1 again, so a row that shifted forward while + // this run was reading cannot fall through the gap an offset would leave. + return { done: false, since: lastUpdatedAt, page: 1, reanchored: true }; + } + // A whole page sharing one timestamp: the offset is the only way past it. + return { done: false, since, page: page + 1, reanchored: false }; +} + +/** + * The `since` this run reads from, and the reason — printed, so a run that + * silently became full (or silently stayed incremental) cannot be mistaken for + * the other one. + */ +export function selectSince(manifest, { full = false, override = null } = {}) { + if (override) return { since: override, reason: `--since=${override} given on the command line` }; + if (full) return { since: null, reason: '--full: the whole board, ignoring the previous manifest' }; + if (!manifest) return { since: null, reason: 'no previous manifest — the first run is full' }; + if (manifest.resume?.since) return { since: manifest.resume.since, reason: `resuming the previous run at ${manifest.resume.since}` }; + if (manifest.next_since) return { since: manifest.next_since, reason: `incremental from the previous run's next_since (${manifest.next_since})` }; + return { since: null, reason: 'the previous manifest carries no cursor — falling back to a full read' }; +} + +/** + * The count check. `expected` is the board's own arithmetic; `archived` is a + * census of files on disk. `pending` is a third answer and not a pass: a + * snapshot still resuming has not enumerated the board yet, so its census says + * nothing about completeness in either direction. + */ +export function countCheck({ openIssuesCount, openPullRequests, archivedOpenIssues, gone = [], resuming = false }) { + const expected = Number(openIssuesCount ?? 0) - Number(openPullRequests ?? 0); + const goneOpen = new Set(gone ?? []); + const archived = Number(archivedOpenIssues ?? 0); + const shortfall = Math.max(0, expected - archived); + const surplus = Math.max(0, archived - expected - goneOpen.size); + if (resuming) { + return { verdict: 'pending', expected, archived, shortfall: null, surplus: null, gone_ledger: goneOpen.size, ok: null }; + } + const ok = shortfall === 0 && surplus === 0; + return { verdict: ok ? 'ok' : shortfall > 0 ? 'shortfall' : 'surplus', expected, archived, shortfall, surplus, gone_ledger: goneOpen.size, ok }; +} + +/** + * The manifest, minus the three fields that move on every run whether or not + * anything changed. Comparing THIS is what makes "no change ⇒ no write" real: + * a run stamp is a fact about the run, not about the board. + */ +export function materialManifest(manifest) { + if (!manifest) return null; + const { generated_at: _stamp, requests: _requests, run: _run, ...rest } = manifest; + return rest; +} + +/** Did anything about the BOARD change between these two manifests? */ +export function manifestChanged(previous, next) { + if (!previous) return true; + return stableJson(materialManifest(previous)) !== stableJson(materialManifest(next)); +} + +/** The manifest this run would write, built key by key so the file is diff-stable. */ +export function buildManifest({ + repo, + generatedAt, + since, + sinceReason, + nextSince, + resume, + counts, + board, + check, + requests, + run, +}) { + return { + schema: ARCHIVE_SCHEMA, + repo, + generated_at: generatedAt, + since: since ?? null, + since_reason: sinceReason, + next_since: nextSince ?? null, + resume: resume ?? null, + counts, + board, + count_check: check, + requests, + run, + }; +} + +// --------------------------------------------------------------------------- +// The archive on disk — read, census, write. No network in this section. +// --------------------------------------------------------------------------- + +/** A JSON file, or `null` when it is absent or unreadable. Absence is a normal answer here. */ +export function readJsonFile(path) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch { + return null; + } +} + +/** The numbers a seat has declared destroyed or moved, so the surplus alarm can stay one. */ +export function readGoneLedger(outDir) { + const raw = readJsonFile(join(outDir, GONE_LEDGER_NAME)); + if (!raw) return []; + const rows = Array.isArray(raw) ? raw : Array.isArray(raw.numbers) ? raw.numbers : []; + return rows.map((r) => Number(typeof r === 'object' ? r?.number : r)).filter((n) => Number.isInteger(n)); +} + +/** + * A census of the archive as it stands on disk — counts by state and by kind. + * + * Read from the FILES, never accumulated across runs: an accumulated counter + * drifts from what is actually stored, and the count check would then be + * comparing the board with a number this tool made up. + */ +export function censusArchive(outDir) { + const counts = { issues_open: 0, issues_closed: 0, pulls_open: 0, pulls_closed: 0, records: 0 }; + let names = []; + try { + names = readdirSync(join(outDir, 'issues')); + } catch { + return counts; + } + for (const name of names) { + if (!name.endsWith('.json')) continue; + const record = readJsonFile(join(outDir, 'issues', name)); + if (!record) continue; + counts.records += 1; + const pull = record.kind === 'pull_request'; + const open = record.state === 'open'; + if (pull && open) counts.pulls_open += 1; + else if (pull) counts.pulls_closed += 1; + else if (open) counts.issues_open += 1; + else counts.issues_closed += 1; + } + return counts; +} + +/** + * Write `text` at `path` only if the bytes differ. Returns whether it wrote. + * + * The rename is what makes a run interruptible without corrupting the archive: + * a run killed by the job timeout leaves either the old file or the new one, + * never half of either. + */ +export function writeIfChanged(path, text, { dryRun = false } = {}) { + let existing = null; + try { + existing = readFileSync(path, 'utf8'); + } catch { + existing = null; + } + if (existing === text) return false; + if (dryRun) return true; + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, text); + renameSync(tmp, path); + return true; +} + +/** + * Apply a whole run's writes. `files` is a Map of path to contents; the return + * value is the paths that actually moved, so "nothing changed" is a measured + * fact and not an assumption the caller makes about its own inputs. + */ +export function applyWrites(files, { dryRun = false } = {}) { + const written = []; + for (const [path, text] of files) { + if (writeIfChanged(path, text, { dryRun })) written.push(path); + } + return written.sort(); +} + +// --------------------------------------------------------------------------- +// --restore — prints, never posts +// --------------------------------------------------------------------------- + +/** + * The sentence a rebuilt record must carry (#17374 F3.4): a record that does not + * say it is a rebuild is a forgery of the original, and the number it names is + * the destroyed one rather than its own. + */ +export function provenanceSentence(runStamp) { + return `rebuilt from the board snapshot at ${runStamp}; the original was destroyed with its author's account`; +} + +/** + * The recreate payload for one destroyed card: a provenance header, the original + * body, the labels to re-apply, and the comment thread as a second block. + * + * ⛔ It carries no attribution footer. The comment channel appends its own, and + * a footer shipped inside a body that a seat then posts is how a record ends up + * with two. + */ +export function restorePayload({ issue, comments = [], runStamp, repo }) { + const opened = issue.author?.login ? `@${issue.author.login}` : 'an account that no longer resolves'; + const closed = issue.closed_at ? `, ${issue.state}${issue.state_reason ? ` (${issue.state_reason})` : ''} at ${issue.closed_at}` : `, ${issue.state}`; + const header = [ + `> **Rebuilt record — not the original.** Originally ${repo}#${issue.number}, opened by ${opened}`, + `> at ${issue.created_at}, last updated ${issue.updated_at}${closed}.`, + `> ${provenanceSentence(runStamp)}.`, + '> The number above is the destroyed card; this card carries a new one.', + ].join('\n'); + + const labels = issue.labels?.length ? issue.labels.map((l) => `\`${l}\``).join(', ') : '(none)'; + const blocks = [ + header, + '', + issue.body?.trim() ? issue.body : '_(the archived body was empty)_', + '', + '---', + '', + `**Labels to re-apply:** ${labels}`, + `**Assignees on the original:** ${issue.assignees?.length ? issue.assignees.map((a) => `@${a}`).join(', ') : '(none)'}`, + ]; + + const thread = [ + '', + '---', + '', + `**Comment thread — ${comments.length} comment(s).** Post each as its own comment, not as part of the body.`, + '', + ]; + for (const [index, comment] of comments.entries()) { + const who = comment.author?.login ? `@${comment.author.login}` : 'an account that no longer resolves'; + thread.push(`### Comment ${index + 1} — ${who} at ${comment.created_at}`, '', comment.body?.trim() ? comment.body : '_(empty)_', ''); + } + + return `${[...blocks, ...thread].join('\n').replace(/\n+$/, '')}\n`; +} + +/** Read one number back out of the archive. Reads the directory and nothing else. */ +export function readArchivedCard(outDir, number) { + const issue = readJsonFile(issuePath(outDir, number)); + if (!issue) return { ok: false, error: `no archived record for #${number} at ${issuePath(outDir, number)}` }; + let comments = []; + try { + comments = readFileSync(commentsPath(outDir, number), 'utf8') + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); + } catch { + comments = []; + } + return { ok: true, issue, comments }; +} + +// --------------------------------------------------------------------------- +// Live layer — the only section that touches the network, and it only READS. +// --------------------------------------------------------------------------- + +/** A rate-limit stop. Carried as a type so the run can tell it from a transport failure. */ +export class RateLimited extends Error { + constructor(message, { resetAt = null, path = null } = {}) { + super(message); + this.name = 'RateLimited'; + this.resetAt = resetAt; + this.path = path; + } +} + +/** + * Is this refusal the budget, rather than a permission problem? GitHub answers + * both 403 and 429 for a spent budget, and the discriminator is the remaining + * count — a 403 with budget left is about the token, and retrying it is as + * useless as retrying the other one is harmful. + */ +export function rateLimitStop({ status, remaining, retryAfter, reset }) { + if (status !== 403 && status !== 429) return null; + const spent = String(remaining ?? '') === '0'; + if (!spent && !retryAfter) return null; + const resetAt = reset ? new Date(Number(reset) * 1000).toISOString() : retryAfter ? `${retryAfter}s from now` : 'unknown'; + return { resetAt }; +} + +/** The request counter every fetch in this run passes through. */ +const requestCount = { value: 0 }; + +async function rest(path) { + requestCount.value += 1; + const res = await fetch(`${API}${path}`, { + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + }, + }); + if (!res.ok) { + const stop = rateLimitStop({ + status: res.status, + remaining: res.headers.get('x-ratelimit-remaining'), + retryAfter: res.headers.get('retry-after'), + reset: res.headers.get('x-ratelimit-reset'), + }); + if (stop) throw new RateLimited(`GET ${path} refused for the rate limit; it resets at ${stop.resetAt}`, { resetAt: stop.resetAt, path }); + const err = new Error(`GET ${path} -> HTTP ${res.status}`); + err.status = res.status; + throw err; + } + return res.json(); +} + +/** Every page of one paginated read, walked to a short page. Never a `Link` header. */ +async function readAllPages(makePath, { budget }) { + const rows = []; + for (let page = 1; ; page++) { + budget.spend(); + const batch = await rest(makePath(page)); + const list = Array.isArray(batch) ? batch : []; + rows.push(...list); + if (list.length < PER_PAGE) return rows; + } +} + +/** The run's request budget. `spend()` throws the planned stop, which is not an error. */ +class BudgetExhausted extends Error {} +function makeBudget(max) { + return { + max, + get spent() { + return requestCount.value; + }, + spend() { + if (requestCount.value >= max) throw new BudgetExhausted(`the per-run budget of ${max} requests is spent`); + }, + }; +} + +/** + * Everything one number owes the archive this run, as files. Comment and review + * reads are skipped when the record says there is nothing to read — the listing + * carries the comment count, and on this board most numbers spend zero requests + * beyond the page they arrived on. + */ +async function collectNumber(repo, raw, outDir, { budget }) { + const record = issueRecord(raw); + const files = new Map(); + files.set(issuePath(outDir, record.number), stableJson(record)); + + if (record.comments > 0) { + const rows = await readAllPages((page) => `/repos/${repo}/issues/${record.number}/comments?per_page=${PER_PAGE}&page=${page}`, { budget }); + files.set(commentsPath(outDir, record.number), stableJsonl(byId(rows.map(commentRecord)))); + } + + if (record.kind === 'pull_request') { + const reviews = await readAllPages((page) => `/repos/${repo}/pulls/${record.number}/reviews?per_page=${PER_PAGE}&page=${page}`, { budget }); + const reviewComments = await readAllPages((page) => `/repos/${repo}/pulls/${record.number}/comments?per_page=${PER_PAGE}&page=${page}`, { budget }); + const rows = [...byId(reviews.map((r) => reviewRecord(r, 'review'))), ...byId(reviewComments.map((r) => reviewRecord(r, 'review_comment')))]; + if (rows.length) files.set(reviewsPath(outDir, record.number), stableJsonl(rows)); + } + + return { record, files }; +} + +/** + * The snapshot run. + * + * Every early exit — budget, rate limit — leaves the same two things behind: the + * files already collected, and a `resume` cursor the next run continues from. + * ⛔ There is no path here that retries a refused request. + */ +async function snapshot(repo, options) { + const outDir = options.out; + const previous = readJsonFile(join(outDir, MANIFEST_NAME)); + const picked = selectSince(previous, { full: options.full, override: options.since }); + const budget = makeBudget(options.maxRequests); + + const files = new Map(); + const seen = new Set(); + let since = picked.since; + let page = 1; + let cursor = picked.since; + let stopped = null; + let walkComplete = false; + + try { + for (;;) { + budget.spend(); + const batch = await rest(listingPath(repo, { since, page })); + const rows = Array.isArray(batch) ? batch : []; + for (const raw of rows) { + if (seen.has(raw.number)) continue; + if (options.limit && seen.size >= options.limit) { + stopped = { kind: 'limit', reason: `--limit=${options.limit} reached` }; + break; + } + const collected = await collectNumber(repo, raw, outDir, { budget }); + for (const [path, text] of collected.files) files.set(path, text); + seen.add(raw.number); + cursor = collected.record.updated_at ?? cursor; + } + if (stopped) break; + const step = nextWalkStep({ batchLength: rows.length, lastUpdatedAt: rows.at(-1)?.updated_at ?? null, since, page }); + if (step.done) { + walkComplete = true; + cursor = step.since ?? cursor; + break; + } + since = step.since; + page = step.page; + } + } catch (err) { + if (err instanceof BudgetExhausted) stopped = { kind: 'budget', reason: err.message }; + else if (err instanceof RateLimited) stopped = { kind: 'rate-limit', reason: err.message, resetAt: err.resetAt }; + else throw err; + } + + // The board's own arithmetic, read in THIS run so the two sides of the count + // check describe one instant. Skipped when the run already stopped: spending + // the last requests on a census of an archive that is knowingly partial buys + // a reading nobody can use. + let board = { open_issues_count: null, open_pull_requests: null, read_at: null }; + if (!stopped) { + try { + budget.spend(); + const meta = await rest(`/repos/${repo}`); + const openPulls = await readAllPages((p) => openPullsPath(repo, { page: p }), { budget }); + board = { open_issues_count: meta?.open_issues_count ?? null, open_pull_requests: openPulls.length, read_at: new Date().toISOString() }; + } catch (err) { + if (err instanceof BudgetExhausted) stopped = { kind: 'budget', reason: err.message }; + else if (err instanceof RateLimited) stopped = { kind: 'rate-limit', reason: err.message, resetAt: err.resetAt }; + else throw err; + } + } + + // Write the records BEFORE the manifest: a run that dies between the two + // leaves an archive richer than its manifest claims, which the next run + // repairs. The other order loses records and reports success. + const written = applyWrites(files, { dryRun: options.dryRun }); + + const resuming = Boolean(stopped); + const counts = censusArchive(outDir); + const gone = readGoneLedger(outDir); + const check = countCheck({ + openIssuesCount: board.open_issues_count, + openPullRequests: board.open_pull_requests, + archivedOpenIssues: counts.issues_open, + gone, + resuming: resuming || board.open_issues_count === null, + }); + + const manifest = buildManifest({ + repo, + generatedAt: new Date().toISOString(), + since: picked.since, + sinceReason: picked.reason, + nextSince: resuming ? (previous?.next_since ?? null) : cursor, + resume: resuming ? { since: cursor, stopped_by: stopped.kind, reason: stopped.reason, ...(stopped.resetAt ? { resets_at: stopped.resetAt } : {}) } : null, + counts, + board, + check, + requests: requestCount.value, + run: { numbers_read: seen.size, files_written: written.length, walk_complete: walkComplete }, + }); + + const manifestPath = join(outDir, MANIFEST_NAME); + const manifestMoved = manifestChanged(previous, manifest) || written.length > 0; + if (manifestMoved) writeIfChanged(manifestPath, stableJson(manifest), { dryRun: options.dryRun }); + + return { manifest, written, manifestMoved, stopped, picked, walkComplete }; +} + +/** What the run tells a reader, and the exit code that goes with it. */ +export function renderRun(result, options) { + const m = result.manifest; + const lines = [ + `board-snapshot — ${m.repo} into ${options.out}${options.dryRun ? ' (DRY RUN — nothing was written)' : ''}`, + ` since ${m.since ?? '(full)'} — ${m.since_reason}`, + ` read ${m.run.numbers_read} number(s) in ${m.requests} request(s); walk ${m.run.walk_complete ? 'complete' : 'INCOMPLETE'}`, + ` written ${result.written.length} file(s)${result.manifestMoved ? ' + manifest' : ''}`, + ` archive ${m.counts.records} record(s): ${m.counts.issues_open} open / ${m.counts.issues_closed} closed issue(s), ${m.counts.pulls_open} open / ${m.counts.pulls_closed} closed pull request(s)`, + ]; + + if (!result.written.length && !result.manifestMoved) { + lines.push(' idempotent nothing changed on the board since the last run, so nothing was written.'); + } + + const check = m.count_check; + if (check.verdict === 'pending') { + lines.push(` count check PENDING — the snapshot is incomplete, so its census is no reading about the board (expected ${check.expected}, archived ${check.archived}).`); + } else if (check.ok) { + lines.push(` count check ok — ${check.archived} open issue(s) archived, board says ${check.expected} (open_issues_count ${m.board.open_issues_count} minus ${m.board.open_pull_requests} open pull request(s)).`); + } else if (check.verdict === 'shortfall') { + lines.push( + ` count check SHORTFALL of ${check.shortfall} — the archive holds ${check.archived} open issue(s) where the board reports ${check.expected}.`, + ' The enumeration missed cards. This is not a warning: an enumeration without a count check is not a reading,', + ' and one that fails its count check is a reading that says it is wrong.', + ); + } else { + lines.push( + ` count check SURPLUS of ${check.surplus} — the archive holds ${check.archived} open issue(s) where the board reports only ${check.expected}.`, + ' Records this archive carries are no longer on the board: destroyed with an account, deleted, or transferred.', + ` Print one back with --restore=N. A number deliberately left gone belongs in ${GONE_LEDGER_NAME}, which is the only way to quiet this.`, + ); + } + + if (result.stopped?.kind === 'rate-limit') { + lines.push( + ` STOPPED rate limit: ${result.stopped.reason}`, + ` Nothing was retried. The next run resumes at ${m.resume.since}.`, + ); + } else if (result.stopped) { + lines.push( + ` paused ${result.stopped.reason} — this is a planned stop, not a failure.`, + ` The next run resumes at ${m.resume.since}.`, + ); + } + + const exitCode = result.stopped?.kind === 'rate-limit' + ? EXIT_RATE_LIMITED + : check.ok === false + ? EXIT_COUNT_MISMATCH + : EXIT_OK; + return { text: lines.join('\n'), exitCode }; +} + +/** + * `--verify-counts`: the count check against a LIVE enumeration instead of the + * archive, in the same arithmetic and through the same page walk. + * + * It exists because the archive's own count check is only a reading once the + * first full snapshot has finished — days of scheduled runs, on a board this + * size — and until then nothing would have exercised the rule the whole + * enumeration rests on. This answers it in a dozen requests, on any day. + */ +async function verifyCounts(repo, options) { + const budget = makeBudget(options.maxRequests); + const numbers = new Set(); + let since = null; + let page = 1; + for (;;) { + budget.spend(); + const batch = await rest(`/repos/${repo}/issues?state=open&sort=updated&direction=asc${since ? `&since=${encodeURIComponent(since)}` : ''}&per_page=${PER_PAGE}&page=${page}`); + const rows = Array.isArray(batch) ? batch : []; + for (const raw of rows) if (!raw.pull_request) numbers.add(raw.number); + const step = nextWalkStep({ batchLength: rows.length, lastUpdatedAt: rows.at(-1)?.updated_at ?? null, since, page }); + if (step.done) break; + since = step.since; + page = step.page; + } + budget.spend(); + const meta = await rest(`/repos/${repo}`); + const openPulls = await readAllPages((p) => openPullsPath(repo, { page: p }), { budget }); + const check = countCheck({ + openIssuesCount: meta?.open_issues_count ?? null, + openPullRequests: openPulls.length, + archivedOpenIssues: numbers.size, + }); + const lines = [ + `board-snapshot --verify-counts — ${repo}`, + ` enumerated ${numbers.size} open issue(s) by walking pages to a short page (no Link header was read)`, + ` board says open_issues_count ${meta?.open_issues_count} minus ${openPulls.length} open pull request(s) = ${check.expected}`, + ` requests ${requestCount.value}`, + check.ok + ? ' count check ok — the enumeration is a reading.' + : ` count check ${check.verdict.toUpperCase()} — shortfall ${check.shortfall}, surplus ${check.surplus}. The enumeration is NOT a reading.`, + ]; + return { text: lines.join('\n'), exitCode: check.ok ? EXIT_OK : EXIT_COUNT_MISMATCH, check }; +} + +function reportPrerequisiteNotMet(err) { + console.error( + `\nboard-snapshot: PREREQUISITE NOT MET — ${err.message}\n\n` + + " Fix: run this where node's fetch reaches api.github.com with a token that can read issues\n" + + ` (a GitHub Actions runner, or an agent container with ${PROXY_FLAG} — this script re-execs\n` + + ' itself with that flag when HTTPS_PROXY is set).\n\n' + + ' NOTHING WAS ARCHIVED. This is not an empty board and not a complete snapshot — it is no\n' + + ' reading at all, and the previous archive is untouched.\n' + + `\n (Exit code ${EXIT_PREREQUISITE_NOT_MET}. Capture it BEFORE any pipe:\n` + + ' `node scripts/pm/board-snapshot.mjs --out=board > /tmp/b.log 2>&1; echo "EXIT=$?"`.)', + ); + return EXIT_PREREQUISITE_NOT_MET; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +export const KNOWN_FLAGS = Object.freeze(['--full', '--dry-run', '--verify-counts', '--self-test', '--help', '-h']); +export const KNOWN_OPTIONS = Object.freeze(['out', 'since', 'max-requests', 'limit', 'restore']); + +export function readOption(argv, name, fallback = null) { + const hit = argv.find((a) => a.startsWith(`--${name}=`)); + if (hit !== undefined) return hit.slice(name.length + 3); + // `--name VALUE` is accepted for every option too: the card spells the restore + // recipe `--restore `, and a tool that answered a usage error to the + // spelling its own card documents would be read as broken rather than strict. + const at = argv.indexOf(`--${name}`); + if (at !== -1 && at + 1 < argv.length && !argv[at + 1].startsWith('-')) return argv[at + 1]; + return fallback; +} + +/** + * Parse, and REFUSE anything unrecognised. + * + * A typo must never make this decision quietly: `--dry-runn` that parses as + * "no flags given" is a live run, and `--limitt=20` is a full-board pull with a + * user token — the one thing the dispatch of this card forbids by name. + */ +export function parseOptions(argv) { + const consumed = new Set(); + for (const [index, arg] of argv.entries()) { + if (consumed.has(index)) continue; + if (!arg.startsWith('-')) return { ok: false, error: `unexpected argument ${JSON.stringify(arg)} — every input to this tool is a flag.` }; + const name = arg.startsWith('--') && arg.includes('=') ? arg.slice(2, arg.indexOf('=')) : null; + if (name === null && KNOWN_OPTIONS.includes(arg.slice(2))) { + if (index + 1 >= argv.length || argv[index + 1].startsWith('-')) return { ok: false, error: `${arg} takes a value: write ${arg}=VALUE or ${arg} VALUE.` }; + consumed.add(index + 1); + continue; + } + if (name === null && !KNOWN_FLAGS.includes(arg)) return { ok: false, error: `unknown flag ${arg}. Known: ${[...KNOWN_FLAGS, ...KNOWN_OPTIONS.map((o) => `--${o}=…`)].join(' ')}` }; + if (name !== null && !KNOWN_OPTIONS.includes(name)) return { ok: false, error: `unknown option --${name}=…. Known: ${KNOWN_OPTIONS.map((o) => `--${o}=…`).join(' ')}` }; + } + + const maxRaw = readOption(argv, 'max-requests', String(DEFAULT_MAX_REQUESTS)); + const maxRequests = Number(maxRaw); + if (!Number.isInteger(maxRequests) || maxRequests < 1) return { ok: false, error: `--max-requests=${maxRaw} is not a positive integer.` }; + + const limitRaw = readOption(argv, 'limit', null); + const limit = limitRaw === null ? null : Number(limitRaw); + if (limitRaw !== null && (!Number.isInteger(limit) || limit < 1)) return { ok: false, error: `--limit=${limitRaw} is not a positive integer.` }; + + const restoreRaw = readOption(argv, 'restore', null); + const restore = restoreRaw === null ? null : Number(restoreRaw); + if (restoreRaw !== null && (!Number.isInteger(restore) || restore < 1)) return { ok: false, error: `--restore=${restoreRaw} is not an issue number.` }; + + return { + ok: true, + options: { + out: readOption(argv, 'out', DEFAULT_OUT_DIR), + since: readOption(argv, 'since', null), + full: argv.includes('--full'), + dryRun: argv.includes('--dry-run'), + verifyCounts: argv.includes('--verify-counts'), + maxRequests, + limit, + restore, + }, + }; +} + +const USAGE = [ + 'board-snapshot — back the board up to an orphan branch, so an account suspension destroys no record.', + '', + ' node scripts/pm/board-snapshot.mjs [--out=DIR] [--full] [--since=ISO] [--limit=N]', + ' [--max-requests=N] [--dry-run]', + ' node scripts/pm/board-snapshot.mjs --out=DIR --restore=N # print a recreate payload; posts nothing', + ' node scripts/pm/board-snapshot.mjs --verify-counts # the count check, live, no archive', + ' node scripts/pm/board-snapshot.mjs --self-test # offline, no network, no token', + '', + ` Default --out is ${DEFAULT_OUT_DIR}/ and the default budget is ${DEFAULT_MAX_REQUESTS} requests per run.`, + ' The board is PM_SWEEP_REPO or GITHUB_REPOSITORY; this tool refuses to guess one.', + ' It reads GitHub and writes files. It has no write path to GitHub in any mode.', +].join('\n'); + +async function main(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + return EXIT_OK; + } + + const parsed = parseOptions(argv); + if (!parsed.ok) { + console.error(`board-snapshot: ${parsed.error}`); + return EXIT_USAGE; + } + const options = parsed.options; + + // --restore reads the archive directory and nothing else: no token, no + // network, no repo. It works from a bare checkout of the archive branch, + // which is the situation it is for. + if (options.restore !== null) { + const card = readArchivedCard(options.out, options.restore); + if (!card.ok) { + console.error(`board-snapshot: ${card.error}`); + return EXIT_USAGE; + } + const manifest = readJsonFile(join(options.out, MANIFEST_NAME)); + console.log(restorePayload({ + issue: card.issue, + comments: card.comments, + runStamp: manifest?.generated_at ?? 'an unstamped archive', + repo: manifest?.repo ?? 'this repository', + })); + return EXIT_OK; + } + + const repoRes = resolveSweepRepo(process.env); + if (!repoRes.valid || repoRes.source === 'default') { + console.error( + `board-snapshot: the board to archive is ${repoRes.source === 'default' ? 'not set' : `${repoRes.source}=${JSON.stringify(repoRes.repo)}, which is not owner/name`}.\n` + + ' Set PM_SWEEP_REPO (the workflow passes github.repository). ⛔ This tool refuses the resolver\'s\n' + + ' default: a copy of it archiving the repo it was copied FROM produces a complete, well-formed,\n' + + ' green archive of the wrong board.', + ); + return EXIT_USAGE; + } + + if (!TOKEN) { + console.error( + 'board-snapshot: no token. Set GITHUB_TOKEN (the workflow supplies its own) or GH_TOKEN.\n' + + ' ⛔ Refusing to run unauthenticated: the anonymous budget is 60 requests/hour, so an\n' + + ' unauthenticated run would write a truncated archive and report it as a snapshot.', + ); + return EXIT_PREREQUISITE_NOT_MET; + } + + try { + const rendered = options.verifyCounts ? await verifyCounts(repoRes.repo, options) : renderRun(await snapshot(repoRes.repo, options), options); + console.log(rendered.text); + return rendered.exitCode; + } catch (err) { + if (err instanceof RateLimited) { + console.error(`board-snapshot: ${err.message}. Nothing was retried.`); + return EXIT_RATE_LIMITED; + } + return reportPrerequisiteNotMet(err); + } +} + +function rearmThroughProxy(args) { + const plan = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); + if (plan.hint) { + console.error(`ℹ️ ${plan.reason}. A refusal below may be about the route, not this container.`); + return null; + } + if (!plan.rearm) return null; + if (process.env[PROXY_REARM_GUARD] === '1') return null; + console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`); + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; + const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') return child.status; + console.error(`⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — every request will bypass the proxy.`); + return null; +} + +// --------------------------------------------------------------------------- +// Self-test — offline, no network, no token, and the only instrument watching +// the rules a clean tree cannot exercise: the walk's re-anchor, the resume +// cursor, the count check's three verdicts, the restore header, and the two +// structural properties (no write path to GitHub, no retry loop) that this +// tool's whole standing rests on. +// +// Every section opens with `battery(...)`; the floor below requires the OPENED +// set to equal the DECLARED set with each battery at or above its own count, so +// a section that stops running names itself instead of going quiet. The counts +// are a FLOOR, never an equality — adding cases is ordinary work. +// --------------------------------------------------------------------------- + +const SELF_TEST_BATTERIES = Object.freeze({ + 'the record shapes: fixed key order, declared absence': 9, + 'the page walk: only a short page ends it': 8, + 'the `since` selection: full, then incremental, then resume': 7, + 'idempotence: no change means no write': 8, + 'the census and the count check': 10, + 'the restore payload: a rebuilt record says it is one': 9, + 'the refusals: a typo must never make this decision': 11, + 'the rate-limit stop, and the two properties that are structural': 8, +}); +const SELF_TEST_BATTERY_FLOOR = 8; +const UNATTRIBUTED_BATTERY = '(unattributed)'; + +let selfTestReachedVerdict = false; + +export function selfTest() { + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { openBattery = name; }; + const cases = []; + const t = (name, ok, detail) => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + cases.push({ name, ok: Boolean(ok), detail }); + }; + + const RAW = (over = {}) => ({ + number: 17297, + title: 'a p1 security decision card', + state: 'open', + state_reason: null, + user: { login: 'os-litant', id: 7, type: 'User' }, + labels: [{ name: 'priority:p1' }, { name: 'domain:skills' }], + assignees: [{ login: 'zeta' }, { login: 'alpha' }], + milestone: null, + created_at: '2026-09-01T00:00:00Z', + updated_at: '2026-09-02T00:00:00Z', + closed_at: null, + comments: 2, + locked: false, + html_url: 'https://github.com/o/r/issues/17297', + body: 'the body that a suspension destroys', + ...over, + }); + const RAW_COMMENT = (over = {}) => ({ + id: 5617561348, + user: { login: 'os-litant', id: 7, type: 'User' }, + created_at: '2026-09-02T10:56:32Z', + updated_at: '2026-09-02T10:56:32Z', + html_url: 'https://github.com/o/r/issues/17297#issuecomment-5617561348', + body: 'Claim: PM loop round 1', + ...over, + }); + + // -- the record shapes ----------------------------------------------------- + battery('the record shapes: fixed key order, declared absence'); + const record = issueRecord(RAW()); + t('the key order is the one this file writes, not the order GitHub answered in', + Object.keys(record).join(',') === 'number,kind,title,state,state_reason,draft,author,labels,assignees,milestone,type,created_at,updated_at,closed_at,closed_by,merged_at,comments,locked,html_url,body', + Object.keys(record).join(',')); + t('labels are sorted, so a re-fetch in another order is not a diff', record.labels.join() === 'domain:skills,priority:p1'); + t('assignees are sorted for the same reason', record.assignees.join() === 'alpha,zeta'); + t('`closed_by` is PRESENT and null — declared absent, not silently missing', 'closed_by' in record && record.closed_by === null); + t('an issue reads kind=issue and its `draft` is null, not false', record.kind === 'issue' && record.draft === null); + const pull = issueRecord(RAW({ number: 17326, pull_request: { merged_at: '2026-09-10T07:45:30Z' }, draft: false })); + t('a pull request reads kind=pull_request and carries merged_at off the listing, at no extra request', + pull.kind === 'pull_request' && pull.merged_at === '2026-09-10T07:45:30Z' && pull.draft === false); + t('two runs over the same answer produce identical bytes', stableJson(issueRecord(RAW())) === stableJson(issueRecord(RAW()))); + t('a JSON file ends in exactly one newline', stableJson({ a: 1 }).endsWith('}\n') && !stableJson({ a: 1 }).endsWith('\n\n')); + t('an empty thread is an empty file, never a line of nothing', stableJsonl([]) === '' && stableJsonl([commentRecord(RAW_COMMENT())]).split('\n').filter(Boolean).length === 1); + + // -- the page walk --------------------------------------------------------- + battery('the page walk: only a short page ends it'); + const full = nextWalkStep({ batchLength: 100, lastUpdatedAt: '2026-09-03T00:00:00Z', since: '2026-09-01T00:00:00Z', page: 1 }); + t('a FULL page must fetch the next — the completeness rule, and nothing else ends the walk', full.done === false); + t('…and it re-anchors on the VALUE, back to page 1, so a row that shifted forward cannot fall through the gap', + full.since === '2026-09-03T00:00:00Z' && full.page === 1 && full.reanchored === true); + const tied = nextWalkStep({ batchLength: 100, lastUpdatedAt: '2026-09-01T00:00:00Z', since: '2026-09-01T00:00:00Z', page: 4 }); + t('a full page whose rows all share the cursor advances the PAGE — the one case an offset is for', + tied.done === false && tied.page === 5 && tied.since === '2026-09-01T00:00:00Z'); + t('a short page ends the walk', nextWalkStep({ batchLength: 3, lastUpdatedAt: '2026-09-04T00:00:00Z', since: null, page: 9 }).done === true); + t('an empty page ends the walk', nextWalkStep({ batchLength: 0, lastUpdatedAt: null, since: 'x', page: 2 }).done === true); + const path = listingPath('o/r', { since: '2026-09-01T00:00:00Z', page: 3 }); + t('the listing asks for state=all — the closed half is exactly what a suspension destroys', path.includes('state=all')); + t('…ordered by updated ascending, 100 a page, by page number', path.includes('sort=updated') && path.includes('direction=asc') && path.includes('per_page=100') && path.includes('page=3')); + t('a full read carries no `since` at all', !listingPath('o/r', {}).includes('since=')); + + // -- the since selection --------------------------------------------------- + battery('the `since` selection: full, then incremental, then resume'); + t('no manifest: the first run is full', selectSince(null).since === null); + t('…and says so, so a full run and an incremental one are never confused', /first run is full/.test(selectSince(null).reason)); + t('a previous manifest hands over its next_since', selectSince({ next_since: '2026-09-05T00:00:00Z' }).since === '2026-09-05T00:00:00Z'); + t('a resume cursor WINS over next_since — an interrupted run is continued, not skipped past', + selectSince({ next_since: '2026-09-09T00:00:00Z', resume: { since: '2026-09-05T00:00:00Z' } }).since === '2026-09-05T00:00:00Z'); + t('--full ignores the manifest', selectSince({ next_since: '2026-09-05T00:00:00Z' }, { full: true }).since === null); + t('--since overrides everything, including a resume cursor', + selectSince({ resume: { since: '2026-09-05T00:00:00Z' } }, { override: '2026-01-01T00:00:00Z' }).since === '2026-01-01T00:00:00Z'); + t('every selection carries a printable reason', ['reason'].every((k) => typeof selectSince(null)[k] === 'string' && selectSince(null)[k].length > 0)); + + // -- idempotence ----------------------------------------------------------- + battery('idempotence: no change means no write'); + const dir = mkdtempSync(join(tmpdir(), 'board-snapshot-')); + try { + const files = new Map([ + [issuePath(dir, 17297), stableJson(issueRecord(RAW()))], + [commentsPath(dir, 17297), stableJsonl([commentRecord(RAW_COMMENT())])], + ]); + t('the first run writes every file', applyWrites(files).length === 2); + t('THE CASE: an unchanged re-run writes nothing at all', applyWrites(files).length === 0); + const changed = new Map(files); + changed.set(issuePath(dir, 17297), stableJson(issueRecord(RAW({ state: 'closed', state_reason: 'completed' })))); + t('a changed record writes exactly its own file', applyWrites(changed).join() === issuePath(dir, 17297)); + const dryDir = mkdtempSync(join(tmpdir(), 'board-snapshot-dry-')); + const dryFiles = new Map([[issuePath(dryDir, 1), stableJson({ number: 1 })]]); + t('--dry-run reports the path it would write', applyWrites(dryFiles, { dryRun: true }).length === 1); + t('…and leaves the disk untouched', readJsonFile(issuePath(dryDir, 1)) === null); + rmSync(dryDir, { recursive: true, force: true }); + const base = { schema: 1, counts: { issues_open: 3 }, generated_at: 'A', requests: 5, run: { numbers_read: 1 } }; + t('a manifest differing only in its run stamp is NOT a change — a stamp is a fact about the run', + manifestChanged(base, { ...base, generated_at: 'B', requests: 9, run: { numbers_read: 4 } }) === false); + t('a manifest whose counts moved IS a change', manifestChanged(base, { ...base, counts: { issues_open: 4 } }) === true); + t('no previous manifest is always a change', manifestChanged(null, base) === true); + + // -- the census and the count check -------------------------------------- + battery('the census and the count check'); + const censusDir = mkdtempSync(join(tmpdir(), 'board-snapshot-census-')); + applyWrites(new Map([ + [issuePath(censusDir, 1), stableJson(issueRecord(RAW({ number: 1, state: 'open' })))], + [issuePath(censusDir, 2), stableJson(issueRecord(RAW({ number: 2, state: 'closed' })))], + [issuePath(censusDir, 3), stableJson(issueRecord(RAW({ number: 3, state: 'open', pull_request: { merged_at: null } })))], + [issuePath(censusDir, 4), stableJson(issueRecord(RAW({ number: 4, state: 'closed', pull_request: { merged_at: 'x' } })))], + ])); + const census = censusArchive(censusDir); + t('the census counts by state AND kind, read off the files rather than accumulated', + census.issues_open === 1 && census.issues_closed === 1 && census.pulls_open === 1 && census.pulls_closed === 1 && census.records === 4); + t('a census of a directory that does not exist is zero, not a throw', censusArchive(join(censusDir, 'nope')).records === 0); + t('the ledger of gone numbers is empty when the file is absent', readGoneLedger(censusDir).length === 0); + writeIfChanged(join(censusDir, GONE_LEDGER_NAME), stableJson([{ number: 9, note: 'transferred' }, 10])); + t('…and reads both a bare number and a row carrying a note', readGoneLedger(censusDir).join() === '9,10'); + rmSync(censusDir, { recursive: true, force: true }); + t('the count check passes when the archive equals open_issues_count minus the open pull requests', + countCheck({ openIssuesCount: 620, openPullRequests: 20, archivedOpenIssues: 600 }).ok === true); + const short = countCheck({ openIssuesCount: 620, openPullRequests: 20, archivedOpenIssues: 590 }); + t('a SHORTFALL is the enumeration missing cards, and it is not ok', short.verdict === 'shortfall' && short.shortfall === 10 && short.ok === false); + const surplus = countCheck({ openIssuesCount: 620, openPullRequests: 20, archivedOpenIssues: 606 }); + t('a SURPLUS is the destruction signature this tool exists for, and it is not ok', surplus.verdict === 'surplus' && surplus.surplus === 6 && surplus.ok === false); + t('…and the gone ledger is the only thing that quiets it', + countCheck({ openIssuesCount: 620, openPullRequests: 20, archivedOpenIssues: 606, gone: [1, 2, 3, 4, 5, 6] }).ok === true); + const pending = countCheck({ openIssuesCount: 620, openPullRequests: 20, archivedOpenIssues: 12, resuming: true }); + t('a resuming snapshot reads PENDING — a census of a partial archive is no reading about the board', pending.verdict === 'pending'); + t('…and pending is not a pass: `ok` is null, so "could not check" never renders as "checked and clean"', pending.ok === null); + + // -- the restore payload ------------------------------------------------- + battery('the restore payload: a rebuilt record says it is one'); + const archived = issueRecord(RAW()); + const thread = [commentRecord(RAW_COMMENT()), commentRecord(RAW_COMMENT({ id: 2, body: 'ACCEPT' }))]; + const payload = restorePayload({ issue: archived, comments: thread, runStamp: '2026-09-10T13:37:00Z', repo: 'o/r' }); + t('THE rule: the payload carries the provenance sentence verbatim', payload.includes(provenanceSentence('2026-09-10T13:37:00Z'))); + t('it names the ORIGINAL number, and says the rebuilt card carries a new one', payload.includes('o/r#17297') && /new one/.test(payload)); + t('it names the author whose account the loss travelled through', payload.includes('@os-litant')); + t('it carries both timestamps', payload.includes('2026-09-01T00:00:00Z') && payload.includes('2026-09-02T00:00:00Z')); + t('it lists the labels to re-apply', payload.includes('`domain:skills`') && payload.includes('`priority:p1`')); + t('the thread is a SECOND block, one heading per comment', payload.includes('Comment 1 — @os-litant') && payload.includes('Comment 2 —') && payload.includes('2 comment(s)')); + t('⛔ it carries no attribution footer — the channel appends its own, and two is what that costs', !payload.includes('Generated by [')); + t('an author whose account is gone renders without inventing a handle', + restorePayload({ issue: { ...archived, author: null }, comments: [], runStamp: 's', repo: 'o/r' }).includes('an account that no longer resolves')); + const restoreDir = mkdtempSync(join(tmpdir(), 'board-snapshot-restore-')); + applyWrites(new Map([[issuePath(restoreDir, 17297), stableJson(archived)], [commentsPath(restoreDir, 17297), stableJsonl(thread)]])); + const readBack = readArchivedCard(restoreDir, 17297); + t('--restore reads the archive DIRECTORY and nothing else, thread included', + readBack.ok && readBack.issue.body === archived.body && readBack.comments.length === 2); + rmSync(restoreDir, { recursive: true, force: true }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + // -- the refusals ---------------------------------------------------------- + battery('the refusals: a typo must never make this decision'); + t('a good command line parses', parseOptions(['--out=archive/board', '--limit=20']).ok === true); + t('an unknown flag is refused', parseOptions(['--archive-everything']).ok === false); + t('an unknown option is refused', parseOptions(['--outt=board']).ok === false); + t('a positional argument is refused', parseOptions(['17297']).ok === false); + t('THE typo: `--dry-runn` is refused rather than parsed as a LIVE run', parseOptions(['--dry-runn']).ok === false); + t('--limit=0 is refused', parseOptions(['--limit=0']).ok === false); + t('--restore=abc is refused', parseOptions(['--restore=abc']).ok === false); + t('--max-requests=-1 is refused', parseOptions(['--max-requests=-1']).ok === false); + t('the budget defaults to the value this file declares, never to unlimited', parseOptions([]).options.maxRequests === DEFAULT_MAX_REQUESTS); + const spaced = parseOptions(['--out', 'archive/board', '--restore', '17297']); + t('the spelling the card documents parses: `--restore ` with a space', spaced.ok === true && spaced.options.restore === 17297 && spaced.options.out === 'archive/board'); + t('…and an option left without a value is refused rather than swallowing the next flag', + parseOptions(['--restore', '--dry-run']).ok === false); + + // -- the rate-limit stop, and the structural properties --------------------- + battery('the rate-limit stop, and the two properties that are structural'); + t('403 with the budget spent is a rate-limit stop', rateLimitStop({ status: 403, remaining: '0', reset: '1789000000' }) !== null); + t('429 with a retry-after is a rate-limit stop', rateLimitStop({ status: 429, retryAfter: '60' }) !== null); + t('403 with budget REMAINING is about the token, not the budget — and retrying it is useless', + rateLimitStop({ status: 403, remaining: '4800' }) === null); + t('a 404 is not a rate-limit stop', rateLimitStop({ status: 404, remaining: '0' }) === null); + t('the reset instant is rendered as an ISO stamp a reader can act on', + /^\d{4}-\d{2}-\d{2}T/.test(rateLimitStop({ status: 403, remaining: '0', reset: '1789000000' }).resetAt)); + const source = readFileSync(SELF_PATH, 'utf8'); + // Built from parts on purpose: a literal would match itself and pin nothing. + t('⛔ STRUCTURAL: this file contains no retry loop — the stop writes a cursor and returns', + !new RegExp(['set', 'Timeout'].join('')).test(source) && !source.includes(['await ', 'sleep('].join(''))); + t('⛔ STRUCTURAL: this file has no write path to GitHub in any mode — the one-board rule, mechanically', + !/method:\s*'(?:POST|PATCH|PUT|DELETE)'/.test(source) && !/\bmethod:\s*(?:method|options\.method)\b/.test(source)); + t('…and its only fetch is the one read helper', (source.match(/await fetch\(/g) ?? []).length === 1); + + const declared = Object.keys(SELF_TEST_BATTERIES); + const problems = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + problems.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); + } + for (const [name, count] of batterySeen) { + if (!declared.includes(name)) problems.push(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES.`); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + problems.push(count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. The verdict below would have claimed those cases hold.` + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`); + } + for (const message of problems) cases.push({ name: message, ok: false }); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` x ${c.name}${c.detail ? ` -- ${c.detail}` : ''}`); + if (failed.length) { + console.error(`x board-snapshot self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log(`OK board-snapshot self-test: ${cases.length} cases pass across ${declared.length} batteries (walk re-anchor, resume cursor, idempotence, the three count-check verdicts, the restore header, and the two structural properties).`); + selfTestReachedVerdict = true; + return 0; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + const code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\nx board-snapshot self-test: selfTest() returned without reaching its verdict, so no success\n' + + 'line was printed. Exiting 0 here would report a self-test that never finished as a self-test\n' + + 'that passed.\n', + ); + process.exit(1); + } + process.exit(code); + } else { + const rearmed = rearmThroughProxy(process.argv.slice(2)); + if (rearmed !== null) process.exit(rearmed); + main(process.argv.slice(2)).then((code) => process.exit(code)); + } +} From 0c5c3f0818cd5b893eef6931532541c36a75c73a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 11:53:03 +0000 Subject: [PATCH 2/2] fix(pm): the archiver's workflow exercises itself at PR time, writing nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:pm-dispatch-gates` pins that no discovered gate family is reachable only through a schedule — every patrol in this tree carries a `pull_request` paths trigger, and a workflow whose script CI never runs at PR time reddens that gate by existing. The first draft of `board-snapshot.yml` omitted the trigger to keep PR-time cost at zero and was refused by exactly that pin, which named the remedy in its own failure text. So a pull request now runs the snapshot on a real runner — transport, flags, page walk and rendering, proven where they will actually run — into the runner's temp dir, `--dry-run`, capped at five numbers and twenty-five requests, with the archive checkout and the commit both skipped. A pull request writes to neither the board nor the archive, and the two spellings differ in where they write and in nothing else, so what a PR exercises is the path a schedule takes. Two readings from the local drill, folded back in: the PENDING count-check line no longer prints an arithmetic the run never read (a stopped run skips the board's own count, and `expected 0` read as a real zero), and `--restore` no longer re-execs itself through the proxy — it makes no request at all, so routing its transport spawned a child to prove a route that mode never uses. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude --- .github/workflows/board-snapshot.yml | 51 +++++++++++++++++++++++----- scripts/pm/board-snapshot.mjs | 12 +++++-- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/.github/workflows/board-snapshot.yml b/.github/workflows/board-snapshot.yml index 8e384196eb..649a9650af 100644 --- a/.github/workflows/board-snapshot.yml +++ b/.github/workflows/board-snapshot.yml @@ -43,15 +43,28 @@ name: Board Snapshot # cursor and exits non-zero with the reset time. A loop against a spent budget # starves every other automated caller in the repository for the rest of the hour. # -# ## No `pull_request` trigger, deliberately +# ## The `pull_request` trigger, and what it is allowed to do # -# The sibling patrol proves itself on a real runner at PR time. This one does -# not, because its live run reads the whole update window and would spend the -# repository's shared request budget on every push to every branch. The half CI -# can hold for free is the offline one, and it is held: `check:pm-board-snapshot` -# runs the script's 70-case `--self-test` inside the required `Lint & Repo Gates` -# job, which has no paths filter (`check:self-test-wired` is what requires that -# step to exist). This job runs the same self-test again before it touches the +# Every patrol in this tree exercises itself on the pull requests that change +# it, and that is not a style preference: `check:pm-dispatch-gates` pins that NO +# discovered gate family is reachable only through a schedule, so a workflow +# whose script CI never runs at PR time reddens that gate by existing. A first +# draft of this file omitted the trigger to keep PR-time cost at zero and was +# refused by exactly that pin. +# +# So a pull_request run DOES execute the snapshot on a real runner — the +# transport, the flags, the page walk and the rendering, proven where they will +# actually run — and it writes NOTHING: no archive branch is checked out, the +# run is `--dry-run` into the runner's temp dir, and the commit step is skipped. +# It is also capped hard (`--limit`, `--max-requests` below), because the +# repository's request budget is shared with the half-state patrol and the +# closed-card sweep and must not be spent per push. ⛔ A pull request must never +# write to the board OR to the archive. +# +# The offline half is held for free beside it: `check:pm-board-snapshot` runs the +# script's 70-case `--self-test` inside the required `Lint & Repo Gates` job, +# which has no paths filter (`check:self-test-wired` is what requires that step +# to exist). This job runs the same self-test again before it touches the # archive, so a broken tool fails loudly instead of committing a broken backup. # # ## Adopting this in a sibling repo @@ -80,6 +93,18 @@ on: # nothing cheaper than a webhook closes that, which is a different card. - cron: '37 1,7,13,19 * * *' workflow_dispatch: {} + # Changes to the archiver itself get exercised before they merge. The paths + # name every file the run actually loads — the archiver, the module it imports + # its proxy plan and repo resolver from, the entry-guard helper both import, + # and this file. An undeclared dependency is one a change can break without + # this trigger firing, which is the same defect the adopt list above warns + # about, one layer along. + pull_request: + paths: + - 'scripts/pm/board-snapshot.mjs' + - 'scripts/pm/check-half-states.mjs' + - 'scripts/invoked-as.mjs' + - '.github/workflows/board-snapshot.yml' # Least privilege. `contents: write` is for the archive BRANCH and nothing else; # the two read scopes are what the snapshot reads. ⛔ This job never writes to @@ -122,6 +147,7 @@ jobs: # checkout of it carries the archive and nothing else. First run creates it # locally; the push below is what publishes it. - name: Check out or create the archive branch + if: github.event_name != 'pull_request' run: | set -euo pipefail git fetch --depth=1 origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true @@ -151,9 +177,15 @@ jobs: # in, always. A copy of this file cannot end up archiving the repo it # was copied FROM, and the script refuses to guess when this is unset. PM_SWEEP_REPO: ${{ github.repository }} + # A pull_request run PROVES the tool on a runner and writes nothing: + # the temp dir has no manifest, so it is a full read, capped to a + # handful of numbers and a budget the shared quota will not notice. + # ⛔ The two spellings differ in WHERE they write and in nothing else, + # so what a PR exercises is the path a schedule takes. + SNAPSHOT_ARGS: ${{ github.event_name == 'pull_request' && '--out=board --dry-run --limit=5 --max-requests=25' || '--out=archive/board' }} run: | set +e - node scripts/pm/board-snapshot.mjs --out=archive/board \ + node scripts/pm/board-snapshot.mjs $SNAPSHOT_ARGS \ > "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err" code=$? set -e @@ -171,6 +203,7 @@ jobs: # check never costs the records the same run archived. - name: Commit and push the archive id: commit + if: github.event_name != 'pull_request' run: | set -euo pipefail if [ -z "$(git -C archive status --porcelain)" ]; then diff --git a/scripts/pm/board-snapshot.mjs b/scripts/pm/board-snapshot.mjs index 34a531e384..7314519d3b 100644 --- a/scripts/pm/board-snapshot.mjs +++ b/scripts/pm/board-snapshot.mjs @@ -809,7 +809,10 @@ export function renderRun(result, options) { const check = m.count_check; if (check.verdict === 'pending') { - lines.push(` count check PENDING — the snapshot is incomplete, so its census is no reading about the board (expected ${check.expected}, archived ${check.archived}).`); + const board = m.board.open_issues_count === null + ? 'the board\'s own count was not read this run' + : `the board reports ${check.expected}`; + lines.push(` count check PENDING — the snapshot is incomplete, so its census is no reading about the board (archived ${check.archived}; ${board}).`); } else if (check.ok) { lines.push(` count check ok — ${check.archived} open issue(s) archived, board says ${check.expected} (open_issues_count ${m.board.open_issues_count} minus ${m.board.open_pull_requests} open pull request(s)).`); } else if (check.verdict === 'shortfall') { @@ -1336,7 +1339,12 @@ if (isEntrypoint(import.meta.url)) { } process.exit(code); } else { - const rearmed = rearmThroughProxy(process.argv.slice(2)); + // ⛔ --restore is not re-exec'd: it reads the archive directory and makes no + // request at all, so routing its transport would spawn a child to prove a + // route nothing in that mode uses. The absence of the re-exec line is part + // of what "prints, never posts, reads nothing but the directory" looks like. + const restoring = process.argv.some((a) => a === '--restore' || a.startsWith('--restore=')); + const rearmed = restoring ? null : rearmThroughProxy(process.argv.slice(2)); if (rearmed !== null) process.exit(rearmed); main(process.argv.slice(2)).then((code) => process.exit(code)); }