From 5e6ee199daa0b11299c479e6c352f1d5bd903dd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 01:38:26 +0000 Subject: [PATCH 1/2] =?UTF-8?q?chore(pm):=20close-cards.mjs=20=E2=80=94=20?= =?UTF-8?q?one=20named=20script=20for=20the=20three-step=20card=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-step closure (comment · label · close) had no named spelling, so a seat's batch ran as a bash loop and the session's write classifier judged it act by act — non-deterministically, which is how a 90-card sweep stopped after one card rather than risk a comment landing without its label write. `scripts/pm/close-cards.mjs` is that spelling: it re-reads every card live, applies the skip matrix, drives post-stamped (child process, its documented flags) and label-write's exported `runLabelWrite`, closes with the reason and reads the answer back, and STOPS at the first card left half-written, naming it and which of the three writes landed. Stamping and the four-step label write are imported, never re-implemented. `.claude/settings.json` gains the four allow rules that let a seat run it and its two helpers as typed, from the repo root. Claude-Session: https://claude.ai/code/session_012GcsUbuqFGBibkEDMRC1eE Co-authored-by: Claude --- .claude/settings.json | 4 + .github/workflows/lint.yml | 23 + package.json | 1 + scripts/pm/close-cards.mjs | 1064 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1092 insertions(+) create mode 100644 scripts/pm/close-cards.mjs diff --git a/.claude/settings.json b/.claude/settings.json index 2115397df94..a9e81008585 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -52,6 +52,10 @@ "Bash(curl -sS -X PUT https://api.github.com/repos/objectstack-ai/objectstack/pulls/*/ccr/auto_merge *)", "Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)", "Bash(node --use-env-proxy scripts/pm/label-write.mjs *)", + "Bash(node scripts/pm/post-stamped.mjs *)", + "Bash(node scripts/pm/label-write.mjs *)", + "Bash(node scripts/pm/close-cards.mjs *)", + "Bash(node --use-env-proxy scripts/pm/close-cards.mjs *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectstack/issues/* *)", "Bash(curl -sS -X PATCH https://api.github.com/repos/objectstack-ai/objectui/issues/* *)" ], diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 92fafd613b2..5fd83442727 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1307,6 +1307,29 @@ jobs: - name: Label-write helper self-test run: pnpm check:pm-label-write + # Card-closure helper self-test (#19469) — same family and the same split + # as the two steps above, and it drives BOTH of them: `close-cards.mjs` is + # the three-step closure (comment · label · close) as one named command, + # so a seat's batch closure is one allow-listed prefix instead of a loop + # the session's write classifier judges act by act. The LIVE path posts + # comments, strips labels and closes cards, so it is emphatically not a + # thing CI runs against the board; the offline half is all CI can hold. + # + # What the self-test instruments is the set of rules a clean tree cannot + # exercise, every one of which is silent when it breaks: the skip matrix + # (not open · assigned · `pm:retriage` · a pm-state that is not EXACTLY + # the expected one · an open PR cross-referencing the card); that an + # unreadable card or timeline STOPS the run rather than being read as a + # "no"; that a 200 whose body does not read back `closed`, or reads back + # another `state_reason`, is not a close; and — the one this tool exists + # for — that a comment which lands whose label write then fails STOPS at + # that card, naming it and which of the three writes landed, instead of + # turning one half-state into a page of them. Weakening any of them leaves + # every ordinary invocation just as green, which is the shape + # `check:self-test-wired` requires this step for. + - name: Card-closure helper self-test + run: pnpm check:pm-close-cards + # 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 diff --git a/package.json b/package.json index 0d45ce752f6..533979bd834 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "check:pm-stale-finding": "node scripts/pm/sweep-stale-finding.mjs --self-test", "check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test", "check:pm-label-write": "node scripts/pm/label-write.mjs --self-test", + "check:pm-close-cards": "node scripts/pm/close-cards.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", diff --git a/scripts/pm/close-cards.mjs b/scripts/pm/close-cards.mjs new file mode 100644 index 00000000000..9df8daf6f20 --- /dev/null +++ b/scripts/pm/close-cards.mjs @@ -0,0 +1,1064 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * close-cards — the three-step card closure (comment · label · close), as ONE + * NAMED command (#19469). + * + * ## Usage — from the REPO ROOT, as one command, with no `cd … &&` in front + * + * node scripts/pm/close-cards.mjs --repo owner/name --list cards.txt --comment closing.md --reason not_planned --dry-run + * node scripts/pm/close-cards.mjs --repo owner/name --list cards.txt --comment closing.md --reason not_planned + * node scripts/pm/close-cards.mjs --self-test # offline, no network at all + * + * ⛔ The leading `cd /path/to/objectstack && ` a seat habitually types is what + * this tool exists to remove. A session's allow rule is a PREFIX match against + * the command AS TYPED, so `cd … && node scripts/pm/close-cards.mjs …` matches + * no rule naming this script and falls to the write classifier — the coin flip + * this card was filed for. Run it from the repo root, or name the script with + * an absolute path and nothing before it. + * + * ## The measured defect this closes + * + * Two seats, one wall, one day. A 90-card closing sweep spelled as a bash loop + * over `post-stamped.mjs` → `label-write.mjs` → `PATCH /issues/{n}` was refused + * by the session runtime's write classifier before any request. The second seat + * took the same order: 90 cards passed its live gate, 77 were actionable, ONE + * closed, and then a batch script, an inline three-card loop and a single + * `post-stamped --comment=…` were each refused — the identical command shape + * that had just succeeded twice. It stopped rather than grind a coin-flip + * channel across 76 three-step acts, because a comment that lands without its + * label write is a HALF-STATE on the board. + * + * The two seat-write rules that existed are spelled with the `--use-env-proxy` + * flag inside the prefix (`Bash(node --use-env-proxy scripts/pm/post-stamped.mjs *)`), + * and seats invoke `node scripts/pm/post-stamped.mjs …` — the tool re-execs + * ITSELF with that flag. So neither rule matched, and no rule named a batch + * shape at all. One named script is one prefix to allow. + * + * ## What this file does NOT contain + * + * ⛔ No stamping and ⛔ no four-step label write. Both already exist, both are + * self-tested, and a second copy of either is a second answer to a question + * this repo has settled: + * + * - the comment goes through `scripts/pm/post-stamped.mjs`. Its write path is + * not exported (`writeArtefact` and `main` are module-private), so it is + * driven as a CHILD PROCESS with its documented flags — `--repo=`, + * `--comment=N`, `--file=`, `--json` — and its exit code is read BEFORE any + * pipe. Its pure half (`renderBody`, `claimKeyedLineRefusals`) IS exported, + * and the pre-flight below imports it so a body that tool would refuse is + * refused ONCE, here, rather than 90 times, one card at a time. + * - the label write goes through `runLabelWrite` from + * `scripts/pm/label-write.mjs`, in-process, with its own `parseOptions` + * building the options — so the four steps, the additive-verb order, the + * `PATCH` fallback and the read-back are the same program the CLI runs. + * - the pm-state vocabulary (`PM_EXCLUSIVE_STATE_LABELS`, `PM_STATE_CLAIM`) + * is imported from `check-half-states.mjs`. ⛔ Never restate a label set. + * + * ## The skip matrix — the card is RE-READ live first, always + * + * Any list snapshot is void by the time a batch reaches card 40, so every card + * is read at the moment it is acted on. It is SKIPPED and logged when: + * + * 1. it is not open; + * 2. it carries an assignee — somebody owns it; + * 3. it carries `pm:retriage` — a summons is outstanding; + * 4. its pm-state is not EXACTLY the expected label (default `pm:queue`): + * no state, a different state, or two states all skip; + * 5. an OPEN pull request references it (`--skip-pr-referenced`, default on). + * + * ### Which PR reading, and why + * + * `GET /repos/{o}/{r}/issues/{n}/timeline`, `cross-referenced` events whose + * `source.issue` carries a `pull_request` object, counted as a hit only when + * that source issue's `state` is `open`. That endpoint is the one this tree + * already declares reachable for cross-references + * (`.claude/skills/pm-dispatch/references/rest-channel.md`, 读侧). ⛔ Not + * `/search/issues`: the egress proxy refuses `/search/*` by design, so a search + * reading would make the default skip unavailable on exactly the seats this + * tool is for. ⛔ Not `closed_by_pull_requests_references`: it answers "which PR + * would CLOSE this", which is narrower than "an open PR references it" and + * would silently pass a card an open PR merely mentions. + * + * A timeline read that FAILS is not a "no": it stops the run (exit 3), because + * skipping a card on an unread signal and closing a card on an unread signal + * are both verdicts taken from nothing. + * + * ## The three writes, in order, and the one state that must never be left + * + * Per actionable card, in this order: + * + * ① comment — post-stamped, read-back its own; + * ② label — label-write `--remove `, four steps, read-back; + * ③ close — `PATCH /repos/{o}/{r}/issues/{n}` with `state: closed` and the + * `state_reason`, then READ THE RESPONSE BACK: a 200 whose body + * does not say `closed` is not a close. + * + * A card whose ① landed and whose ② or ③ did not is a HALF-WRITE: a closing + * comment under a card that is still open and still claims `pm:queue`. The run + * STOPS at the first one — ⛔ it does not continue to the next card, and ⛔ it + * does not retry — and exits 4 naming the card and exactly which steps landed. + * Continuing would turn one half-state into a page of them. + * + * ## Exit codes — capture them BEFORE any pipe + * + * 0 every non-skipped card landed all three writes (a run of all-skips too). + * 2 usage. Nothing was read and nothing was written. + * 3 PREREQUISITE NOT MET — no token, no route, or a read that failed. ⛔ NOT + * MEASURED for every card after the one that could not be read. + * 4 HALF-WRITE — a card is on the board in a state nobody asked for. The + * message names the card and the steps that landed. Go look at that card. + * 5 the platform REFUSED a write outright and nothing landed for that card. + * + * `node scripts/pm/close-cards.mjs … > /tmp/cc.log 2>&1; EXIT=$?; tail -40 /tmp/cc.log` + */ + +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from '../invoked-as.mjs'; +import { + EXIT_PREREQUISITE_NOT_MET, + PM_EXCLUSIVE_STATE_LABELS, + PM_STATE_CLAIM, + PROXY_FLAG, + proxyRearmPlan, + proxyRoute, + resolveSweepRepo, +} from './check-half-states.mjs'; +import { classifyHttp, parseOptions as parseLabelWriteOptions, runLabelWrite } from './label-write.mjs'; +import { + EXIT_NOT_STORED as POST_STAMPED_EXIT_NOT_STORED, + STAMP_TOKEN, + claimKeyedLineRefusals, + keyedLineRefusalText, + renderBody, +} from './post-stamped.mjs'; + +const SELF_PATH = fileURLToPath(import.meta.url); +const POST_STAMPED_PATH = fileURLToPath(new URL('./post-stamped.mjs', import.meta.url)); +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +/** ⛔ THIS tool's own re-exec guard name (#18939): a sibling's inherited guard must never silence it. */ +const PROXY_REARM_GUARD = 'OS_CLOSE_CARDS_PROXY_REARMED'; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 2; +export const EXIT_PREREQUISITE = EXIT_PREREQUISITE_NOT_MET; +export const EXIT_HALF_WRITE = 4; +export const EXIT_PLATFORM_REFUSAL = 5; + +/** The `state_reason` values GitHub accepts on a close. ⛔ Never a free string: a typo closes 90 cards as the wrong kind. */ +export const CLOSE_REASONS = Object.freeze(['not_planned', 'completed', 'duplicate']); + +/** The annotation that says a summons is outstanding — a card carrying it is never swept. */ +export const RETRIAGE_LABEL = 'pm:retriage'; + +/** The three writes, in the order they are spent. Named once so the log, the refusal and the self-test read one list. */ +export const STEPS = Object.freeze(['comment', 'label', 'close']); + +const DEFAULT_EXPECT_STATE = 'pm:queue'; + +const render = (values) => (values.length ? values.map((v) => `\`${v}\``).join(', ') : 'none'); + +// --------------------------------------------------------------------------- +// The list file — pure, because a misread list is the whole act pointed at the +// wrong cards. +// --------------------------------------------------------------------------- + +/** + * Card numbers out of a list file: one per line, space-separated, or both, with + * or without the `#`. + * + * ⛔ A `#` cannot introduce a comment here — `#19440` IS the ordinary spelling + * of a card, and a parser that dropped those lines would silently close nothing + * and report a clean run. So every token must BE a card number: anything else + * is refused by name rather than skipped. + * + * Duplicates are collapsed (a card cannot be closed twice) and REPORTED, since + * a list carrying one is a list somebody built by hand from two sources. + */ +export function parseCardList(raw) { + const tokens = String(raw ?? '') + .split(/\s+/) + .filter((t) => t.length > 0); + if (tokens.length === 0) return { ok: false, error: 'the --list file names no cards at all. ⛔ An empty list is refused, never read as "nothing to do".' }; + const bad = tokens.filter((t) => !/^#?\d+$/.test(t)); + if (bad.length > 0) { + return { + ok: false, + error: + `the --list file carries ${bad.length} token(s) that are not card numbers: ${render(bad.slice(0, 5))}` + + `${bad.length > 5 ? ` (and ${bad.length - 5} more)` : ''}. A list is card numbers only — one per line or ` + + 'space-separated, `#19440` or `19440`. ⛔ Nothing here is treated as a comment: `#` opens a card number.', + }; + } + const numbers = []; + const duplicates = []; + for (const token of tokens) { + const n = Number(token.replace(/^#/, '')); + if (!Number.isInteger(n) || n <= 0) return { ok: false, error: `\`${token}\` is not a card number.` }; + if (numbers.includes(n)) duplicates.push(n); + else numbers.push(n); + } + return { ok: true, numbers, duplicates }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +/** + * Parse argv, or refuse. Pure — this is the layer that decides WHICH board and + * WHICH cards, and every refusal here is one the self-test pins offline. + * + * `--expect-state` is validated against the IMPORTED state vocabulary rather + * than accepted as a free string: a typo (`pm:queued`) would match no card, + * skip all 90, and print a confident run that did nothing — the #4690 shape. + */ +export function parseCliOptions(argv) { + const opts = { + repo: null, + list: null, + comment: null, + reason: null, + expectState: DEFAULT_EXPECT_STATE, + skipPrReferenced: true, + dryRun: false, + }; + const args = argv ?? []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const eq = arg.indexOf('='); + const flag = eq === -1 ? arg : arg.slice(0, eq); + const inline = eq === -1 ? null : arg.slice(eq + 1); + const value = () => (inline === null ? args[++i] : inline); + + if (flag === '--repo') opts.repo = value(); + else if (flag === '--list') opts.list = value(); + else if (flag === '--comment') opts.comment = value(); + else if (flag === '--reason') opts.reason = value(); + else if (flag === '--expect-state') opts.expectState = value(); + else if (flag === '--skip-pr-referenced') opts.skipPrReferenced = true; + else if (flag === '--no-skip-pr-referenced') opts.skipPrReferenced = false; + else if (flag === '--dry-run') opts.dryRun = true; + else return { ok: false, error: `unrecognised option \`${flag}\`` }; + } + + if (!opts.list) return { ok: false, error: '--list FILE is required — the card numbers to sweep, one per line or space-separated' }; + if (!opts.comment) return { ok: false, error: '--comment FILE is required — the closing comment posted onto every card this run closes' }; + if (!opts.reason) return { ok: false, error: `--reason is required — one of ${render(CLOSE_REASONS)}` }; + if (!CLOSE_REASONS.includes(opts.reason)) { + return { ok: false, error: `\`${opts.reason}\` is not a close reason. GitHub takes ${render(CLOSE_REASONS)}, and nothing else.` }; + } + if (typeof opts.expectState !== 'string' || opts.expectState.length === 0) { + return { ok: false, error: '--expect-state takes the pm-state label a card must carry, e.g. `pm:queue`' }; + } + if (!PM_EXCLUSIVE_STATE_LABELS.includes(opts.expectState)) { + return { + ok: false, + error: + `\`${opts.expectState}\` is not a pm-state label. The states are ${render(PM_EXCLUSIVE_STATE_LABELS)}. ` + + 'A typo here matches no card, skips every one of them, and prints a confident run that closed nothing.', + }; + } + + if (!opts.repo) opts.repo = resolveSweepRepo(process.env).repo; + if (!/^[\w.-]+\/[\w.-]+$/.test(String(opts.repo))) { + return { ok: false, error: `--repo must be owner/name, got \`${opts.repo}\`. ⛔ Refusing to fall back to another board.` }; + } + + return { ok: true, options: opts }; +} + +const USAGE = [ + 'close-cards — the three-step card closure (comment · label · close) as ONE named command.', + '', + ' node scripts/pm/close-cards.mjs --repo OWNER/NAME --list FILE --comment FILE --reason REASON', + ' [--expect-state pm:queue] [--no-skip-pr-referenced] [--dry-run]', + ' node scripts/pm/close-cards.mjs --self-test', + '', + ' ⛔ Invoke it from the REPO ROOT with nothing in front of `node` — no `cd … &&` compound. A session', + ' allow rule is a PREFIX match against the command as typed, so a leading `cd` matches no rule and', + ' drops the whole act onto the write classifier, which is the defect this script was written for.', + '', + ` --list FILE card numbers, one per line or space-separated, \`#19440\` or \`19440\`.`, + ' --comment FILE the closing comment, posted through post-stamped onto every card this run closes.', + ` It must carry \`${STAMP_TOKEN}\` — the clock each posting act reads.`, + ` --reason R ${CLOSE_REASONS.join(' | ')}.`, + ' --expect-state L the pm-state label a card must carry, EXACTLY and alone (default `pm:queue`).', + ' --no-skip-pr-referenced act on a card an open PR references (the skip is ON by default).', + ' --dry-run re-read every card and print the plan. Writes NOTHING, on any card.', + '', + ' Per card, re-read live first, then SKIP + log on: not open · has an assignee · carries', + ` \`${RETRIAGE_LABEL}\` · pm-state is not exactly the expected label · an open PR references it.`, + ' Otherwise: post the comment, remove the state label (four-step, read back), close with the reason.', + '', + ` Exits: ${EXIT_OK} every non-skipped card landed all three writes · ${EXIT_USAGE} usage ·`, + ` ${EXIT_PREREQUISITE} PREREQUISITE NOT MET, nothing measured · ${EXIT_HALF_WRITE} HALF-WRITE, a card is in a state`, + ` nobody asked for — it is NAMED · ${EXIT_PLATFORM_REFUSAL} the platform refused a write. Capture the code BEFORE any pipe.`, +].join('\n'); + +// --------------------------------------------------------------------------- +// Pre-flight — the closing comment is judged ONCE, here, by the tool that will +// post it, rather than ninety times one card at a time. +// --------------------------------------------------------------------------- + +/** + * Would post-stamped accept this body, and does it carry a stamp of the posting + * act's own? Pure: it drives post-stamped's exported `renderBody` and its + * keyed-line refusals, so this gate and that tool cannot come to disagree. + * + * The one requirement this adds on top: `substituted >= 1`. post-stamped + * accepts a body that spells no token at all (`no-token`), which is right for a + * one-off artefact and wrong for a batch closure — an unstamped closing comment + * repeated across ninety cards records none of their closing times. + */ +export function preflightComment(text, nowMs = Date.now()) { + const rendered = renderBody(text, nowMs); + if (!rendered.ok) return { ok: false, error: rendered.error, kind: rendered.kind }; + if (rendered.substituted < 1) { + return { + ok: false, + kind: 'unstamped-batch', + error: + `close-cards: REFUSED — the --comment file spells no \`${STAMP_TOKEN}\` outside a quotation, so every card ` + + 'in this run would receive a closing comment carrying no closing time. A batch closure is exactly the act ' + + `whose stamp cannot be recovered afterwards. Put \`${STAMP_TOKEN}\` where the closing time belongs.`, + }; + } + const keyed = claimKeyedLineRefusals(rendered.body); + if (keyed.length > 0) return { ok: false, kind: 'keyed-line', error: keyedLineRefusalText(keyed) }; + return { ok: true, rendered }; +} + +// --------------------------------------------------------------------------- +// The skip matrix — pure, so every row is driven offline. +// --------------------------------------------------------------------------- + +const labelNamesOf = (raw) => (Array.isArray(raw) ? raw : []).map((l) => (typeof l === 'string' ? l : l?.name)).filter(Boolean); +const loginsOf = (raw) => (Array.isArray(raw) ? raw : []).map((a) => (typeof a === 'string' ? a : a?.login)).filter(Boolean); + +/** + * Does an OPEN pull request reference this card, read off the timeline? + * + * A `cross-referenced` event's `source.issue` is a full issue object; the + * `pull_request` member is what makes it a PR rather than a card, and `state` + * is that PR's. ⛔ An event with no `source.issue` is not a silent no — it is + * simply not a cross-reference, and every other event type is ignored here. + */ +export function openPrReferences(timeline) { + const hits = []; + for (const event of Array.isArray(timeline) ? timeline : []) { + if (event?.event !== 'cross-referenced') continue; + const source = event?.source?.issue; + if (!source?.pull_request) continue; + if (source?.state !== 'open') continue; + hits.push(source.number); + } + return hits; +} + +/** + * Why this card is left alone, or `null` when it is actionable. The order is + * the order the order was written in, and the FIRST reason is the one reported: + * a closed card with an assignee is reported as closed, which is what a reader + * needs to know. + */ +export function skipReason(card, { expectState, openPrs = [] } = {}) { + if (card?.state !== 'open') return `not open (state ${card?.state ?? 'unknown'}${card?.state_reason ? `/${card.state_reason}` : ''})`; + const assignees = loginsOf(card?.assignees).concat(card?.assignee?.login ? [card.assignee.login] : []); + if (assignees.length > 0) return `has an assignee (${render([...new Set(assignees)])}) — somebody owns it`; + const labels = labelNamesOf(card?.labels); + if (labels.includes(RETRIAGE_LABEL)) return `carries \`${RETRIAGE_LABEL}\` — a summons is outstanding`; + const states = labels.filter((l) => PM_EXCLUSIVE_STATE_LABELS.includes(l)); + if (states.length === 0) return `carries no pm-state label, and this run acts only on \`${expectState}\``; + if (states.length > 1) return `carries ${states.length} pm-state labels (${render(states)}) — two claims, and this run acts only on exactly \`${expectState}\``; + if (states[0] !== expectState) { + return `pm-state is \`${states[0]}\` (${PM_STATE_CLAIM[states[0]] ?? 'a state this run does not act on'}), not \`${expectState}\``; + } + if (openPrs.length > 0) return `an open PR references it (${openPrs.map((n) => `#${n}`).join(', ')})`; + return null; +} + +// --------------------------------------------------------------------------- +// Transport — one shape for every call, so `classifyHttp` (imported, never +// re-derived) sees the same fields whatever went wrong. ⛔ Nothing here throws +// on an HTTP status: a refusal is an observation this tool routes on. +// --------------------------------------------------------------------------- + +async function rest(path, { method = 'GET', body = null } = {}) { + let res; + try { + res = await fetch(`${API}${path}`, { + method, + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch (e) { + return { status: 0, rateRemaining: null, json: null, detail: e?.message ?? 'fetch threw', call: `${method} ${path}` }; + } + const rateRemaining = res.headers.get('x-ratelimit-remaining'); + let json = null; + if (res.status !== 204) { + try { + json = await res.json(); + } catch { + json = null; + } + } + return { + status: res.status, + rateRemaining: rateRemaining === null ? null : Number(rateRemaining), + json, + detail: typeof json?.message === 'string' ? json.message : '', + call: `${method} ${path}`, + }; +} + +/** + * The node flags a freshly spawned sibling tool needs to reach GitHub from + * here. Pure. + * + * A child spawned as `node