Skip to content

Commit c16c00f

Browse files
fix(pm): fleet-roster and mailbox-open page their reads in the script, not through gh --paginate --slurp (#26006)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D6hfpTrgtTBiyXscg9Lfr1f)_ Fixes #25962 Clause-②: no Metadata-protocol: none ## What `scripts/pm/fleet-roster.mjs` and `scripts/pm/mailbox-open.mjs` read lists through `gh api --paginate --slurp`, which the built-in gh of cloud sessions refuses. The fleet roster (#25462) could not be regenerated from a cloud seat. - New `scripts/pm/gh-pages.mjs`: `readAllPages(path)` forces `per_page=100`, walks `page=1, 2, …` with plain `gh api <path>` until a short page, and returns an error (never a partial list) on a failed page, a non-list page, or more than 50 full pages. No `Link` header, the same rule as `board-snapshot.mjs`. - Both scripts import it; `ghJson()` keeps its call shape, so no caller changes. - Each `--self-test` gains the battery "paging: a three-page read through the loop equals the slurped array" (4 cases: 100+100+37 rows equal the slurped array flattened; the pages asked and kept query keys; an exact multiple stops after one empty page; a failed or non-list page is an error). Battery floor 7 → 8 in both. No change to what the roster shows or its columns. No relay change. Publishes nothing, so `skip-changeset`. ## Evidence - `node scripts/pm/fleet-roster.mjs --self-test` → 73 cases in 8 batteries pass; `node scripts/pm/mailbox-open.mjs --self-test` → 44 cases in 8 batteries pass. - With a fake `gh` first on PATH that refuses `--paginate` and `--slurp` and forwards everything else to the real CLI: the base commit exits 3 (`gh: --paginate is not supported…`), this branch exits 0 and its `--json` rows (22) equal the base commit's rows read with the real gh. - `readAllPages` on `/repos/objectstack-ai/objectstack/pulls?state=open` under the fake gh: 20 rows, the same count as `gh api --paginate --slurp`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Lv8mtVfg8BPpU5Zkh3xGj8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88531fa commit c16c00f

3 files changed

Lines changed: 109 additions & 20 deletions

File tree

‎scripts/pm/fleet-roster.mjs‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ import { fileURLToPath } from 'node:url';
8686

8787
import { isEntrypoint } from '../invoked-as.mjs';
8888
import { validateStroke } from './fleet-write/validate.mjs';
89+
import { ghApi, PER_PAGE, readAllPages } from './gh-pages.mjs';
8990
// The mailbox label, its ledger exclusion and its branch prefix have ONE owner,
9091
// the mailbox opener (#25417); this file reads them, never re-spells them.
9192
import * as mailboxOpen from './mailbox-open.mjs';
@@ -389,18 +390,11 @@ export function planWrite({ board, issue, body }) {
389390
}
390391

391392
// ---------------------------------------------------------------------------
392-
// The live edges — `gh api` reads, the relay
393+
// The live edges — `gh api` reads (paged by `gh-pages.mjs`), the relay
393394
// ---------------------------------------------------------------------------
394395

395396
function ghJson(path, { paginate = false } = {}) {
396-
const r = spawnSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path], { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
397-
if (r.status !== 0) return { ok: false, error: (r.stderr || r.error?.message || `gh exited ${r.status}`).trim() };
398-
try {
399-
const parsed = JSON.parse(r.stdout);
400-
return { ok: true, value: paginate ? parsed.flat() : parsed };
401-
} catch (e) {
402-
return { ok: false, error: `unparseable answer from GET ${path}: ${e.message}` };
403-
}
397+
return paginate ? readAllPages(path) : ghApi(path);
404398
}
405399

406400
/** Read every repo into the snapshot shape `buildRows` takes; throws on the first failed read. */
@@ -528,8 +522,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
528522
'the rendered body: marker, columns, footer, determinism': 8,
529523
'refusing to overwrite what is not the roster': 6,
530524
'the CLI: fixture runs offline, usage errors are exit 2': 5,
525+
'paging: a three-page read through the loop equals the slurped array': 4,
531526
});
532-
const SELF_TEST_BATTERY_FLOOR = 7;
527+
const SELF_TEST_BATTERY_FLOOR = 8;
533528

534529
async function selfTest() {
535530
let failed = 0;
@@ -652,6 +647,23 @@ async function selfTest() {
652647
return [p.actions.length, p.actions[0].op, Object.keys(p.actions[0]).sort(), p.errors];
653648
})(), [1, 'issue_patch', ['body', 'issue', 'op'], []]);
654649

650+
battery('paging: a three-page read through the loop equals the slurped array');
651+
{
652+
const slurped = [0, 1, 2].map((n) => Array.from({ length: n < 2 ? PER_PAGE : 37 }, (_, i) => ({ number: n * PER_PAGE + i + 1 })));
653+
const asked = [];
654+
const get = (path) => {
655+
asked.push(path);
656+
const page = Number(new URLSearchParams(path.split('?')[1]).get('page'));
657+
return { ok: true, value: slurped[page - 1] ?? [] };
658+
};
659+
const read = readAllPages('/repos/o/r/issues?state=open&labels=pm%3Aseat&per_page=100', { get });
660+
t('three pages read = the --slurp array, flattened', [read.ok, read.value], [true, slurped.flat()]);
661+
t('pages 1..3 asked, per_page kept once, other keys kept, stop at the short page', asked, [1, 2, 3].map((n) => `/repos/o/r/issues?state=open&labels=pm%3Aseat&per_page=100&page=${n}`));
662+
const exact = [];
663+
t('an exact multiple of per_page reads one empty page and stops', [readAllPages('/x', { get: (p) => (exact.push(p), { ok: true, value: exact.length === 1 ? slurped[0] : [] }) }).value.length, exact.length], [PER_PAGE, 2]);
664+
t('a failed page or a non-list page is an error, never a partial list', [readAllPages('/x', { get: () => ({ ok: false, error: 'boom' }) }), readAllPages('/x', { get: () => ({ ok: true, value: { message: 'x' } }) }).ok], [{ ok: false, error: 'boom' }, false]);
665+
}
666+
655667
battery('the CLI: fixture runs offline, usage errors are exit 2');
656668
const cli = (args) => spawnSync(process.execPath, [SELF_PATH, ...args], { encoding: 'utf8', env: { ...process.env, PATH: '/nonexistent', OS_FLEET_SESSION: '', CLAUDE_CODE_REMOTE_SESSION_ID: '' } });
657669
const jsonRun = cli(['--fixture', FIXTURE_PATH, '--json']);

‎scripts/pm/gh-pages.mjs‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* gh-pages — every page of one paginated REST read, walked by `page=N` until a
5+
* short page comes back (#25962). Shared by `fleet-roster.mjs` and
6+
* `mailbox-open.mjs`.
7+
*
8+
* ## Why not `gh api --paginate --slurp`
9+
*
10+
* The built-in gh of cloud sessions answers `--slurp is not supported by this
11+
* built-in gh.`, and the fleet runs in cloud sessions — so a reader that leans
12+
* on that flag is a reader no seat can run. Plain `gh api <path>` works under
13+
* both the real CLI and the built-in one, so the loop lives here instead.
14+
*
15+
* ⛔ No `Link` header: a page shorter than `per_page` is the last one, the same
16+
* rule as `board-snapshot.mjs`'s `readAllPages`. An exact multiple of
17+
* `per_page` costs one extra, empty read.
18+
*
19+
* A library, not an instrument: no CLI and no `--self-test` of its own. Both
20+
* importers' self-tests read a three-page fixture through `readAllPages` with
21+
* an injected `get`, so the loop is tested offline twice.
22+
*/
23+
24+
import { spawnSync } from 'node:child_process';
25+
26+
export const PER_PAGE = 100;
27+
/** A runaway guard, not a budget: 50 pages × 100 is far past any read these scripts make. */
28+
export const MAX_PAGES = 50;
29+
30+
/** `path` with `per_page` forced to `PER_PAGE` and `page` set to `page`; every other query key kept in order. */
31+
export function pagePath(path, page) {
32+
const [base, query = ''] = String(path).split('?', 2);
33+
const keep = query.split('&').filter((kv) => kv && !/^(?:per_page|page)=/.test(kv));
34+
return `${base}?${[...keep, `per_page=${PER_PAGE}`, `page=${page}`].join('&')}`;
35+
}
36+
37+
/** One `gh api <path>` read, parsed. `{ ok: true, value }` or `{ ok: false, error }` — never throws. */
38+
export function ghApi(path, { maxBuffer = 256 * 1024 * 1024 } = {}) {
39+
const r = spawnSync('gh', ['api', path], { encoding: 'utf8', maxBuffer });
40+
if (r.status !== 0) return { ok: false, error: (r.stderr || r.error?.message || `gh exited ${r.status}`).trim() };
41+
try {
42+
return { ok: true, value: JSON.parse(r.stdout) };
43+
} catch (e) {
44+
return { ok: false, error: `unparseable answer from GET ${path}: ${e.message}` };
45+
}
46+
}
47+
48+
/**
49+
* Every row of a paginated list read: `page=1, 2, …` until a page shorter than
50+
* `PER_PAGE`. Same answer shape as `ghApi`. A page that is not an array, a
51+
* failed page, or a read past `MAX_PAGES` is an error — ⛔ never a partial list
52+
* returned as if whole.
53+
*/
54+
export function readAllPages(path, { get = ghApi } = {}) {
55+
const rows = [];
56+
for (let page = 1; page <= MAX_PAGES; page++) {
57+
const p = pagePath(path, page);
58+
const r = get(p);
59+
if (!r.ok) return r;
60+
if (!Array.isArray(r.value)) return { ok: false, error: `GET ${p} answered ${r.value === null ? 'null' : typeof r.value}, not a list page` };
61+
rows.push(...r.value);
62+
if (r.value.length < PER_PAGE) return { ok: true, value: rows };
63+
}
64+
return { ok: false, error: `GET ${path} still returned full pages after ${MAX_PAGES}; refusing a partial read` };
65+
}

‎scripts/pm/mailbox-open.mjs‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import { fileURLToPath } from 'node:url';
8888

8989
import { isEntrypoint } from '../invoked-as.mjs';
9090
import { validateStroke } from './fleet-write/validate.mjs';
91+
import { ghApi, PER_PAGE, readAllPages } from './gh-pages.mjs';
9192

9293
const SELF_PATH = fileURLToPath(import.meta.url);
9394
const PM_DIR = dirname(SELF_PATH);
@@ -232,18 +233,11 @@ export function planText(plan) {
232233
}
233234

234235
// ---------------------------------------------------------------------------
235-
// The live edges — `gh api` reads, `git`, with-fleet, the relay
236+
// The live edges — `gh api` reads (paged by `gh-pages.mjs`), `git`, with-fleet, the relay
236237
// ---------------------------------------------------------------------------
237238

238239
function ghJson(path, { paginate = false } = {}) {
239-
const r = spawnSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path], { encoding: 'utf8' });
240-
if (r.status !== 0) return { ok: false, error: (r.stderr || r.error?.message || `gh exited ${r.status}`).trim() };
241-
try {
242-
const parsed = JSON.parse(r.stdout);
243-
return { ok: true, value: paginate ? parsed.flat() : parsed };
244-
} catch (e) {
245-
return { ok: false, error: `unparseable answer from GET ${path}: ${e.message}` };
246-
}
240+
return paginate ? readAllPages(path) : ghApi(path);
247241
}
248242

249243
function run(cmd, args) {
@@ -371,8 +365,9 @@ const SELF_TEST_BATTERIES = Object.freeze({
371365
'refusing to close what is not an open mailbox': 4,
372366
'the ledger exclusion and the footer': 4,
373367
'the CLI: dry run prints the actions file and sends nothing': 5,
368+
'paging: a three-page read through the loop equals the slurped array': 4,
374369
});
375-
const SELF_TEST_BATTERY_FLOOR = 7;
370+
const SELF_TEST_BATTERY_FLOOR = 8;
376371

377372
async function selfTest() {
378373
let failed = 0;
@@ -443,6 +438,23 @@ async function selfTest() {
443438
t('a session_ id is kept', bodyFooter('session_01XYZ').includes('/code/session_01XYZ)'), true);
444439
t('no or a malformed session falls back to the bare footer', [bodyFooter(null), bodyFooter('cse_')].every((f) => f.endsWith('(https://claude.ai/code)_')), true);
445440

441+
battery('paging: a three-page read through the loop equals the slurped array');
442+
{
443+
const slurped = [0, 1, 2].map((n) => Array.from({ length: n < 2 ? PER_PAGE : 37 }, (_, i) => ({ number: n * PER_PAGE + i + 1 })));
444+
const asked = [];
445+
const get = (path) => {
446+
asked.push(path);
447+
const page = Number(new URLSearchParams(path.split('?')[1]).get('page'));
448+
return { ok: true, value: slurped[page - 1] ?? [] };
449+
};
450+
const read = readAllPages('/repos/o/r/issues?state=open&labels=pm%3Aseat&per_page=100', { get });
451+
t('three pages read = the --slurp array, flattened', [read.ok, read.value], [true, slurped.flat()]);
452+
t('pages 1..3 asked, per_page kept once, other keys kept, stop at the short page', asked, [1, 2, 3].map((n) => `/repos/o/r/issues?state=open&labels=pm%3Aseat&per_page=100&page=${n}`));
453+
const exact = [];
454+
t('an exact multiple of per_page reads one empty page and stops', [readAllPages('/x', { get: (p) => (exact.push(p), { ok: true, value: exact.length === 1 ? slurped[0] : [] }) }).value.length, exact.length], [PER_PAGE, 2]);
455+
t('a failed page or a non-list page is an error, never a partial list', [readAllPages('/x', { get: () => ({ ok: false, error: 'boom' }) }), readAllPages('/x', { get: () => ({ ok: true, value: { message: 'x' } }) }).ok], [{ ok: false, error: 'boom' }, false]);
456+
}
457+
446458
battery('the CLI: dry run prints the actions file and sends nothing');
447459
const cli = (args) => spawnSync(process.execPath, [SELF_PATH, ...args], { encoding: 'utf8', env: { ...process.env, PATH: '/nonexistent', OS_FLEET_SESSION: '', CLAUDE_CODE_REMOTE_SESSION_ID: '' } });
448460
const dry = cli(['--repo', 'objectstack-ai/objectstack', '--login', 'os-bill', '--dry-run']);

0 commit comments

Comments
 (0)