From 9efc9fd11c03fbbd28a8973726ec3cfb7a33d109 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:35:14 +0000 Subject: [PATCH 1/6] wip(pm): fleet-write transfer row, stroke rules and the two-repository token list Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- .github/workflows/fleet-write.yml | 22 +++-- scripts/pm/fleet-write/ops.mjs | 69 +++++++++++++- scripts/pm/fleet-write/validate.mjs | 134 ++++++++++++++++++++++++++-- 3 files changed, 207 insertions(+), 18 deletions(-) diff --git a/.github/workflows/fleet-write.yml b/.github/workflows/fleet-write.yml index 0536bada83c..0e391fd661b 100644 --- a/.github/workflows/fleet-write.yml +++ b/.github/workflows/fleet-write.yml @@ -16,8 +16,9 @@ # need in front. # # ⛔ The token this run WRITES with is the App installation token step 3 -# mints — narrowed to the one target repository and to the permissions the -# op table declares (`issues: write`, `pull-requests: write`, the +# mints — narrowed to the payload's repository (and, for a `transfer` stroke +# alone, that transfer's one target too: `transferIssue` needs issues write on +# both ends) and to the permissions the op table declares (`issues: write`, `pull-requests: write`, the # `contents: write` GitHub's auto-merge mutations require, and the # `metadata: read` the sender gate's permission read needs) — and revoked by # that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing @@ -82,7 +83,8 @@ jobs: # unknown key, an unknown op, a target outside the organization, a # non-integer number, an over-cap string or an over-cap payload with # every reason in the step summary and zero writes; on success it hands - # the target's NAME to the mint below through `$GITHUB_OUTPUT`. + # the mint below the repository NAMES its token must reach through + # `$GITHUB_OUTPUT` — one for every stroke, two for a transfer stroke. - name: Validate the payload (closed schema, zero writes on refusal) id: validate env: @@ -92,10 +94,14 @@ jobs: # The fleet identity, minted on the runner and never held by a seat: # `OS_FLEET_APP_ID` is an organization variable, `OS_FLEET_PRIVATE_KEY` # an organization secret (both set by the maintainer). `repositories` - # narrows the token to the ONE repository the payload named — the - # validator's output, never the payload read again here — and the - # `permission-*` inputs narrow it to what `scripts/pm/fleet-write/ops.mjs` - # declares. The action revokes the token in its post step. + # narrows the token to the list the validator computed — the payload's + # ONE repository, or for a `transfer` stroke that repository plus the + # transfer's one target from the governed roster — the validator's + # output, never the payload read again here; its `--self-test` pins one + # name for every other op. The `permission-*` inputs narrow it to what + # `scripts/pm/fleet-write/ops.mjs` declares. A target the App's + # installation does not cover fails THIS step, loudly, with zero writes. + # The action revokes the token in its post step. - name: Mint the fleet App token, narrowed to the target and the op table id: token uses: actions/create-github-app-token@v2 @@ -103,7 +109,7 @@ jobs: app-id: ${{ vars.OS_FLEET_APP_ID }} private-key: ${{ secrets.OS_FLEET_PRIVATE_KEY }} owner: objectstack-ai - repositories: ${{ steps.validate.outputs.repo_name }} + repositories: ${{ steps.validate.outputs.repositories }} permission-issues: write permission-pull-requests: write # ONE consumer: GitHub's `enablePullRequestAutoMerge` / `disablePullRequestAutoMerge` diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 901174e0aa2..8a62bb03b50 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -51,6 +51,24 @@ * declares exactly it on `actions/create-github-app-token`, and * `validate.mjs --self-test` pins the two spellings equal. * + * ## The one row whose token reaches a SECOND repository — `transfer` + * + * GitHub's `transferIssue` mutation (GraphQL only; REST has no transfer + * endpoint) needs `issues: write` on the repository the card is in AND on the + * one it moves to, so a transfer stroke's token is minted for both. That is + * the only widening the relay makes, and it is fenced four ways: the target is + * a closed enum — `TRANSFER_TARGETS`, the fleet's governed-repository roster + * reused from `check-governed-merges.mjs`, never a second roster and never + * free text; the target is never the source; a stroke carrying a transfer + * carries transfers alone, to ONE target; and the executor gates the sender on + * BOTH repositories before the first write. Every other op keeps the + * single-repository token — `validate.mjs` computes the list the workflow + * mints for (`tokenRepositoriesOf`) and its `--self-test` pins both halves. + * The mutation never sets `createLabelsIfMissing` (GitHub's default is false): + * a label with no same-named label on the target is dropped, never created + * there. Comments and assignees travel with the card and the old URL + * redirects — GitHub's documented transfer semantics. + * * ## The platform's `client_payload` ceilings — pinned, with their source * * "The maximum number of top-level properties is 10. The total size of the @@ -64,6 +82,8 @@ * reject with a 422 is refused locally with the reason. */ +import { GOVERNED_REPOS } from '../check-governed-merges.mjs'; + /** * The relay's four files, repo-relative — the population a card touching any * of them implicates (the dispatch derivation reads these literals as path @@ -86,6 +106,14 @@ export const RELAY_EVENT_TYPE = 'fleet-write'; /** The one organization the App is installed on; a target outside it is refused. */ export const TARGET_OWNER = 'objectstack-ai'; +/** + * The repositories a `transfer` may move a card to — the fleet's governed + * roster, reused, so a repository joins the fleet in ONE place and becomes a + * transfer target there. `validate.mjs --self-test` pins that every entry is + * in `TARGET_OWNER`. + */ +export const TRANSFER_TARGETS = Object.freeze(GOVERNED_REPOS.map((r) => r.slug)); + /** The seat-side transport selector and its three values. */ export const TRANSPORT_ENV = 'OS_FLEET_TRANSPORT'; export const TRANSPORTS = Object.freeze(['direct', 'dispatch', 'auto']); @@ -158,6 +186,7 @@ export const FIELDS = Object.freeze({ team_reviewers: Object.freeze({ kind: 'list', max: MAX_LOGINS_PER_ACTION, itemMax: MAX_LOGIN_CHARS }), state: Object.freeze({ kind: 'enum', values: Object.freeze(['open', 'closed']) }), state_reason: Object.freeze({ kind: 'enum', values: Object.freeze(['completed', 'not_planned', 'duplicate', 'reopened']) }), + target_repo: Object.freeze({ kind: 'enum', values: TRANSFER_TARGETS }), }); /** @@ -184,16 +213,41 @@ const PR_MUTATIONS = Object.freeze({ automerge_disable: 'mutation($id: ID!) { disablePullRequestAutoMerge(input: { pullRequestId: $id }) { pullRequest { number autoMergeRequest { enabledAt } } } }', }); +/** + * The transfer mutation. The executor resolves both node ids first — + * `GET /repos/{repo}/issues/{n}` (refusing a pull request, or a card no longer + * on `repo`) and `GET /repos/{target}` — then sends + * `{ query, variables: { issueId, repositoryId } }`. `createLabelsIfMissing` + * is deliberately absent: GitHub's default (false) drops a label the target + * does not carry instead of creating it there. + */ +const TRANSFER_MUTATION = 'mutation($issueId: ID!, $repositoryId: ID!) { transferIssue(input: { issueId: $issueId, repositoryId: $repositoryId }) { issue { number url repository { nameWithOwner } } } }'; + +/** + * What a seat reads when a transfer fails at the mint or at the mutation — + * spelled once, printed by the executor's summary and by the door. + */ +export function transferRemedy(source, target) { + return ( + `a transfer needs the fleet App installed on BOTH ${source} and ${target} with issues write on each: a failed mint, a target whose ` + + `node id cannot be read, or "Resource not accessible by integration" / "Could not resolve to a Repository" on the mutation means ` + + `the installation does not cover ${target} — the maintainer adds it to the objectstack-fleet App's repository access. ` + + '⛔ Not rebuilt by hand meanwhile: a rebuild is only for a target GitHub refuses (outside the organization).' + ); +} + /** * The closed op list. Each row: * `permission` the App permission the row spends (a key of `PERMISSIONS`); * `required` keys that must be present; * `optional` keys that may be; anything else on the action is refused; * `atLeastOne` (optional) of these keys, at least one must be present; + * `secondRepo` (optional) the key naming a SECOND repository the token + * must reach — `transfer`'s alone (header above); * `requests` the request descriptors the executor issues, in order: * `{ verb, path, body?, idempotent404?, graphql? }` — a - * `graphql` descriptor names the mutation and the pull whose - * node id it needs. + * `graphql` descriptor names the mutation and the pull (or + * the issue and target) whose node ids it needs. */ export const OPS = Object.freeze({ comment: Object.freeze({ @@ -301,6 +355,15 @@ export const OPS = Object.freeze({ optional: Object.freeze([]), requests: (a) => [{ verb: 'POST', path: '/graphql', graphql: { mutation: 'disablePullRequestAutoMerge', query: PR_MUTATIONS.automerge_disable, pull: a.pull } }], }), + // The one row whose token reaches a second repository — see the header. + // `issue`, never `pull`: a pull request does not transfer. + transfer: Object.freeze({ + permission: 'issues', + required: Object.freeze(['issue', 'target_repo']), + optional: Object.freeze([]), + secondRepo: 'target_repo', + requests: (a) => [{ verb: 'POST', path: '/graphql', graphql: { mutation: 'transferIssue', query: TRANSFER_MUTATION, issue: a.issue, target_repo: a.target_repo } }], + }), }); export const OP_NAMES = Object.freeze(Object.keys(OPS)); @@ -326,4 +389,4 @@ export const REFUSED_PATH_FAMILIES = Object.freeze([ ]); /** The GraphQL mutations the table may name — anything else in a `graphql` descriptor is a bug this pins. */ -export const ALLOWED_MUTATIONS = Object.freeze(['markPullRequestReadyForReview', 'convertPullRequestToDraft', 'enablePullRequestAutoMerge', 'disablePullRequestAutoMerge']); +export const ALLOWED_MUTATIONS = Object.freeze(['markPullRequestReadyForReview', 'convertPullRequestToDraft', 'enablePullRequestAutoMerge', 'disablePullRequestAutoMerge', 'transferIssue']); diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index 9fed2838c52..4080a80e4bd 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -7,7 +7,7 @@ * * node scripts/pm/fleet-write/validate.mjs --file payload.json # judge a payload on disk * node scripts/pm/fleet-write/validate.mjs --from-env # judge $FLEET_WRITE_PAYLOAD (the runner) - * node scripts/pm/fleet-write/validate.mjs --from-env --github-output # …and write repo_name / request_id / action_count to $GITHUB_OUTPUT + * node scripts/pm/fleet-write/validate.mjs --from-env --github-output # …and write repositories / request_id / action_count to $GITHUB_OUTPUT * node scripts/pm/fleet-write/validate.mjs --self-test # offline, no network * * ## One validator, imported by both ends @@ -32,7 +32,20 @@ * capped in length and per item; an enum is one of its values; * - the whole payload respects the platform's own `client_payload` limits * (ten top-level properties, under 64KB — the header of `ops.mjs` quotes - * the source), so what this file accepts the platform accepts. + * the source), so what this file accepts the platform accepts; + * - a stroke carrying a `transfer` carries transfers alone, to ONE target + * from `TRANSFER_TARGETS` that is not `repo`, each card once — so the + * token list below is `repo` alone for every other stroke and exactly + * `repo` + that target for a transfer stroke. + * + * ## The token list — the one place a second repository enters + * + * `--github-output` writes `repositories=[,]`, the list the + * workflow's mint step narrows the App token to (`tokenRepositoriesOf`): the + * payload's repository, plus the `secondRepo` a row declares — `transfer`'s + * target, the only such row. The workflow reads that output and never the + * payload, and `--self-test` pins the list to ONE name for every other op, + * two for a transfer, and the workflow line that reads it. * * A refusal is the WHOLE list of reasons, not the first one: a seat fixing a * payload reads every problem in one pass, and the runner's step summary @@ -52,6 +65,7 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../../invoked-as.mjs'; +import { GOVERNED_REPOS } from '../check-governed-merges.mjs'; import { ALLOWED_MUTATIONS, CLIENT_PAYLOAD_MAX_BYTES, @@ -71,6 +85,7 @@ import { SESSION_SHAPE, TARGET_OWNER, TARGET_REPO_SHAPE, + TRANSFER_TARGETS, } from './ops.mjs'; const SELF_PATH = fileURLToPath(import.meta.url); @@ -146,6 +161,57 @@ export function validateAction(action, index) { return errors; } +/** + * The rules that span actions — today, the one row whose token reaches a + * second repository (`secondRepo`, `transfer`'s alone): such a stroke carries + * that op alone, names ONE second repository, never `repo` itself, and each + * card once. Runs only over actions that passed `validateAction`. Pure. + */ +export function strokeErrors(repo, actions) { + const errors = []; + const widening = actions.map((a, i) => ({ a, i })).filter(({ a }) => OPS[a.op].secondRepo); + if (widening.length === 0) return errors; + const op = widening[0].a.op; + const key = OPS[op].secondRepo; + for (const [i, a] of actions.entries()) { + if (a.op !== op) errors.push(`actions[${i}] is \`${a.op}\` in a stroke that carries a \`${op}\` — its token reaches a second repository, so that stroke carries \`${op}\` alone; send \`${a.op}\` in its own dispatch`); + } + const seconds = [...new Set(widening.map(({ a }) => a[key]))]; + if (seconds.length > 1) errors.push(`the \`${op}\` actions name ${seconds.length} ${key} values (${seconds.join(', ')}) — one stroke reaches ONE second repository; split them into one dispatch per ${key}`); + for (const { a, i } of widening) { + if (a[key] === repo) errors.push(`actions[${i}].${key} is ${repo}, the stroke's own repository — a \`${op}\` moves a card to ANOTHER repository`); + } + const seen = new Set(); + for (const { a, i } of widening) { + if (seen.has(a.issue)) errors.push(`actions[${i}].issue #${a.issue} is named twice in one \`${op}\` stroke`); + seen.add(a.issue); + } + return errors; +} + +/** + * Every action, then the rules that span them — the stroke without its + * envelope, for a door that plans one before it knows its transport. + */ +export function validateStroke(repo, actions) { + const errors = actions.flatMap((a, i) => validateAction(a, i)); + return errors.length ? errors : strokeErrors(repo, actions); +} + +/** + * The repositories the relay's token must reach for a JUDGED payload: its + * `repo`, then the one second repository a `secondRepo` row names. One entry + * for every stroke but a transfer; two for a transfer stroke. + */ +export function tokenRepositoriesOf(payload) { + const out = [payload.repo]; + for (const a of payload.actions) { + const key = OPS[a.op].secondRepo; + if (key && !out.includes(a[key])) out.push(a[key]); + } + return out; +} + /** * The whole payload. Pure. Returns `{ ok, errors, payload }` where `payload` * is a normalised copy carrying ONLY the keys this file admitted (so a caller @@ -184,7 +250,7 @@ export function validatePayload(input) { if (!Array.isArray(actions)) errors.push(`actions must be an array, got ${actions === undefined ? 'nothing' : isPlainObject(actions) ? 'an object' : typeof actions}`); else if (actions.length === 0) errors.push('actions must carry at least one action'); else if (actions.length > MAX_ACTIONS) errors.push(`actions carries ${actions.length} actions, over the cap of ${MAX_ACTIONS}`); - else for (let i = 0; i < actions.length; i++) errors.push(...validateAction(actions[i], i)); + else errors.push(...validateStroke(typeof repo === 'string' ? repo : '', actions)); if (errors.length) return { ok: false, errors, payload: null }; @@ -261,7 +327,8 @@ export function readPayloadSource(opts, { env = process.env, read = (p) => readF /** The `$GITHUB_OUTPUT` lines the workflow's later steps read. Values are single-line by construction of the validator. */ export function githubOutputLines(payload) { - return [`repo_name=${payload.repo.split('/')[1]}`, `request_id=${payload.request_id}`, `action_count=${payload.actions.length}`, `session=${payload.session}`].join('\n') + '\n'; + const repositories = tokenRepositoriesOf(payload).map((r) => r.split('/')[1]).join(','); + return [`repositories=${repositories}`, `request_id=${payload.request_id}`, `action_count=${payload.actions.length}`, `session=${payload.session}`].join('\n') + '\n'; } const USAGE = [ @@ -328,8 +395,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 14, 'the normalised payload: only judged keys travel': 3, 'the CLI: a file, the environment, GitHub outputs, and the exit ladder': 10, + 'the transfer row: an issue never a pull, a target from the governed roster never the source, a stroke of its own to one target, labels never created': 12, + 'the token scope: ONE repository for every op but transfer, source + target for a transfer stroke, and the workflow mints from that list': 8, }); -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 9; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -441,6 +510,7 @@ export async function selfTest() { pr_draft: { pull: 1 }, automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, + transfer: { issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, }; t('every op in the table has a minimal shape this validator accepts', OP_NAMES.filter((op) => !validatePayload(one({ op, ...minimal[op] })).ok), []); t('…and the minimal-shape ledger names every op, no more and no less', Object.keys(minimal).sort(), [...OP_NAMES].sort()); @@ -464,7 +534,7 @@ export async function selfTest() { battery('refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint'); { const sample = { - issue: 7, pull: 7, comment_id: 7, body: 'b', title: 't', head: 'h', base: 'main', labels: ['a b'], assignees: ['u'], reviewers: ['u'], team_reviewers: ['t'], state: 'closed', state_reason: 'completed', + issue: 7, pull: 7, comment_id: 7, body: 'b', title: 't', head: 'h', base: 'main', labels: ['a b'], assignees: ['u'], reviewers: ['u'], team_reviewers: ['t'], state: 'closed', state_reason: 'completed', target_repo: `${TARGET_OWNER}/objectui`, }; const everyRequest = OP_NAMES.flatMap((op) => { const spec = OPS[op]; @@ -534,11 +604,61 @@ export async function selfTest() { t('a file that is not JSON is exit 3', (await drive(['--file', 'notjson.json'])).code, EXIT_PREREQUISITE); t('--from-env with the variable absent is exit 3 naming the variable', (await drive(['--from-env'], {})).err.includes(PAYLOAD_ENV) && (await drive(['--from-env'], {})).code === EXIT_PREREQUISITE); const env = await drive(['--from-env', '--github-output'], { [PAYLOAD_ENV]: good, GITHUB_OUTPUT: '/out.txt' }); - t('--from-env --github-output writes repo_name, request_id, action_count and session to $GITHUB_OUTPUT', [env.code, env.appended[0]?.file, env.appended[0]?.text], [EXIT_OK, '/out.txt', 'repo_name=objectstack\nrequest_id=fw-20260922T090000Z-abc123\naction_count=1\nsession=session_01ABCDEFGHJKMNPQRSTVWXYZ\n']); + t('--from-env --github-output writes repositories, request_id, action_count and session to $GITHUB_OUTPUT', [env.code, env.appended[0]?.file, env.appended[0]?.text], [EXIT_OK, '/out.txt', 'repositories=objectstack\nrequest_id=fw-20260922T090000Z-abc123\naction_count=1\nsession=session_01ABCDEFGHJKMNPQRSTVWXYZ\n']); t('--github-output without GITHUB_OUTPUT is exit 3', (await drive(['--from-env', '--github-output'], { [PAYLOAD_ENV]: good })).code, EXIT_PREREQUISITE); t('--json prints the normalised payload as one line after the OK line', JSON.parse((await drive(['--file', 'good.json', '--json'])).out.split('\n')[1]).request_id, 'fw-20260922T090000Z-abc123'); } + // ── the transfer row ────────────────────────────────────────────────────── + battery('the transfer row: an issue never a pull, a target from the governed roster never the source, a stroke of its own to one target, labels never created'); + { + const UI = `${TARGET_OWNER}/objectui`; + const move = (issue, target = UI) => ({ op: 'transfer', issue, target_repo: target }); + const spec = OPS.transfer; + t('the transfer row: issue and target_repo required, nothing optional, spending issues, reaching a second repository through target_repo', [spec.required, spec.optional, spec.permission, spec.secondRepo], [['issue', 'target_repo'], [], 'issues', 'target_repo']); + const req = spec.requests(move(7), 'objectstack-ai/objectstack'); + t('…and its one request is the transferIssue mutation, carrying the issue and the target the executor resolves to node ids', [req.length, req[0].verb, req[0].path, req[0].graphql.mutation, req[0].graphql.issue, req[0].graphql.target_repo], [1, 'POST', '/graphql', 'transferIssue', 7, UI]); + t('the query takes both node ids as variables and asks for the new number, url and repository', ['$issueId: ID!', '$repositoryId: ID!', 'issueId: $issueId', 'repositoryId: $repositoryId', 'number url repository { nameWithOwner }'].every((needle) => req[0].graphql.query.includes(needle))); + t('⛔ the mutation never sets createLabelsIfMissing — a label the target lacks is dropped, never created there', req[0].graphql.query.includes('createLabelsIfMissing'), false); + t('the target roster IS the governed-repository roster — reused, never a second list', [...TRANSFER_TARGETS], GOVERNED_REPOS.map((r) => r.slug)); + t('…every entry of which is in the one organization', TRANSFER_TARGETS.every((r) => TARGET_REPO_SHAPE.test(r)) && TRANSFER_TARGETS.length >= 2); + t('a target outside the roster is refused, naming the roster', refuses(fixturePayload({ actions: [move(7, `${TARGET_OWNER}/not-a-fleet-repo`)] }), `actions[0].target_repo must be one of "${TRANSFER_TARGETS[0]}"`)); + t('…and so is one in another organization', refuses(fixturePayload({ actions: [move(7, 'someone-else/objectui')] }), 'target_repo must be one of')); + t('the source itself is refused as a target', refuses(fixturePayload({ actions: [move(7, `${TARGET_OWNER}/objectstack`)] }), 'the stroke\'s own repository')); + const pull = validatePayload(fixturePayload({ actions: [{ op: 'transfer', pull: 7, target_repo: UI }] })); + t('⛔ a pull is refused: transfer takes no pull key, and its issue is required', [pull.ok, pull.errors.some((e) => e.includes('actions[0].pull is not a key `transfer` takes')), pull.errors.some((e) => e.includes('actions[0].issue is required'))], [false, true, true]); + t('a transfer beside any other op is refused — the stroke carries transfers alone', refuses(fixturePayload({ actions: [move(7), { op: 'comment', issue: 7, body: 'x' }] }), 'actions[1] is `comment` in a stroke that carries a `transfer`')); + t('two targets in one stroke are refused, as is one card named twice; four cards to one target (the consumer\'s shape) pass', [ + refuses(fixturePayload({ actions: [move(7), move(8, `${TARGET_OWNER}/cloud`)] }), 'one stroke reaches ONE second repository'), + refuses(fixturePayload({ actions: [move(7), move(7)] }), 'named twice'), + validatePayload(fixturePayload({ actions: [move(1), move(2), move(3), move(4)] })).ok, + ], [true, true, true]); + } + + // ── the token scope ─────────────────────────────────────────────────────── + battery('the token scope: ONE repository for every op but transfer, source + target for a transfer stroke, and the workflow mints from that list'); + { + const one = (action) => fixturePayload({ actions: [action] }); + const minimal = { + comment: { issue: 1, body: 'b' }, comment_edit: { comment_id: 1, body: 'b' }, labels_add: { issue: 1, labels: ['a'] }, labels_remove: { issue: 1, labels: ['a'] }, + assign: { issue: 1, assignees: ['u'] }, unassign: { issue: 1, assignees: ['u'] }, issue_patch: { issue: 1, state: 'closed' }, issue_create: { title: 't', body: 'b' }, + pr_create: { title: 't', head: 'h', base: 'main' }, pr_request_reviewers: { pull: 1, reviewers: ['u'] }, pr_ready: { pull: 1 }, pr_draft: { pull: 1 }, + automerge_enable: { pull: 1 }, automerge_disable: { pull: 1 }, + }; + const narrow = OP_NAMES.filter((op) => !OPS[op].secondRepo); + t('transfer is the ONLY row that reaches a second repository', OP_NAMES.filter((op) => OPS[op].secondRepo), ['transfer']); + t('…and every other op is exercised here, no more and no less', Object.keys(minimal).sort(), [...narrow].sort()); + t('⛔ every other op\'s stroke mints for exactly ONE repository — the payload\'s own', narrow.map((op) => tokenRepositoriesOf(validatePayload(one({ op, ...minimal[op] })).payload)).filter((list) => list.length !== 1 || list[0] !== `${TARGET_OWNER}/objectstack`), []); + t('…and so does a mixed stroke of them all', tokenRepositoriesOf(validatePayload(fixturePayload({ actions: narrow.map((op) => ({ op, ...minimal[op] })) })).payload), [`${TARGET_OWNER}/objectstack`]); + const moves = validatePayload(fixturePayload({ actions: [{ op: 'transfer', issue: 1, target_repo: `${TARGET_OWNER}/objectui` }, { op: 'transfer', issue: 2, target_repo: `${TARGET_OWNER}/objectui` }] })).payload; + t('a transfer stroke mints for its source and its one target — two, never more', tokenRepositoriesOf(moves), [`${TARGET_OWNER}/objectstack`, `${TARGET_OWNER}/objectui`]); + t('the GitHub output spells that list as names: one for a comment, two for a transfer', [githubOutputLines(fixturePayload()).split('\n')[0], githubOutputLines(moves).split('\n')[0]], ['repositories=objectstack', 'repositories=objectstack,objectui']); + const workflow = readFileSync(resolve(SELF_PATH, '../../../..', '.github/workflows/fleet-write.yml'), 'utf8'); + const repoInputs = [...workflow.matchAll(/^\s+repositories:\s*(.*?)\s*$/gm)].map((m) => m[1]); + t('the workflow\'s mint reads that list — the validate step\'s output, never the payload — and nothing else names one', repoInputs, ['${{ steps.validate.outputs.repositories }}']); + t('…under the one organization, as owner', /^\s+owner:\s*objectstack-ai\s*$/m.test(workflow) && TARGET_OWNER === 'objectstack-ai'); + } + // ── the floor, BEFORE the verdict ───────────────────────────────────────── const declared = Object.keys(SELF_TEST_BATTERIES); const floor = []; From f795be8614bda514e991401f6b2e34200074d108 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:37:13 +0000 Subject: [PATCH 2/6] wip(pm): fleet-write executor gates both repositories and sends transferIssue Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- scripts/pm/fleet-write/execute.mjs | 155 +++++++++++++++++++++++----- scripts/pm/fleet-write/validate.mjs | 3 +- 2 files changed, 129 insertions(+), 29 deletions(-) diff --git a/scripts/pm/fleet-write/execute.mjs b/scripts/pm/fleet-write/execute.mjs index 4b31a1e5a17..b5f3c7627f8 100644 --- a/scripts/pm/fleet-write/execute.mjs +++ b/scripts/pm/fleet-write/execute.mjs @@ -13,8 +13,9 @@ * * `.github/workflows/fleet-write.yml` runs this as its last step, after the * validator refused nothing and `actions/create-github-app-token` minted an - * installation token narrowed to `payload.repo` and to `PERMISSIONS` - * (`ops.mjs`). That token arrives in `GITHUB_TOKEN`, from the environment + * installation token narrowed to `payload.repo` — plus, for a `transfer` + * stroke alone, the transfer's one target (`tokenRepositoriesOf` in + * `validate.mjs`) — and to `PERMISSIONS` (`ops.mjs`). That token arrives in `GITHUB_TOKEN`, from the environment * only: it is never printed, never written to a file, never placed in an * argument; every string this file emits is scrubbed of it, and the runner's * own secret masking is the second net, not the first. The App's private key @@ -31,13 +32,21 @@ * There is no sender allowlist: GitHub's write permission on the target IS * the authorization, so a seat that can push to a repo can write to it as the * fleet, and one that cannot, cannot — whichever repo the dispatch landed on. + * The gate runs on EVERY repository the token reaches: a transfer stroke is + * refused unless the sender holds write on the source AND the target, the + * same pair GitHub itself requires of a person who transfers a card. * * ## The run * * Every action is validated AGAIN here (defence in depth: the executor trusts * the validator's rule, not the step that ran it), then its requests are * issued in order with the App token: REST calls straight from the op table; - * the pull-request GraphQL ops resolve the pull's node id with one GET first. + * the pull-request GraphQL ops resolve the pull's node id with one GET first; + * a `transfer` reads the issue (refusing a pull request, or a card that no + * longer answers from the source — already moved) and the target repository, + * then sends `transferIssue` with both node ids and accepts only an answer + * that places the card on the target. A transfer that fails prints the + * installation remedy `transferRemedy` spells, in the log and the summary. * The FIRST failure stops the run — later actions are NOT attempted, and the * summary says which — because a seat that dispatched five related writes * must be able to read exactly where the board was left. A 404 on a directed @@ -76,8 +85,8 @@ import { isEntrypoint } from '../../invoked-as.mjs'; import { scrub } from '../fleet-token.mjs'; import { classifyHttp } from '../label-write.mjs'; import { EXIT_WRITE_PACE_REFUSED, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from '../write-pace.mjs'; -import { OPS, PERMISSIONS } from './ops.mjs'; -import { PAYLOAD_ENV, refusalText, validatePayload } from './validate.mjs'; +import { OPS, PERMISSIONS, transferRemedy } from './ops.mjs'; +import { PAYLOAD_ENV, refusalText, tokenRepositoriesOf, validatePayload } from './validate.mjs'; const SELF_PATH = fileURLToPath(import.meta.url); const DEFAULT_API = 'https://api.github.com'; @@ -116,12 +125,25 @@ export function senderVerdict({ status, json } = {}) { return { verdict: 'prerequisite', role: '', permission: '', roleName: '' }; } +/** `owner/name` from an issue's `repository_url`, or null — where the card answers from NOW. */ +export function repoOfIssue(json) { + const m = /\/repos\/([^/]+\/[^/]+)$/.exec(String(json?.repository_url ?? '')); + return m ? m[1] : null; +} + /** Did this answer land the request? A 404 on a directed label DELETE is the label already being gone. */ export function requestLanded(req, { status, json } = {}) { if (req.graphql) { if (status !== 200 || !json || typeof json !== 'object') return { ok: false, why: `HTTP ${status}` }; if (Array.isArray(json.errors) && json.errors.length) return { ok: false, why: `GraphQL: ${json.errors.map((e) => e?.message ?? 'error').join('; ')}` }; if (!json.data) return { ok: false, why: 'GraphQL: no data' }; + if (req.graphql.target_repo !== undefined) { + // A transfer lands only when the answer places the card on the target. + const moved = json.data[req.graphql.mutation]?.issue; + const where = moved?.repository?.nameWithOwner; + if (!moved || !Number.isInteger(moved.number)) return { ok: false, why: 'GraphQL: the answer carries no transferred issue' }; + if (String(where ?? '').toLowerCase() !== req.graphql.target_repo.toLowerCase()) return { ok: false, why: `GraphQL: the answer places the issue on ${where ?? 'no repository'}, not ${req.graphql.target_repo}` }; + } return { ok: true, why: '' }; } if (status >= 200 && status < 300) return { ok: true, why: '' }; @@ -132,6 +154,8 @@ export function requestLanded(req, { status, json } = {}) { /** The one thing a seat needs from an answer: the id / number / url of what was written. */ export function resultOf(req, json) { if (req.graphql) { + const moved = json?.data?.[req.graphql.mutation]?.issue; + if (moved) return [`#${moved.number}`, moved.url, moved.repository?.nameWithOwner ? `(now on ${moved.repository.nameWithOwner})` : ''].filter(Boolean).join(' '); const pr = json?.data?.[req.graphql.mutation]?.pullRequest; if (!pr) return 'ok'; const bits = [`#${pr.number}`]; @@ -149,9 +173,10 @@ export function resultOf(req, json) { } /** The summary table, as markdown lines. */ -export function summaryText({ payload, sender, role, rows, stoppedAt = null, notAttempted = 0, refusal = null }) { +export function summaryText({ payload, sender, role, rows, stoppedAt = null, notAttempted = 0, refusal = null, remedy = null, reaches = null }) { const lines = [`### fleet-write \`${payload?.request_id ?? '?'}\``, '']; - lines.push(`- sender: \`${sender ?? '?'}\`${role ? ` (${role})` : ''} · session: \`${payload?.session ?? '?'}\` · target: \`${payload?.repo ?? '?'}\` · ${payload?.actions?.length ?? 0} action(s)`); + const also = Array.isArray(reaches) && reaches.length > 1 ? ` · the token also reaches ${reaches.slice(1).map((r) => `\`${r}\``).join(', ')}` : ''; + lines.push(`- sender: \`${sender ?? '?'}\`${role ? ` (${role})` : ''} · session: \`${payload?.session ?? '?'}\` · target: \`${payload?.repo ?? '?'}\`${also} · ${payload?.actions?.length ?? 0} action(s)`); if (refusal) { lines.push('', `⛔ ${refusal} — zero writes.`); return `${lines.join('\n')}\n`; @@ -161,6 +186,7 @@ export function summaryText({ payload, sender, role, rows, stoppedAt = null, not lines.push(''); if (stoppedAt === null) lines.push(`✓ ${rows.length} request(s) landed, every action done.`); else lines.push(`✗ stopped at action ${stoppedAt.action} (\`${stoppedAt.op}\`): ${stoppedAt.why}. ${notAttempted} later action(s) NOT attempted.`); + if (remedy) lines.push('', `Remedy: ${remedy}`); return `${lines.join('\n')}\n`; } @@ -209,6 +235,32 @@ async function rest(api, path, { method = 'GET', body = null } = {}, deps = {}) return { status: res.status, json, detail: scrub(typeof json?.message === 'string' ? json.message : '', [token]), call: `${method} ${path}` }; } +/** + * The node ids a GraphQL request needs, read before the mutation. Returns + * `{ variables }`, or `{ fail: { call, status, result, why } }`; never throws. + */ +async function graphqlVariables(req, payload, api, t) { + const g = req.graphql; + const unread = (r, what) => ({ fail: { call: r.call, status: r.status, result: 'no node id', why: `${r.call} -> HTTP ${r.status}${r.detail ? ` (${r.detail})` : ''}: ${what} could not be read` } }); + if (g.pull !== undefined) { + const pr = await rest(api, `/repos/${payload.repo}/pulls/${g.pull}`, {}, t); + if (pr.status !== 200 || typeof pr.json?.node_id !== 'string') return unread(pr, "the pull's node id"); + return { variables: { id: pr.json.node_id } }; + } + // transferIssue: the card on THIS repository — never a pull request, never one already moved — then the target. + const issue = await rest(api, `/repos/${payload.repo}/issues/${g.issue}`, {}, t); + if (issue.status !== 200 || typeof issue.json?.node_id !== 'string') return unread(issue, "the issue's node id"); + if (issue.json.pull_request) return { fail: { call: issue.call, status: issue.status, result: 'a pull request', why: `#${g.issue} on ${payload.repo} is a pull request — only an issue transfers` } }; + const at = repoOfIssue(issue.json); + if (!at || at.toLowerCase() !== payload.repo.toLowerCase()) { + const there = at ? `it answers from ${at}${Number.isInteger(issue.json.number) ? ` as #${issue.json.number}` : ''} — already transferred` : 'its repository could not be read'; + return { fail: { call: issue.call, status: issue.status, result: 'not on the source', why: `#${g.issue} no longer answers from ${payload.repo}: ${there}` } }; + } + const target = await rest(api, `/repos/${g.target_repo}`, {}, t); + if (target.status !== 200 || typeof target.json?.node_id !== 'string') return unread(target, `the target repository ${g.target_repo}'s node id`); + return { variables: { issueId: issue.json.node_id, repositoryId: target.json.node_id } }; +} + /** * The run. Returns `{ exit, rows, summary, lines }`; never throws on a status. * @@ -238,19 +290,25 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF return { exit: EXIT_PREREQUISITE, rows: [], summary: summaryText({ payload, sender, rows: [], refusal: 'no sender' }), lines }; } - // ── the sender gate ─────────────────────────────────────────────────────── - const permPath = `/repos/${payload.repo}/collaborators/${encodeURIComponent(sender)}/permission`; - const perm = await rest(api, permPath, {}, t); - const gate = senderVerdict(perm); - if (gate.verdict === 'prerequisite') { - log(`fleet-write/execute: PREREQUISITE NOT MET — ${perm.call} -> HTTP ${perm.status}${perm.detail ? ` (${perm.detail})` : ''}; the sender's permission could not be read. ⛔ zero writes.`); - return { exit: EXIT_PREREQUISITE, rows: [], summary: summaryText({ payload, sender, rows: [], refusal: `the permission read answered HTTP ${perm.status}` }), lines }; - } - if (gate.verdict === 'refused') { - log(`fleet-write/execute: SENDER REFUSED — \`${sender}\` is ${gate.role} on ${payload.repo}; the relay writes only for a sender with write, maintain or admin there. ⛔ zero writes.`); - return { exit: EXIT_SENDER_REFUSED, rows: [], summary: summaryText({ payload, sender, role: gate.role, rows: [], refusal: `sender \`${sender}\` is ${gate.role} on the target` }), lines }; + // ── the sender gate — on EVERY repository the token reaches ─────────────── + const reaches = tokenRepositoriesOf(payload); + const roles = []; + for (const repo of reaches) { + const permPath = `/repos/${repo}/collaborators/${encodeURIComponent(sender)}/permission`; + const perm = await rest(api, permPath, {}, t); + const gate = senderVerdict(perm); + if (gate.verdict === 'prerequisite') { + log(`fleet-write/execute: PREREQUISITE NOT MET — ${perm.call} -> HTTP ${perm.status}${perm.detail ? ` (${perm.detail})` : ''}; the sender's permission on ${repo} could not be read. ⛔ zero writes.`); + return { exit: EXIT_PREREQUISITE, rows: [], summary: summaryText({ payload, sender, rows: [], reaches, refusal: `the permission read on ${repo} answered HTTP ${perm.status}` }), lines }; + } + if (gate.verdict === 'refused') { + log(`fleet-write/execute: SENDER REFUSED — \`${sender}\` is ${gate.role} on ${repo}; the relay writes only for a sender with write, maintain or admin on every repository the stroke reaches. ⛔ zero writes.`); + return { exit: EXIT_SENDER_REFUSED, rows: [], summary: summaryText({ payload, sender, role: gate.role, rows: [], reaches, refusal: `sender \`${sender}\` is ${gate.role} on ${repo === payload.repo ? 'the target' : repo}` }), lines }; + } + roles.push({ repo, role: gate.role }); } - log(`fleet-write/execute: request ${payload.request_id} · sender ${sender} (${gate.role}) · session ${payload.session} · target ${payload.repo} · ${payload.actions.length} action(s)`); + const role = roles.length === 1 ? roles[0].role : roles.map((r) => `${r.role} on ${r.repo}`).join(', '); + log(`fleet-write/execute: request ${payload.request_id} · sender ${sender} (${role}) · session ${payload.session} · target ${payload.repo}${reaches.length > 1 ? ` · the token also reaches ${reaches.slice(1).join(', ')}` : ''} · ${payload.actions.length} action(s)`); // ── the actions, in order ───────────────────────────────────────────────── const rows = []; @@ -261,15 +319,15 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF for (const req of requests) { let body = req.body ?? null; if (req.graphql) { - const pr = await rest(api, `/repos/${payload.repo}/pulls/${req.graphql.pull}`, {}, t); - if (pr.status !== 200 || typeof pr.json?.node_id !== 'string') { - const why = `${pr.call} -> HTTP ${pr.status}${pr.detail ? ` (${pr.detail})` : ''}: the pull's node id could not be read`; - rows.push({ action: i + 1, op: action.op, call: pr.call, status: pr.status, result: 'no node id' }); + const resolved = await graphqlVariables(req, payload, api, t); + if (resolved.fail) { + const why = scrub(resolved.fail.why, [token]); + rows.push({ action: i + 1, op: action.op, call: resolved.fail.call, status: resolved.fail.status, result: resolved.fail.result }); stoppedAt = { action: i + 1, op: action.op, why }; log(` ✗ action ${i + 1} ${action.op}: ${why}`); break; } - body = { query: req.graphql.query, variables: { id: pr.json.node_id } }; + body = { query: req.graphql.query, variables: resolved.variables }; } // The GraphQL leg is spelled with its literal verb: every mutation is a POST to one path. const r = req.graphql ? await rest(api, '/graphql', { method: 'POST', body }, t) : await rest(api, req.path, { method: req.verb, body }, t); @@ -285,10 +343,13 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF } } const notAttempted = stoppedAt ? payload.actions.length - stoppedAt.action : 0; - const summary = summaryText({ payload, sender, role: gate.role, rows, stoppedAt, notAttempted }); + const failedAction = stoppedAt ? payload.actions[stoppedAt.action - 1] : null; + const remedy = failedAction && OPS[failedAction.op].secondRepo ? transferRemedy(payload.repo, failedAction[OPS[failedAction.op].secondRepo]) : null; + const summary = summaryText({ payload, sender, role, rows, stoppedAt, notAttempted, remedy, reaches }); if (stoppedAt) log(`fleet-write/execute: ✗ stopped at action ${stoppedAt.action} (${stoppedAt.op}); ${notAttempted} later action(s) NOT attempted. The board holds what the rows above say landed.`); else log(`fleet-write/execute: ✓ ${rows.length} request(s) landed, every action done.`); - return { exit: stoppedAt ? EXIT_ACTION_FAILED : EXIT_OK, rows, summary, lines, role: gate.role }; + if (remedy) log(`fleet-write/execute: remedy — ${remedy}`); + return { exit: stoppedAt ? EXIT_ACTION_FAILED : EXIT_OK, rows, summary, lines, role }; } // --------------------------------------------------------------------------- @@ -305,8 +366,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'redaction: the token reaches no summary line, log line or error': 3, 'the wiring: both halves around every write verb, on the roster': 4, 'the CLI: environment inputs, the payload refusal, the exit ladder': 7, + 'the transfer: the sender gated on BOTH repositories, both node ids first, a pull or a moved card refused before the mutation, landing only on the target, the remedy on failure': 11, }); -const SELF_TEST_BATTERY_FLOOR = 9; +const SELF_TEST_BATTERY_FLOOR = 10; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -468,6 +530,42 @@ export async function selfTest() { t('a pull whose node id cannot be read is a failed action with no mutation sent', [noNode.exit, noNode.seen.some((s) => s.call === 'POST /graphql')], [EXIT_ACTION_FAILED, false]); } + // ── the transfer ──────────────────────────────────────────────────────── + battery('the transfer: the sender gated on BOTH repositories, both node ids first, a pull or a moved card refused before the mutation, landing only on the target, the remedy on failure'); + { + const UI = 'objectstack-ai/objectui'; + const PERM_UI = `GET /repos/${UI}/collaborators/os-support-ai/permission`; + const issueAt = (repo, extra = {}) => ({ status: 200, json: { number: 7, node_id: 'I_kwDO7', repository_url: `https://api.github.test/repos/${repo}`, ...extra } }); + const landedOn = (where) => ({ data: { transferIssue: { issue: { number: 31, url: `https://github.test/${where}/issues/31`, repository: { nameWithOwner: where } } } } }); + const happyT = { + ...allowed, + [PERM_UI]: { status: 200, json: { permission: 'write', role_name: 'maintain' } }, + [`GET /repos/${REPO}/issues/7`]: issueAt(REPO), + [`GET /repos/${UI}`]: { status: 200, json: { node_id: 'R_kgDOui', full_name: UI } }, + 'POST /graphql': { status: 200, json: landedOn(UI) }, + }; + const move = base([{ op: 'transfer', issue: 7, target_repo: UI }]); + const ok = await run(move, happyT, { file: paceFile }); + t('a transfer gates the sender on the source AND the target, reads the issue and the target, then sends ONE mutation', [ok.exit, ok.seen.map((s) => s.call)], [EXIT_OK, [PERM, PERM_UI, `GET /repos/${REPO}/issues/7`, `GET /repos/${UI}`, 'POST /graphql']], ok.logs.join(' | ')); + t('…with both node ids as its variables, under the transferIssue query', [ok.seen[4]?.body?.variables, ok.seen[4]?.body?.query.includes('transferIssue(')], [{ issueId: 'I_kwDO7', repositoryId: 'R_kgDOui' }, true]); + t('…and the row reads the new number, url and repository; the summary names both repositories and both roles', [ok.rows[0]?.result, ok.summary.includes(`the token also reaches \`${UI}\``), ok.summary.includes(`write on ${REPO}, maintain on ${UI}`)], [`#31 https://github.test/${UI}/issues/31 (now on ${UI})`, true, true]); + const noTarget = await run(move, { ...happyT, [PERM_UI]: { status: 200, json: { permission: 'read', role_name: 'read' } } }); + t('⛔ a sender with write on the source but not on the target is refused: exit 4, zero writes, the target named', [noTarget.exit, writes(noTarget.seen).length, noTarget.logs.some((l) => l.includes(`is read on ${UI}`))], [EXIT_SENDER_REFUSED, 0, true]); + const pullT = await run(move, { ...happyT, [`GET /repos/${REPO}/issues/7`]: issueAt(REPO, { pull_request: { url: 'https://api.github.test/pulls/7' } }) }); + t('a number that is a pull request is refused before the mutation', [pullT.exit, pullT.seen.some((s) => s.call === 'POST /graphql'), pullT.rows[0]?.result], [EXIT_ACTION_FAILED, false, 'a pull request']); + const already = await run(move, { ...happyT, [`GET /repos/${REPO}/issues/7`]: issueAt(UI, { number: 31 }) }); + t('a card that already answers from another repository (the platform followed its redirect) is refused before the mutation', [already.exit, already.seen.some((s) => s.call === 'POST /graphql'), already.logs.some((l) => l.includes(`it answers from ${UI} as #31 — already transferred`))], [EXIT_ACTION_FAILED, false, true]); + const uncovered = await run(move, { ...happyT, [`GET /repos/${UI}`]: { status: 404, json: { message: 'Not Found' } } }); + t('a target whose node id cannot be read stops before the mutation, and the summary names the installation remedy', [uncovered.exit, uncovered.seen.some((s) => s.call === 'POST /graphql'), uncovered.summary.includes('Remedy:') && uncovered.summary.includes("objectstack-fleet App's repository access")], [EXIT_ACTION_FAILED, false, true]); + const denied = await run(move, { ...happyT, 'POST /graphql': { status: 200, json: { data: { transferIssue: null }, errors: [{ message: 'Resource not accessible by integration' }] } } }); + t('a mutation the platform refuses is exit 5 with its sentence, and the remedy in the log and the summary', [denied.exit, denied.rows[0]?.result.includes('Resource not accessible by integration'), denied.logs.some((l) => l.includes('remedy —')), denied.summary.includes('Remedy:')], [EXIT_ACTION_FAILED, true, true, true]); + const req = OPS.transfer.requests({ op: 'transfer', issue: 7, target_repo: UI })[0]; + t('⛔ an answer that places the card anywhere but the target is not a landing', requestLanded(req, { status: 200, json: landedOn('objectstack-ai/cloud') }).ok, false); + t('…nor is one that carries no issue', requestLanded(req, { status: 200, json: { data: { transferIssue: { issue: null } } } }).ok, false); + const plain = await run(base([{ op: 'comment', issue: 1, body: 'x' }]), { ...allowed, [`POST /repos/${REPO}/issues/1/comments`]: { status: 403, json: { message: 'Resource not accessible by integration' } } }); + t('a failure that is not a transfer carries no transfer remedy, and its token reached one repository only', [plain.exit, plain.summary.includes('Remedy:'), plain.summary.includes('the token also reaches')], [EXIT_ACTION_FAILED, false, false]); + } + // ── the summary ───────────────────────────────────────────────────────── battery('the summary: request, sender and role, session, target, one row per request'); { @@ -566,7 +664,8 @@ export async function selfTest() { console.log( `✓ fleet-write/execute self-test: ${cases.length} cases pass across ${declared.length} batteries — the sender gate from the target repo's answer, ` + 'every op as the request the table declares, stop at the first failure with later actions untouched, the idempotent label DELETE, the GraphQL ' + - 'ops behind a node-id read, one summary row per request, and a known token that came back out of NO summary, log or error.', + 'ops behind a node-id read, a transfer gated on both repositories and landing only on its target, one summary row per request, and a known ' + + 'token that came back out of NO summary, log or error.', ); selfTestReachedVerdict = true; return 0; diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index 4080a80e4bd..e8e1dce3cbb 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -689,7 +689,8 @@ export async function selfTest() { } console.log( `✓ fleet-write/validate self-test: ${cases.length} cases pass across ${declared.length} batteries — a closed envelope, one organization, ` + - 'closed keys per op with typed values, the platform\'s own client_payload ceilings pinned with their source, and no row that can reach a refused family.', + 'closed keys per op with typed values, the platform\'s own client_payload ceilings pinned with their source, no row that can reach a refused family, ' + + 'and a token list of ONE repository for every op but transfer, whose second is one governed target.', ); selfTestReachedVerdict = true; return 0; From 7b9fe67277ad77bdba566ab405ff5296a6513341 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:42:08 +0000 Subject: [PATCH 3/6] =?UTF-8?q?wip(pm):=20issue-transfer=20door=20?= =?UTF-8?q?=E2=80=94=20one=20card,=20the=20relay's=20transfer=20row=20or?= =?UTF-8?q?=20a=20direct=20mutation,=20read=20back=20at=20its=20new=20addr?= =?UTF-8?q?ess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- scripts/pm/issue-transfer.mjs | 735 ++++++++++++++++++++++++++++++++++ scripts/pm/write-pace.mjs | 6 +- 2 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 scripts/pm/issue-transfer.mjs diff --git a/scripts/pm/issue-transfer.mjs b/scripts/pm/issue-transfer.mjs new file mode 100644 index 00000000000..717461790eb --- /dev/null +++ b/scripts/pm/issue-transfer.mjs @@ -0,0 +1,735 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * issue-transfer — the one door a card leaves its repository through, WITH + * its history. + * + * node scripts/pm/issue-transfer.mjs --repo objectstack-ai/objectstack --issue 123 --to objectstack-ai/objectui [--json] + * node scripts/pm/issue-transfer.mjs --issue 123 --to objectstack-ai/objectui --dry-run + * node scripts/pm/issue-transfer.mjs --self-test # offline, a fake platform, no network at all + * + * ## Why this file exists + * + * A card filed in the wrong repository used to be REBUILT in the right one — + * a provenance header, every bare `#N` spelled out, the source closed as + * moved — because the fleet-write relay had no transfer op. A rebuild loses + * the comment thread, the timeline, the cross-references and the reactions, + * and leaves a closed husk behind. GitHub's `transferIssue` mutation keeps + * them, keeps comments and assignees, and redirects the old URL. This door + * performs it — through the relay's `transfer` row in a cloud seat, or + * directly with a fleet token elsewhere — and reads the card back at its new + * address. One card per call; N cards are N calls. + * + * ## What it refuses, before any request leaves + * + * - a source outside the organization (a transfer never leaves it); + * - whatever the relay's own validator refuses for the same stroke — + * `validateStroke` in `fleet-write/validate.mjs`, ONE judge for both + * transports: a target outside `TRANSFER_TARGETS` (the governed roster), + * the source as its own target, a number that is not a positive integer. + * + * And after one read, before any write: + * + * - a number that is a pull request — only an issue transfers; + * - a card that no longer answers from the source (the platform followed + * its redirect): already moved. The door names where it lives now and + * sends nothing — so after an UNCONFIRMED, READ the card; this refusal is + * what a blind re-run would meet, not a licence to try one. + * + * ## The transport — `OS_FLEET_TRANSPORT` direct | dispatch | auto + * + * `dispatch` packs ONE `transfer` action into ONE `repository_dispatch`; the + * relay mints its token for the source AND the target — the one widening it + * makes — and gates the sender on both. `direct` reads the target's node id + * and sends the relay row's own mutation with the token this process holds. + * `auto` takes `dispatch` in a cloud seat container and `direct` elsewhere, + * and says which; it falls back to `direct` only when NO run appeared, never + * from a run that failed (issue-create's rule, and dispatch.mjs's reasons). + * + * ## Read-back — the old URL redirects, the card answers at the new one + * + * `GET /repos/{source}/issues/{n}` WITHOUT following redirects must answer + * 301 naming the new issue, and `GET /repos/{target}/issues/{m}` must answer + * from the target with the title the pre-read saw. Both, or not confirmed. + * Labels with no same-named label on the target are dropped by the platform + * (the relay never asks it to create them); the read-back prints what stayed. + * + * ## When the platform refuses — fail closed, name the remedy + * + * Whether the fleet App's installation covers the target is a maintainer + * setting no seat token can read. A failed run, a target whose node id cannot + * be read, or a refused mutation is refused here with `transferRemedy` + * (`fleet-write/ops.mjs`): the maintainer adds the target to the App's + * repository access. ⛔ The door never falls back to a rebuild; that recipe + * stays for a target GitHub refuses outright (outside the organization). + * + * ## Exit codes — capture them BEFORE any pipe (issue-create's ladder) + * + * 0 transferred, and read back: the old URL redirects, the new one answers. + * 2 usage, or a refusal above. Nothing was transferred. + * 3 PREREQUISITE NOT MET — no token, the platform unreachable, the + * credential rate-limit exhausted, or no route. Nothing was transferred. + * 4 the platform answered, and the BOARD DISAGREES — the card sits on + * another repository, under another title, or the old URL still serves + * it. Every number seen is printed. + * 5 the platform refused, or the relay run FAILED. The remedy is printed. + * 6 UNCONFIRMED — the dispatch was accepted and its run did not appear or + * complete within the ceiling, or the read-back could not be taken. The + * run URL is printed. Go READ the card; ⛔ never re-run blind. + * 10 the write throttle refused. Nothing was sent. + */ + +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { isEntrypoint } from '../invoked-as.mjs'; +import { EXIT_PREREQUISITE_NOT_MET, PROXY_FLAG, proxyRearmPlan, resolveSweepRepo } from './check-half-states.mjs'; +import { EXIT_UNCONFIRMED, exitForResult, fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; +import { repoOfIssue, requestLanded } from './fleet-write/execute.mjs'; +import { OPS, TARGET_OWNER, TARGET_REPO_SHAPE, TRANSFER_TARGETS, transferRemedy } from './fleet-write/ops.mjs'; +import { refusalText as relayRefusalText, validateStroke } from './fleet-write/validate.mjs'; +import { classifyHttp } from './label-write.mjs'; +import { EXIT_WRITE_PACE_REFUSED, isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from './write-pace.mjs'; + +const SELF_PATH = fileURLToPath(import.meta.url); +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; +const PROXY_REARM_GUARD = 'OS_ISSUE_TRANSFER_PROXY_REARMED'; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 2; +export const EXIT_PREREQUISITE = EXIT_PREREQUISITE_NOT_MET; +export const EXIT_BOARD_DISAGREES = 4; +export const EXIT_PLATFORM_REFUSAL = 5; + +// --------------------------------------------------------------------------- +// The pure core — arguments, the plan, the verdict on a status. +// --------------------------------------------------------------------------- + +export function parseArgs(argv) { + const opts = { repo: null, issue: null, to: null, json: false, dryRun: false, selfTest: false, help: false, unknown: [], errors: [] }; + const args = [...argv]; + const value = (flag) => { + const v = args.shift(); + if (v === undefined || v.startsWith('--')) { + opts.errors.push(`${flag} needs a value`); + return null; + } + return v; + }; + while (args.length) { + const a = args.shift(); + if (a === '--help' || a === '-h') opts.help = true; + else if (a === '--self-test') opts.selfTest = true; + else if (a === '--json') opts.json = true; + else if (a === '--dry-run') opts.dryRun = true; + else if (a === '--repo') opts.repo = value(a); + else if (a === '--issue') opts.issue = value(a); + else if (a === '--to') opts.to = value(a); + else if (a.startsWith('--') && a.includes('=')) { + const [k, ...rest] = a.split('='); + args.unshift(k, rest.join('=')); + } else opts.unknown.push(a); + } + return opts; +} + +/** + * The plan: the one `transfer` action, its source and target, or why not. + * The relay's validator judges the stroke for BOTH transports. + */ +export function planFrom(opts, { env = process.env } = {}) { + const errors = []; + const resolved = opts.repo ? { repo: String(opts.repo).trim(), source: '--repo' } : resolveSweepRepo(env); + const repo = resolved.repo; + if (!TARGET_REPO_SHAPE.test(repo) || repo.includes('..')) { + errors.push(`the source must be ${TARGET_OWNER}/ — a transfer never leaves the one organization the fleet App is installed on; got ${JSON.stringify(repo)} (from ${resolved.source})`); + } + const rawIssue = opts.issue === null ? null : String(opts.issue).trim(); + if (rawIssue === null) errors.push('the card is required: --issue '); + else if (!/^[1-9][0-9]*$/.test(rawIssue)) errors.push(`--issue must be a positive integer; got ${JSON.stringify(opts.issue)}`); + const to = opts.to === null ? null : String(opts.to).trim(); + if (to === null) errors.push(`the target is required: --to `); + const action = { op: 'transfer', issue: rawIssue === null ? null : Number(rawIssue), target_repo: to }; + if (errors.length === 0) errors.push(...validateStroke(repo, [action])); + return { ok: errors.length === 0, errors, repo, to, issue: action.issue, action }; +} + +/** What `--dry-run` prints: the whole stroke, nothing sent. */ +export function dryRunText(plan) { + return [ + 'issue-transfer --dry-run — NOTHING was sent, nothing was read.', + ` card : ${plan.repo}#${plan.issue}`, + ` target : ${plan.to}`, + ` mutation : ${OPS.transfer.requests(plan.action)[0].graphql.mutation} (labels the target lacks are dropped, never created)`, + ` relay : ONE fleet-write action ${JSON.stringify(plan.action)}; its token reaches ${plan.repo} and ${plan.to}`, + ].join('\n'); +} + +/** The exit a transport verdict maps to. `null` means "carry on". */ +export function exitForVerdict(verdict) { + if (verdict === 'ok') return null; + if (verdict === 'prerequisite' || verdict === 'ratelimit') return EXIT_PREREQUISITE; + return EXIT_PLATFORM_REFUSAL; +} + +/** The issue number an API redirect names (`…/issues/31`), or null. */ +export function numberFromRedirect({ json, location } = {}) { + for (const candidate of [json?.url, location]) { + const m = /\/issues\/([1-9][0-9]*)(?:[?#].*)?$/.exec(String(candidate ?? '')); + if (m) return Number(m[1]); + } + return null; +} + +// --------------------------------------------------------------------------- +// Transport — the one shape, with both halves of the throttle around the one +// write verb. ⛔ Nothing here throws on an HTTP status. +// --------------------------------------------------------------------------- + +async function rest(path, { method = 'GET', body = null, redirect = 'follow' } = {}, deps = {}) { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const token = deps.token ?? TOKEN; + const paceDeps = deps.pace ?? {}; + // ⏱ The throttle, on the write verb only — the POST that carries the mutation. + const paced = isWriteMethod(method); + if (paced) await paceWrite({ token, kind: `issue-transfer ${method}` }, paceDeps); + let res; + try { + res = await fetchImpl(`${API}${path}`, { + method, + redirect, + headers: { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch (e) { + if (paced) releaseWriteLease(paceDeps.file); // ⏱ no response will come, so the fleet's turn ends here + return { status: 0, rateRemaining: null, json: null, location: 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; + } + } + // ⏱ …and the other half, with the verdict `classifyHttp` already reached. + if (paced) { + noteResponse( + { token, status: res.status, headers: res.headers, body: json, verdict: classifyHttp({ status: res.status, rateRemaining: rateRemaining === null ? null : Number(rateRemaining) }) }, + paceDeps, + ); + } + return { + status: res.status, + rateRemaining: rateRemaining === null ? null : Number(rateRemaining), + json, + location: res.headers.get('location'), + detail: typeof json?.message === 'string' ? json.message : '', + call: `${method} ${path}`, + }; +} + +/** + * Transfer the card the plan names, read it back, and say what happened. + * Returns `{ exitCode, from, to, lines, transport, relay }`; never throws on a + * status. + */ +export async function transferIssue(plan, deps = {}) { + const lines = []; + const token = deps.token ?? TOKEN; + const ctx = { from: { repo: plan.repo, number: plan.issue }, to: null, transport: null, relay: null }; + const done = (exitCode, extra = {}) => ({ exitCode, lines, ...ctx, ...extra }); + const remedy = () => ` Remedy: ${transferRemedy(plan.repo, plan.to)}`; + const said = (r) => (r.detail ? ` — ${r.detail}` : ''); + if (!token) { + lines.push('✗ issue-transfer: no GITHUB_TOKEN / GH_TOKEN in the environment — nothing was sent. Run it through scripts/pm/with-fleet.sh, which mints the fleet identity.'); + return done(EXIT_PREREQUISITE); + } + const route = deps.route ?? (await resolveRoute(deps.env ?? process.env)); + ctx.transport = route.transport; + if (route.error) { + lines.push(`✗ issue-transfer: PREREQUISITE NOT MET — ${route.error} Nothing was sent.`); + return done(EXIT_PREREQUISITE); + } + lines.push(` issue-transfer: transport ${route.transport} — ${route.reason}`); + + // ── the pre-read: the card, still on the source, and an issue ───────────── + const pre = await rest(`/repos/${plan.repo}/issues/${plan.issue}`, {}, deps); + if (pre.status !== 200 || !pre.json) { + lines.push(`✗ issue-transfer: ${pre.call} → HTTP ${pre.status}${said(pre)}: the card could not be read, so nothing was sent.`); + return done(exitForVerdict(classifyHttp({ status: pre.status, rateRemaining: pre.rateRemaining })) ?? EXIT_PLATFORM_REFUSAL); + } + if (pre.json.pull_request) { + lines.push(`✗ issue-transfer: ${plan.repo}#${plan.issue} is a pull request — only an issue transfers. Nothing was sent.`); + return done(EXIT_USAGE); + } + const at = repoOfIssue(pre.json); + if (!at || at.toLowerCase() !== plan.repo.toLowerCase()) { + const where = at ? ` — it lives on ${at} as #${pre.json.number}${pre.json.html_url ? ` ${pre.json.html_url}` : ''}` : ' (its repository could not be read)'; + lines.push(`✗ issue-transfer: ${plan.repo}#${plan.issue} no longer answers from ${plan.repo}${where}. Already moved; nothing was sent.`); + return done(EXIT_USAGE, at ? { to: { repo: at, number: pre.json.number ?? null, url: pre.json.html_url ?? null } } : {}); + } + const title = String(pre.json.title ?? '').trim(); + const labelsBefore = (pre.json.labels ?? []).map((l) => l?.name ?? String(l)); + + // ── the transfer ────────────────────────────────────────────────────────── + let claimed = null; + if (route.transport === 'dispatch') { + const packed = packRequest({ repo: plan.repo, session: route.session, actions: [plan.action] }); + if (!packed.ok) { + lines.push(relayRefusalText(packed.errors), ' Nothing was sent.'); + return done(EXIT_USAGE); + } + const sent = await (deps.send ?? sendFleetWrite)(packed.payload, { token, log: (line) => lines.push(` ${line}`) }); + if (sent.ok) { + ctx.relay = sent; + lines.push(` issue-transfer: the relay run ${sent.run?.url ?? sent.run?.id ?? ''} completed — reading the card back.`); + } else if (route.requested === 'auto' && sent.state === 'no-run') { + lines.push(` ${fallbackText(sent, 'issue-transfer')}`); + } else if (sent.state === 'no-run' || sent.state === 'timeout') { + lines.push(unconfirmedText(sent, 'issue-transfer')); + return done(EXIT_UNCONFIRMED, { relay: sent }); + } else { + lines.push(`✗ issue-transfer: relay ${sent.state === 'refused' ? 'REFUSED the dispatch' : 'run FAILED'} — ${sent.detail}${sent.run?.url ? ` ${sent.run.url}` : ''}. ⛔ Not retried and not fallen back: go READ the run's summary and the card.`); + if (sent.state !== 'refused') lines.push(remedy()); + return done(exitForResult(sent), { relay: sent }); + } + } + if (ctx.relay === null) { + const target = await rest(`/repos/${plan.to}`, {}, deps); + if (target.status !== 200 || typeof target.json?.node_id !== 'string') { + lines.push(`✗ issue-transfer: ${target.call} → HTTP ${target.status}${said(target)}: the target's node id could not be read. Nothing was sent.`, remedy()); + return done(exitForVerdict(classifyHttp({ status: target.status, rateRemaining: target.rateRemaining })) ?? EXIT_PLATFORM_REFUSAL); + } + // The relay row's own descriptor — one spelling of the mutation for both transports. + const req = OPS.transfer.requests(plan.action)[0]; + const answer = await rest('/graphql', { method: 'POST', body: { query: req.graphql.query, variables: { issueId: pre.json.node_id, repositoryId: target.json.node_id } } }, deps); + const verdict = classifyHttp({ status: answer.status, rateRemaining: answer.rateRemaining }); + const bad = exitForVerdict(verdict); + if (bad !== null) { + lines.push(`✗ issue-transfer: POST /graphql ${req.graphql.mutation} → HTTP ${answer.status}${said(answer)} (${verdict}). Nothing was transferred.`); + lines.push(verdict === 'ratelimit' ? ' The credential is rate-limit exhausted: this binds the IDENTITY, so switching tools or tokens to keep writing is the same act as retrying. ⛔ Do not.' : remedy()); + return done(bad); + } + const landed = requestLanded(req, answer); + const moved = answer.json?.data?.[req.graphql.mutation]?.issue; + if (!landed.ok) { + if (moved && Number.isInteger(moved.number)) { + lines.push(`✗ issue-transfer: the board disagrees — ${landed.why}; it answered #${moved.number}${moved.url ? ` ${moved.url}` : ''}.`); + return done(EXIT_BOARD_DISAGREES, { to: { repo: moved.repository?.nameWithOwner ?? null, number: moved.number, url: moved.url ?? null } }); + } + lines.push(`✗ issue-transfer: POST /graphql ${req.graphql.mutation} → ${landed.why}. Nothing was transferred.`, remedy()); + return done(EXIT_PLATFORM_REFUSAL); + } + claimed = moved.number; + lines.push(` issue-transfer: ${req.graphql.mutation} answered ${plan.to}#${claimed}${moved.url ? ` ${moved.url}` : ''} — reading the card back.`); + } + + // ── the read-back: the old URL redirects, the new one answers ───────────── + const unconfirmed = (why) => { + lines.push(`✗ issue-transfer: UNCONFIRMED — ${why}. Go READ ${plan.repo}#${plan.issue} and ${plan.to}; ⛔ do not re-run blind. Exit ${EXIT_UNCONFIRMED}.`); + return done(EXIT_UNCONFIRMED); + }; + const old = await rest(`/repos/${plan.repo}/issues/${plan.issue}`, { redirect: 'manual' }, deps); + if (old.status === 200) { + lines.push(`✗ issue-transfer: the board disagrees — ${old.call} still answers 200 from ${repoOfIssue(old.json) ?? '?'}: the card did not move${claimed !== null ? `, though the mutation answered #${claimed}` : ''}.`); + return done(EXIT_BOARD_DISAGREES); + } + if (old.status !== 301) return unconfirmed(`${old.call} answered HTTP ${old.status}${said(old)}, not the 301 a moved card answers`); + const number = numberFromRedirect(old); + if (number === null) return unconfirmed(`${old.call} answered 301 naming no issue number`); + if (claimed !== null && claimed !== number) { + lines.push(`✗ issue-transfer: the board disagrees — the mutation answered #${claimed}, the old URL redirects to #${number}.`); + return done(EXIT_BOARD_DISAGREES, { to: { repo: plan.to, number, url: null } }); + } + const fresh = await rest(`/repos/${plan.to}/issues/${number}`, {}, deps); + if (fresh.status !== 200 || !fresh.json) return unconfirmed(`the old URL redirects to #${number}, but ${fresh.call} answered HTTP ${fresh.status}${said(fresh)}`); + const freshAt = repoOfIssue(fresh.json); + const freshTitle = String(fresh.json.title ?? '').trim(); + const to = { repo: freshAt ?? plan.to, number, url: fresh.json.html_url ?? null }; + if (!freshAt || freshAt.toLowerCase() !== plan.to.toLowerCase() || freshTitle !== title) { + lines.push(`✗ issue-transfer: the board disagrees — #${number} answers from ${freshAt ?? '?'} titled ${JSON.stringify(freshTitle)}; wanted ${plan.to} titled ${JSON.stringify(title)}.`); + return done(EXIT_BOARD_DISAGREES, { to }); + } + const labelsAfter = (fresh.json.labels ?? []).map((l) => l?.name ?? String(l)); + const dropped = labelsBefore.filter((l) => !labelsAfter.includes(l)); + lines.push(`✓ issue-transfer: ${plan.repo}#${plan.issue} → ${plan.to}#${number}${to.url ? ` ${to.url}` : ''}`); + lines.push( + ` read-back: the old URL answers 301 to #${number}; #${number} answers from ${plan.to} with the same title; labels kept: ${labelsAfter.join(', ') || '(none)'}` + + `${dropped.length ? `; dropped (no same-named label on the target): ${dropped.join(', ')}` : ''}.`, + ); + return done(EXIT_OK, { to }); +} + +// --------------------------------------------------------------------------- +// Self-test — offline: a fake board that moves the card when the mutation (or +// the relay) lands, injected routes, no network. +// --------------------------------------------------------------------------- + +const SELF_TEST_BATTERIES = Object.freeze({ + "the arguments: one card, a source in the organization, a target from the governed roster that is not the source — judged by the relay's own validator": 11, + 'the pre-read: a pull request, a card already moved, or an unreadable card is refused before any write': 5, + "the direct transport: the target's node id, then ONE paced mutation carrying both node ids — the relay row's own query": 7, + 'the read-back: the old URL answers 301 to the new card, which answers from the target with the same title': 7, + 'the relay transport: ONE dispatch carrying ONE transfer, the new number read from the redirect, a failed run names the remedy and is never fallen back from': 8, + 'dry-run: no request leaves, and the plan is printed': 3, + 'the wiring: both halves around the one write verb, on the roster': 4, +}); +const SELF_TEST_BATTERY_FLOOR = 7; +const UNATTRIBUTED_BATTERY = '(unattributed)'; + +const batteryCases = new Map(); +let openBattery = null; +const battery = (name) => { + openBattery = name; +}; + +let selfTestReachedVerdict = false; + +export async function selfTest() { + const cases = []; + const t = (name, actual, expected = true, detail) => { + const bucket = openBattery ?? UNATTRIBUTED_BATTERY; + batteryCases.set(bucket, (batteryCases.get(bucket) ?? 0) + 1); + const ok = JSON.stringify(actual) === JSON.stringify(expected); + cases.push({ name, ok, detail: ok ? '' : `${detail ? `${detail} — ` : ''}got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}` }); + }; + const { mkdtempSync, rmSync, readFileSync: readReal, existsSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + + const SRC = `${TARGET_OWNER}/objectstack`; + const UI = `${TARGET_OWNER}/objectui`; + const env = { PM_SWEEP_REPO: SRC }; + const plan = planFrom(parseArgs(['--repo', SRC, '--issue', '7', '--to', UI]), { env }); + + // ── the arguments ───────────────────────────────────────────────────────── + battery("the arguments: one card, a source in the organization, a target from the governed roster that is not the source — judged by the relay's own validator"); + { + t('a card, a source and a target make a plan carrying ONE transfer action', [plan.ok, plan.action], [true, { op: 'transfer', issue: 7, target_repo: UI }], plan.errors.join(' | ')); + t('…on the board the environment resolves when --repo is absent', planFrom(parseArgs(['--issue', '7', '--to', UI]), { env }).repo, SRC); + t('a target outside the governed roster is refused, naming the roster', planFrom(parseArgs(['--issue', '7', '--to', `${TARGET_OWNER}/elsewhere`]), { env }).errors.some((e) => e.includes('must be one of') && e.includes(UI))); + t('the source as its own target is refused', planFrom(parseArgs(['--issue', '7', '--to', SRC]), { env }).errors.some((e) => e.includes("the stroke's own repository"))); + t('a source outside the organization is refused', planFrom(parseArgs(['--repo', 'someone/else', '--issue', '7', '--to', UI]), { env }).errors.some((e) => e.includes(`the source must be ${TARGET_OWNER}/`))); + t('a number that is not a positive integer is refused, every spelling of it', ['7a', '0', '-3', '1.5', ' '].map((n) => planFrom(parseArgs(['--issue', n, '--to', UI]), { env }).ok), [false, false, false, false, false]); + t('the card is required', planFrom(parseArgs(['--to', UI]), { env }).errors.some((e) => e.includes('the card is required'))); + t('the target is required, and the refusal names the roster', planFrom(parseArgs(['--issue', '7']), { env }).errors.some((e) => e.includes('the target is required') && e.includes(TRANSFER_TARGETS[1]))); + t('a flag without its value is a usage error', parseArgs(['--issue', '7', '--to']).errors, ['--to needs a value']); + t('an unknown flag is reported, ⛔ never silently dropped', parseArgs(['--isue', '7']).unknown, ['--isue', '7']); + t('the --flag=value spelling is accepted', planFrom(parseArgs([`--repo=${SRC}`, '--issue=7', `--to=${UI}`]), { env }).ok); + } + + const dir = mkdtempSync(join(tmpdir(), 'issue-transfer-')); + try { + let paceCase = 0; + const paceFile = join(dir, 'pace-0.jsonl'); + const paceFor = (file) => ({ + file, + env: { OS_PM_WRITE_MIN_GAP_MS: '0' }, + log: () => {}, + exit: (code) => { + throw Object.assign(new Error(`throttle refused with exit ${code}`), { throttleExit: code }); + }, + }); + const TOKEN_FIXTURE = 'ghs_FixtureTokenNotRealAtAll0000000000000'; + const API_TEST = 'https://api.github.test'; + const card = (repo, number, extra = {}) => ({ + number, + node_id: `I_${number}`, + title: 'Console grid drops the sort', + html_url: `https://github.test/${repo}/issues/${number}`, + repository_url: `${API_TEST}/repos/${repo}`, + labels: [{ name: 'bug' }, { name: 'repo:objectui' }], + ...extra, + }); + const REDIRECT = `${API_TEST}/repositories/99/issues/31`; + const landedOn = (where, number = 31) => ({ data: { transferIssue: { issue: { number, url: `https://github.test/${where}/issues/${number}`, repository: { nameWithOwner: where } } } } }); + // A board that MOVES the card when the mutation lands (or when the relay fake says it did). + const board = (overrides = {}) => { + const state = { moved: false }; + const answers = { + [`GET /repos/${SRC}/issues/7`]: (init) => { + if (!state.moved) return { status: 200, json: card(SRC, 7) }; + if (init.redirect === 'manual') return { status: 301, json: { message: 'Moved Permanently', url: REDIRECT }, headers: { location: REDIRECT } }; + return { status: 200, json: card(UI, 31, { labels: [{ name: 'bug' }] }) }; + }, + [`GET /repos/${UI}`]: () => ({ status: 200, json: { node_id: 'R_ui', full_name: UI } }), + 'POST /graphql': () => { + state.moved = true; + return { status: 200, json: landedOn(UI) }; + }, + [`GET /repos/${UI}/issues/31`]: () => (state.moved ? { status: 200, json: card(UI, 31, { labels: [{ name: 'bug' }] }) } : { status: 404, json: { message: 'Not Found' } }), + ...overrides, + }; + return { state, answers }; + }; + const platform = (answers, seen) => async (url, init) => { + const u = new URL(url); + const call = `${init?.method ?? 'GET'} ${u.pathname}`; + seen.push({ call, redirect: init?.redirect ?? 'follow', body: init?.body ? JSON.parse(init.body) : null, auth: init?.headers?.authorization ?? '' }); + const make = answers[call]; + const a = make ? make(init ?? {}) : { status: 404, json: { message: 'Not Found' } }; + if (a.throws) throw new Error(a.throws); + return { status: a.status, headers: new Headers({ 'x-ratelimit-remaining': '4999', ...(a.headers ?? {}) }), json: async () => a.json }; + }; + const DIRECT_ROUTE = { requested: 'direct', transport: 'direct', reason: 'self-test: direct', error: null, session: null }; + const drive = async (b, { file, route, send, token = TOKEN_FIXTURE, planOverride } = {}) => { + const seen = []; + const pace = paceFor(file ?? join(dir, `pace-${paceCase++}.jsonl`)); + try { + const r = await transferIssue(planOverride ?? plan, { fetch: platform(b.answers, seen), token, pace, route: route ?? DIRECT_ROUTE, send }); + return { ...r, seen, pace, text: r.lines.join('\n') }; + } catch (e) { + if (e?.throttleExit === undefined) throw e; + return { exitCode: e.throttleExit, lines: [], seen, pace, throttled: true, text: '' }; + } + }; + const posts = (seen) => seen.filter((s) => s.call.startsWith('POST ')); + + // ── the pre-read ──────────────────────────────────────────────────────── + battery('the pre-read: a pull request, a card already moved, or an unreadable card is refused before any write'); + { + const pull = await drive(board({ [`GET /repos/${SRC}/issues/7`]: () => ({ status: 200, json: card(SRC, 7, { pull_request: { url: 'x' } }) }) })); + t('a number that is a pull request is usage (2), and nothing is sent', [pull.exitCode, posts(pull.seen).length, pull.text.includes('is a pull request')], [EXIT_USAGE, 0, true]); + const moved = await drive(board({ [`GET /repos/${SRC}/issues/7`]: () => ({ status: 200, json: card(UI, 31) }) })); + t('a card that already answers from another repository is usage (2), naming where it lives, and nothing is sent', [moved.exitCode, posts(moved.seen).length, moved.text.includes(`it lives on ${UI} as #31`), moved.to], [EXIT_USAGE, 0, true, { repo: UI, number: 31, url: `https://github.test/${UI}/issues/31` }]); + const missing = await drive(board({ [`GET /repos/${SRC}/issues/7`]: () => ({ status: 404, json: { message: 'Not Found' } }) })); + t('a card that cannot be read is a platform refusal (5), and nothing is sent', [missing.exitCode, posts(missing.seen).length], [EXIT_PLATFORM_REFUSAL, 0]); + const down = await drive(board({ [`GET /repos/${SRC}/issues/7`]: () => ({ throws: 'ECONNRESET' }) })); + t('an unreachable platform is exit 3', down.exitCode, EXIT_PREREQUISITE); + const noToken = await drive(board(), { token: '' }); + t('no token is exit 3 before any request, naming with-fleet.sh', [noToken.exitCode, noToken.seen.length, noToken.text.includes('with-fleet.sh')], [EXIT_PREREQUISITE, 0, true]); + } + + // ── the direct transport ──────────────────────────────────────────────── + battery("the direct transport: the target's node id, then ONE paced mutation carrying both node ids — the relay row's own query"); + { + const ok = await drive(board(), { file: paceFile }); + t('a direct transfer: the pre-read, the target, ONE mutation, then the two read-backs — exit 0', [ok.exitCode, ok.seen.map((s) => s.call)], [EXIT_OK, [`GET /repos/${SRC}/issues/7`, `GET /repos/${UI}`, 'POST /graphql', `GET /repos/${SRC}/issues/7`, `GET /repos/${UI}/issues/31`]], ok.text); + t("the mutation carries both node ids, under the relay row's own query", [posts(ok.seen)[0].body.variables, posts(ok.seen)[0].body.query], [{ issueId: 'I_7', repositoryId: 'R_ui' }, OPS.transfer.requests(plan.action)[0].graphql.query]); + t('every request carried the bearer token', ok.seen.every((s) => s.auth === `Bearer ${TOKEN_FIXTURE}`)); + const refused = await drive(board({ 'POST /graphql': () => ({ status: 200, json: { data: { transferIssue: null }, errors: [{ message: 'Resource not accessible by integration' }] } }) })); + t('a refused mutation is exit 5 carrying the platform\'s sentence and the installation remedy', [refused.exitCode, refused.text.includes('Resource not accessible by integration'), refused.text.includes("objectstack-fleet App's repository access")], [EXIT_PLATFORM_REFUSAL, true, true]); + const uncovered = await drive(board({ [`GET /repos/${UI}`]: () => ({ status: 404, json: { message: 'Not Found' } }) })); + t('a target whose node id cannot be read is exit 5 with the remedy, and no mutation is sent', [uncovered.exitCode, posts(uncovered.seen).length, uncovered.text.includes('Remedy:')], [EXIT_PLATFORM_REFUSAL, 0, true]); + const exhausted = await drive(board({ 'POST /graphql': () => ({ status: 403, headers: { 'x-ratelimit-remaining': '0' }, json: { message: 'API rate limit exceeded' } }) })); + t('a 403 with the quota exhausted is exit 3 that binds the identity — no remedy about the installation', [exhausted.exitCode, exhausted.text.includes('binds the IDENTITY'), exhausted.text.includes('Remedy:')], [EXIT_PREREQUISITE, true, false]); + const elsewhere = await drive(board({ 'POST /graphql': () => ({ status: 200, json: landedOn(`${TARGET_OWNER}/cloud`) }) })); + t('an answer that places the card on another repository is exit 4 — the board disagrees', elsewhere.exitCode, EXIT_BOARD_DISAGREES); + } + + // ── the read-back ─────────────────────────────────────────────────────── + battery('the read-back: the old URL answers 301 to the new card, which answers from the target with the same title'); + { + const ok = await drive(board()); + t('the success line names both addresses; the read-back line names the 301 and the label the target dropped', [ok.text.includes(`✓ issue-transfer: ${SRC}#7 → ${UI}#31`), ok.text.includes('the old URL answers 301 to #31') && ok.text.includes('dropped (no same-named label on the target): repo:objectui')], [true, true], ok.text); + t('…the old URL was read WITHOUT following its redirect, the pre-read with it', ok.seen.filter((s) => s.call === `GET /repos/${SRC}/issues/7`).map((s) => s.redirect), ['follow', 'manual']); + const stuck = await drive(board({ 'POST /graphql': () => ({ status: 200, json: landedOn(UI) }) })); + t('a mutation that answered while the old URL still serves the card is exit 4', [stuck.exitCode, stuck.text.includes('the card did not move')], [EXIT_BOARD_DISAGREES, true]); + const b404 = board(); + const gone = await drive({ answers: { ...b404.answers, [`GET /repos/${SRC}/issues/7`]: (init) => (init.redirect === 'manual' ? { status: 404, json: { message: 'Not Found' } } : b404.answers[`GET /repos/${SRC}/issues/7`](init)) } }); + t('an old URL that answers neither 301 nor 200 is UNCONFIRMED (6), never a success', [gone.exitCode, gone.text.includes('UNCONFIRMED')], [EXIT_UNCONFIRMED, true]); + const bo = board(); + bo.answers['POST /graphql'] = () => { + bo.state.moved = true; + return { status: 200, json: landedOn(UI, 30) }; + }; + const other = await drive(bo); + t('a redirect naming another number than the mutation answered is exit 4, both numbers printed', [other.exitCode, other.text.includes('the mutation answered #30, the old URL redirects to #31')], [EXIT_BOARD_DISAGREES, true], other.text); + const bRenamed = board(); + const renamed = await drive({ answers: { ...bRenamed.answers, 'POST /graphql': bRenamed.answers['POST /graphql'], [`GET /repos/${UI}/issues/31`]: () => ({ status: 200, json: card(UI, 31, { title: 'something else' }) }) } }); + t('a new card under another title is exit 4', [renamed.exitCode, renamed.text.includes('titled "something else"')], [EXIT_BOARD_DISAGREES, true]); + t('the redirect number is read from the url, else the location header, else nothing', [numberFromRedirect({ json: { url: REDIRECT } }), numberFromRedirect({ json: {}, location: `${API_TEST}/repositories/9/issues/12` }), numberFromRedirect({ json: { url: `${API_TEST}/repos/x/y` } })], [31, 12, null]); + } + + // ── the relay transport ───────────────────────────────────────────────── + battery('the relay transport: ONE dispatch carrying ONE transfer, the new number read from the redirect, a failed run names the remedy and is never fallen back from'); + { + const SESSION = 'session_01ABCDEFGHJKMNPQRSTVWXYZ'; + const dispatchRoute = (requested = 'dispatch') => ({ requested, transport: 'dispatch', reason: 'self-test: dispatch', error: null, session: SESSION }); + const RUN = { id: 42, url: 'https://github.test/run/42', status: 'completed', conclusion: 'success' }; + const outcome = (state, b, extra = {}) => async (payload) => { + if (state === 'success') b.state.moved = true; + return { state, ok: state === 'success', status: state === 'refused' ? 404 : 204, verdict: state === 'refused' ? 'refusal' : 'ok', requestId: payload.request_id, startMs: 1, ceilingMs: 2, run: state === 'no-run' || state === 'refused' ? null : RUN, detail: '', ...extra }; + }; + const sentPayloads = []; + const b = board(); + const ok = await drive(b, { route: dispatchRoute(), send: async (p) => { sentPayloads.push(p); return outcome('success', b)(p); } }); + t('under dispatch ONE payload carries ONE transfer, with the session and the source', [sentPayloads.length, sentPayloads[0]?.session, sentPayloads[0]?.repo, sentPayloads[0]?.actions], [1, SESSION, SRC, [{ op: 'transfer', issue: 7, target_repo: UI }]]); + t('…no POST left this process; the new number came from the redirect and was read back', [ok.exitCode, ok.to?.number, ok.seen.map((s) => s.call)], [EXIT_OK, 31, [`GET /repos/${SRC}/issues/7`, `GET /repos/${SRC}/issues/7`, `GET /repos/${UI}/issues/31`]], ok.text); + const bf = board(); + const failed = await drive(bf, { route: dispatchRoute('auto'), send: outcome('failure', bf, { run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' }) }); + t('⛔ a run that FAILED is exit 5 naming the installation remedy, never fallen back from even under auto: no POST', [failed.exitCode, failed.text.includes('Remedy:') && failed.text.includes(UI), posts(failed.seen).length], [EXIT_PLATFORM_REFUSAL, true, 0]); + const br = board(); + const refused = await drive(br, { route: dispatchRoute(), send: outcome('refused', br) }); + t('a dispatch the platform refused is exit 5 with no installation remedy — nothing ran', [refused.exitCode, refused.text.includes('Remedy:')], [EXIT_PLATFORM_REFUSAL, false]); + const bt = board(); + const timedOut = await drive(bt, { route: dispatchRoute(), send: outcome('timeout', bt, { run: { ...RUN, status: 'in_progress', conclusion: null } }) }); + t('a run that did not complete within the ceiling is exit 6 with the run url', [timedOut.exitCode, timedOut.text.includes('https://github.test/run/42'), posts(timedOut.seen).length], [EXIT_UNCONFIRMED, true, 0]); + const bn = board(); + const noRun = await drive(bn, { route: dispatchRoute('dispatch'), send: outcome('no-run', bn) }); + t('under an EXPLICIT dispatch, no run is exit 6 — no fall-back', [noRun.exitCode, posts(noRun.seen).length], [EXIT_UNCONFIRMED, 0]); + const ba = board(); + const fallback = await drive(ba, { route: dispatchRoute('auto'), send: outcome('no-run', ba) }); + t('under AUTO, no run falls back to the direct mutation — said out loud — and the card is read back', [fallback.exitCode, fallback.text.includes('Falling back to DIRECT'), posts(fallback.seen).map((s) => s.call)], [EXIT_OK, true, ['POST /graphql']]); + // A run that reports success while the board never moved: the read-back, not the run, decides. + const bs = board(); + const lied = await drive(bs, { route: dispatchRoute(), send: async (p) => ({ state: 'success', ok: true, status: 204, verdict: 'ok', requestId: p.request_id, startMs: 1, ceilingMs: 2, run: RUN, detail: '' }) }); + t('a run that succeeded while the old URL still serves the card is exit 4, never a success', [lied.exitCode, lied.text.includes('the card did not move')], [EXIT_BOARD_DISAGREES, true]); + } + + // ── dry-run ───────────────────────────────────────────────────────────── + battery('dry-run: no request leaves, and the plan is printed'); + { + const spawned = spawnSync(process.execPath, [SELF_PATH, '--dry-run', '--repo', SRC, '--issue', '7', '--to', UI], { + encoding: 'utf8', + env: { ...process.env, OS_PM_WRITE_PACE_FILE: join(dir, 'dry-pace.jsonl'), GITHUB_TOKEN: TOKEN_FIXTURE, GH_TOKEN: '', HTTPS_PROXY: '', https_proxy: '' }, + }); + t('--dry-run exits 0 and prints the stroke it did not send', [spawned.status, spawned.stdout.includes('NOTHING was sent') && spawned.stdout.includes('transferIssue') && spawned.stdout.includes(`${SRC}#7`) && spawned.stdout.includes(UI)], [0, true], spawned.stderr.slice(-300)); + t('…and the throttle recorded nothing: a dry run makes no write', existsSync(join(dir, 'dry-pace.jsonl')), false); + t('a refused plan is usage, printed, and nothing is sent', spawnSync(process.execPath, [SELF_PATH, '--dry-run', '--repo', SRC, '--issue', '7', '--to', SRC], { encoding: 'utf8', env: { ...process.env, HTTPS_PROXY: '', https_proxy: '' } }).status, EXIT_USAGE); + } + + // ── the wiring ────────────────────────────────────────────────────────── + battery('the wiring: both halves around the one write verb, on the roster'); + { + const { WIRED_WRITE_TOOLS } = await import('./write-pace.mjs'); + const own = readReal(SELF_PATH, 'utf8'); + t('write-pace lists this file among the wired write tools', WIRED_WRITE_TOOLS.includes('scripts/pm/issue-transfer.mjs')); + t('this file calls both halves, guarded by the write-verb predicate', own.includes('paceWrite(') && own.includes('noteResponse(') && own.includes('isWriteMethod(')); + const records = readReal(paceFile, 'utf8'); + t('the mutation above was paced; the reads were not', [records.includes('issue-transfer POST'), records.includes('issue-transfer GET')], [true, false]); + t("⛔ and the token never reached the throttle's log", records.includes(TOKEN_FIXTURE), false); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + // ── the floor, BEFORE the verdict ───────────────────────────────────────── + const declared = Object.keys(SELF_TEST_BATTERIES); + const floor = []; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + floor.push(`the battery ledger declares ${declared.length} batteries, below its floor of ${SELF_TEST_BATTERY_FLOOR} — a section that stopped being declared is a section nothing floors.`); + } + for (const [name, count] of batteryCases) { + if (name in SELF_TEST_BATTERIES) continue; + floor.push(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.`); + } + for (const name of declared) { + const count = batteryCases.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + floor.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 floor) cases.push({ name: message, ok: false, detail: '' }); + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ issue-transfer self-test: ${failed.length} of ${cases.length} case(s) failed.`); + selfTestReachedVerdict = true; + return 1; + } + console.log( + `✓ issue-transfer self-test: ${cases.length} cases pass across ${declared.length} batteries — one card to a governed target judged by the relay's own validator, ` + + 'a pull request or an already-moved card refused before any write, one paced mutation or one dispatch, a read-back that demands the 301 and the card at its new ' + + 'address, a failed run that names the installation remedy and is never fallen back from, and both halves of the throttle around the one write.', + ); + selfTestReachedVerdict = true; + return 0; +} + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +const USAGE = [ + 'usage:', + ' node scripts/pm/issue-transfer.mjs [--repo owner/name] --issue --to owner/name [--json] [--dry-run]', + ' node scripts/pm/issue-transfer.mjs --self-test', + '', + ` --to is one of: ${TRANSFER_TARGETS.join(', ')} (the governed roster), never the source.`, + ' One card per call. OS_FLEET_TRANSPORT=direct|dispatch|auto (default auto): dispatch sends ONE transfer through the', + " fleet-write relay as objectstack-fleet[bot]; auto takes it in a cloud seat container (the seat's session is read from the", + " container's CLAUDE_CODE_REMOTE_SESSION_ID; OS_FLEET_SESSION overrides it — a local checkout, a test) and direct elsewhere.", + ` Exits: 0 transferred and read back · ${EXIT_USAGE} usage or refused · ${EXIT_PREREQUISITE} prerequisite · ${EXIT_BOARD_DISAGREES} the board disagrees · ${EXIT_PLATFORM_REFUSAL} platform refused / run failed · ${EXIT_UNCONFIRMED} UNCONFIRMED · ${EXIT_WRITE_PACE_REFUSED} throttle refused`, +].join('\n'); + +function rearmThroughProxy(args) { + const plan = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + guard: PROXY_REARM_GUARD, + }); + 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; + 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 — the request will bypass the proxy.`); + return null; +} + +export async function main(argv) { + const opts = parseArgs(argv); + if (opts.help) { + console.log(USAGE); + return EXIT_OK; + } + if (opts.selfTest) return selfTest(); + if (opts.unknown.length || opts.errors.length) { + for (const e of opts.errors) console.error(`issue-transfer: ${e}`); + if (opts.unknown.length) console.error(`issue-transfer: unrecognised argument ${opts.unknown.map((u) => `\`${u}\``).join(', ')}`); + console.error(`\n${USAGE}`); + return EXIT_USAGE; + } + const plan = planFrom(opts); + if (!plan.ok) { + for (const e of plan.errors) console.error(`✗ issue-transfer: ${e}`); + console.error(' Nothing was sent.'); + return EXIT_USAGE; + } + if (opts.dryRun) { + console.log(dryRunText(plan)); + return EXIT_OK; + } + const rearmed = rearmThroughProxy(argv); + if (rearmed !== null) return rearmed; + const result = await transferIssue(plan); + for (const line of result.lines) console.error(line); + if (opts.json && result.to) console.log(JSON.stringify({ from: result.from, to: result.to, transport: result.transport ?? null, relay_run: result.relay?.run?.url ?? null, exit: result.exitCode })); + return result.exitCode; +} + +if (isEntrypoint(import.meta.url)) { + const code = await main(process.argv.slice(2)); + if (process.argv.includes('--self-test') && !selfTestReachedVerdict) { + console.error( + '\n✗ issue-transfer self-test: selfTest() returned without reaching its verdict, so no success line was\n' + + 'printed. Exiting 0 here would report a self-test that never finished as one that passed.\n', + ); + process.exit(1); + } + process.exit(code); +} diff --git a/scripts/pm/write-pace.mjs b/scripts/pm/write-pace.mjs index 1ce41b9e0c6..acb66d4e6d9 100644 --- a/scripts/pm/write-pace.mjs +++ b/scripts/pm/write-pace.mjs @@ -1286,10 +1286,11 @@ const battery = (name) => { let selfTestReachedVerdict = false; /** - * The write transports this throttle is wired into — the census. Nine: the + * The write transports this throttle is wired into — the census. Ten: the * five board tools, the token minter (its one POST creates no content, but a * write verb is a write verb and the roster below is mechanical), the - * card-creation door, and the two halves of the fleet-write relay — the + * card-creation door, the card-transfer door, and the two halves of the + * fleet-write relay — the * seat-side dispatcher (its one POST is the `repository_dispatch` that * carries a stroke) and the runner-side executor (the writes that stroke asked * for, paced on the runner's own log). @@ -1309,6 +1310,7 @@ export const WIRED_WRITE_TOOLS = Object.freeze([ 'scripts/pm/sweep-stale-finding.mjs', 'scripts/pm/fleet-token.mjs', 'scripts/pm/issue-create.mjs', + 'scripts/pm/issue-transfer.mjs', 'scripts/pm/fleet-write/dispatch.mjs', 'scripts/pm/fleet-write/execute.mjs', ]); From 5d9c568f46afdd485b6ebde1ab6d0abf6e3cffd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 15:43:52 +0000 Subject: [PATCH 4/6] wip(pm): check:pm-issue-transfer row, and the coordination rule points at the door Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- .../pm-dispatch/references/cross-repo-coordination.md | 2 +- package.json | 1 + scripts/pm/fleet-write/ops.mjs | 3 ++- scripts/pm/issue-transfer.mjs | 7 +++++-- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/cross-repo-coordination.md b/.claude/skills/pm-dispatch/references/cross-repo-coordination.md index d37037a0932..b1eb6acfdba 100644 --- a/.claude/skills/pm-dispatch/references/cross-repo-coordination.md +++ b/.claude/skills/pm-dispatch/references/cross-repo-coordination.md @@ -22,7 +22,7 @@ - 工装只答被告知的仓,缺 `--repo`/`PM_SWEEP_REPO` 即答 objectstack;⛔ 不答手上那张卡的仓。 - 规则 1:issue 住在修复落地的仓,分诊时按判据严格执行。 - 判据:正文抽掉 objectstack 还成立 ⇒ 当场转仓(console/UI 缺陷即转 objectui);不成立才是缝卡。 -- transfer 不可用时重建:出处头 + 裸 `#N` 改全名 + 关源单为 moved。 +- 转仓走 `issue-transfer.mjs`;名册外或平台拒才重建:出处头 + 裸 `#N` 改全名 + 关源单为 moved。 - 缝卡收窄为真协调卡:留 objectstack 带 `repo:*`,正文点名读者(哪个座位、哪一步)。 - 决策收件箱按仓:平台在 objectstack / objectui,元数据项目在本仓;在飞卡 ⛔ 不中途转仓。 - 规则 2:跨层功能点或 objectui 消费的 `Seam:` 卡恒由分诊立父单 + 逐层子单,spec/后端先行。 diff --git a/package.json b/package.json index 5c151d7b5b6..8f3a5b0cfb0 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "check:pm-write-pace": "node scripts/pm/write-pace.mjs --self-test", "check:pm-fleet-token": "node scripts/pm/fleet-token.mjs --self-test", "check:pm-issue-create": "node scripts/pm/issue-create.mjs --self-test", + "check:pm-issue-transfer": "node scripts/pm/issue-transfer.mjs --self-test", "check:pm-with-fleet": "bash scripts/pm/with-fleet.sh --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", diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 8a62bb03b50..58dff3a4e40 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -232,7 +232,8 @@ export function transferRemedy(source, target) { `a transfer needs the fleet App installed on BOTH ${source} and ${target} with issues write on each: a failed mint, a target whose ` + `node id cannot be read, or "Resource not accessible by integration" / "Could not resolve to a Repository" on the mutation means ` + `the installation does not cover ${target} — the maintainer adds it to the objectstack-fleet App's repository access. ` + - '⛔ Not rebuilt by hand meanwhile: a rebuild is only for a target GitHub refuses (outside the organization).' + '⛔ Not rebuilt by hand meanwhile — an installation gap is a setting to fix, not a refusal; the rebuild recipe is for a target ' + + 'outside TRANSFER_TARGETS or a move GitHub refuses outright (another organization, a private card into a public repository).' ); } diff --git a/scripts/pm/issue-transfer.mjs b/scripts/pm/issue-transfer.mjs index 717461790eb..1b2c5de0c78 100644 --- a/scripts/pm/issue-transfer.mjs +++ b/scripts/pm/issue-transfer.mjs @@ -61,8 +61,11 @@ * setting no seat token can read. A failed run, a target whose node id cannot * be read, or a refused mutation is refused here with `transferRemedy` * (`fleet-write/ops.mjs`): the maintainer adds the target to the App's - * repository access. ⛔ The door never falls back to a rebuild; that recipe - * stays for a target GitHub refuses outright (outside the organization). + * repository access. ⛔ The door never falls back to a rebuild: an + * installation gap is a setting to fix. The rebuild recipe + * (`.claude/skills/pm-dispatch/references/cross-repo-coordination.md`) stays + * for a target outside `TRANSFER_TARGETS` and for a move GitHub refuses + * outright — another organization, a private card into a public repository. * * ## Exit codes — capture them BEFORE any pipe (issue-create's ladder) * From 72bc4eaab1c56a1d02278faf09bad4f4607e95e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:22:33 +0000 Subject: [PATCH 5/6] test(pm): issue-transfer's read-back pins that only a 301 is read as the move Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- scripts/pm/issue-transfer.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/pm/issue-transfer.mjs b/scripts/pm/issue-transfer.mjs index 1b2c5de0c78..b7de88ad15c 100644 --- a/scripts/pm/issue-transfer.mjs +++ b/scripts/pm/issue-transfer.mjs @@ -383,7 +383,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ "the arguments: one card, a source in the organization, a target from the governed roster that is not the source — judged by the relay's own validator": 11, 'the pre-read: a pull request, a card already moved, or an unreadable card is refused before any write': 5, "the direct transport: the target's node id, then ONE paced mutation carrying both node ids — the relay row's own query": 7, - 'the read-back: the old URL answers 301 to the new card, which answers from the target with the same title': 7, + 'the read-back: the old URL answers 301 to the new card, which answers from the target with the same title': 8, 'the relay transport: ONE dispatch carrying ONE transfer, the new number read from the redirect, a failed run names the remedy and is never fallen back from': 8, 'dry-run: no request leaves, and the plan is printed': 3, 'the wiring: both halves around the one write verb, on the roster': 4, @@ -541,7 +541,11 @@ export async function selfTest() { t('a mutation that answered while the old URL still serves the card is exit 4', [stuck.exitCode, stuck.text.includes('the card did not move')], [EXIT_BOARD_DISAGREES, true]); const b404 = board(); const gone = await drive({ answers: { ...b404.answers, [`GET /repos/${SRC}/issues/7`]: (init) => (init.redirect === 'manual' ? { status: 404, json: { message: 'Not Found' } } : b404.answers[`GET /repos/${SRC}/issues/7`](init)) } }); - t('an old URL that answers neither 301 nor 200 is UNCONFIRMED (6), never a success', [gone.exitCode, gone.text.includes('UNCONFIRMED')], [EXIT_UNCONFIRMED, true]); + t('an old URL that answers neither 301 nor 200 is UNCONFIRMED (6), never a success', [gone.exitCode, gone.text.includes('UNCONFIRMED') && gone.text.includes('not the 301 a moved card answers')], [EXIT_UNCONFIRMED, true]); + // Only the PERMANENT redirect a moved card answers counts — a temporary one naming the same card is not that fact. + const b307 = board(); + const temporary = await drive({ answers: { ...b307.answers, [`GET /repos/${SRC}/issues/7`]: (init) => (init.redirect === 'manual' && b307.state.moved ? { status: 307, json: { url: REDIRECT }, headers: { location: REDIRECT } } : b307.answers[`GET /repos/${SRC}/issues/7`](init)) } }); + t('⛔ a 307 naming the new card is UNCONFIRMED too — only the 301 a moved card answers is read as the move', [temporary.exitCode, temporary.text.includes('HTTP 307')], [EXIT_UNCONFIRMED, true], temporary.text); const bo = board(); bo.answers['POST /graphql'] = () => { bo.state.moved = true; From 425752b2058eebd6fd907db03f61707c282f74eb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 16:23:37 +0000 Subject: [PATCH 6/6] docs(pm): rewrap the fleet-write token comments Claude-Session: https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq Co-authored-by: Claude --- .github/workflows/fleet-write.yml | 8 ++++---- scripts/pm/write-pace.mjs | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/fleet-write.yml b/.github/workflows/fleet-write.yml index 0e391fd661b..8bd6608e85f 100644 --- a/.github/workflows/fleet-write.yml +++ b/.github/workflows/fleet-write.yml @@ -18,10 +18,10 @@ # ⛔ The token this run WRITES with is the App installation token step 3 # mints — narrowed to the payload's repository (and, for a `transfer` stroke # alone, that transfer's one target too: `transferIssue` needs issues write on -# both ends) and to the permissions the op table declares (`issues: write`, `pull-requests: write`, the -# `contents: write` GitHub's auto-merge mutations require, and the -# `metadata: read` the sender gate's permission read needs) — and revoked by -# that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing +# both ends) and to the permissions the op table declares (`issues: write`, +# `pull-requests: write`, the `contents: write` GitHub's auto-merge mutations +# require, and the `metadata: read` the sender gate's permission read needs) +# — and revoked by that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing # (`permissions: {}` below); the job grants it `contents: read` for the # checkout and nothing else, so the run's ONLY write path is the App token, # and it exists only inside the execute step's environment. diff --git a/scripts/pm/write-pace.mjs b/scripts/pm/write-pace.mjs index acb66d4e6d9..d58491d0a6e 100644 --- a/scripts/pm/write-pace.mjs +++ b/scripts/pm/write-pace.mjs @@ -1290,10 +1290,9 @@ let selfTestReachedVerdict = false; * five board tools, the token minter (its one POST creates no content, but a * write verb is a write verb and the roster below is mechanical), the * card-creation door, the card-transfer door, and the two halves of the - * fleet-write relay — the - * seat-side dispatcher (its one POST is the `repository_dispatch` that - * carries a stroke) and the runner-side executor (the writes that stroke asked - * for, paced on the runner's own log). + * fleet-write relay — the seat-side dispatcher (its one POST is the + * `repository_dispatch` that carries a stroke) and the runner-side executor + * (the writes that stroke asked for, paced on the runner's own log). * * Spelled REPO-RELATIVE, because the dispatch derivation reads every * separator-bearing literal in a gate's source as a path lead: a bare