Skip to content

Commit cb8756b

Browse files
feat(fleet-write): pr_create carries labels; os-dev declares skip-changeset in the create's stroke (#24175)
Fixes #24157 Clause-②: no ## What this PR does Two parts, one PR, the card's route 1 + 3 (fold `skip-changeset` into the `pr_create` stroke; rewrite the 「席位代做」 line). Option 4 (relaxing `Check Changeset`) is a gate change and is not here. ### 1. The relay op — `pr_create` carries `labels` - `scripts/pm/fleet-write/ops.mjs`: `pr_create` gains an optional `labels` list. It becomes a later request of the SAME action, `POST /repos/REPO/issues/NUMBER/labels` with `fromAnswer: 'number'` and `labelled: [...a.labels]` — the exact shape the `assignees` leg has (`assigned`). The row still spends `pull-requests` with `alsoSpends: ['issues']`, so the token minted for the stroke already covers the labels POST; `PERMISSIONS` is unchanged and the workflow's mint step needed no edit. - `scripts/pm/fleet-write/validate.mjs`: the typed check is the one `labels_add` already uses — `FIELDS.labels` (array of 1..20 strings, each ≤ 50 bytes, no duplicates) is applied to any op whose key list admits `labels`, so adding `labels` to the row's `optional` is the whole validator change; the self-test gains the `pr_create` labels cases beside the assignees ones, the minimal-shape ledger names `labels`, and the 「exactly ONE descriptor reads an earlier answer」 pin becomes 「exactly TWO — assignees then labels」, with a new pin that every such leg is an additive POST to `/assignees` or `/labels`, never the whole-set replace. - `scripts/pm/fleet-write/execute.mjs` — **the one place that had to learn the leg** (the dispatch order assumed none): ① `requestLanded` judges a `labelled` descriptor's 2xx against the names sent — the labels endpoint answers the pull's whole label set, so a name it lacks is a FAILED action naming it, never a quiet partial; more than sent (the Auto Label workflow's `documentation` / `size/*`) is a landing. ② `resultOf` prints the labels as the platform lists them on that row. ③ `fromAnswer` legs are now filled from the action's FIRST answer (the create's), not the PREVIOUS request's: with both legs present the previous answer of the labels leg would have been the assignee POST's issue object, which happens to carry a number but is not the create. The self-test pins the label leg landing on the number the create answered while the assignee answer carries a different one. - `scripts/pm/fleet-write/dispatch.mjs` and `.github/workflows/fleet-write.yml` need nothing: dispatch's self-test is green unchanged (222 cases), and the workflow only runs `validate.mjs --from-env --github-output` then `execute.mjs`. ### 2. The dev contract — `.claude/agents/os-dev.md`, 404 → 404 lines (ceiling 404, headroom 0) Four lines rewritten in place, no line added or removed. Each line's old text and where every dropped clause survives: | line | before | after | dropped clause → survival | |---|---|---|---| | 61 | 写预算四笔:`git push`、`pr_create`(draft,含 assignee)、`label-write`(仅标签)、`os-dev-report` 评论 | 写预算三笔:`git push`、`pr_create`(draft,含 assignee 与 labels)、`os-dev-report` 评论 | `label-write`(仅标签) as a budgeted stroke → it is no longer a default stroke; the late-label case is named on line 303 | | 303 | objectstack:标签是真实机制,打标签是你的默认步骤,PR 一开出就打;派发词可收窄或禁写 | objectstack:`skip-changeset` 以 `pr_create` 的 `labels` 同笔申报;后发现的标签才走 `label-write.mjs` | 「打标签是你的默认步骤,PR 一开出就打」→ strengthened: declared in the create's stroke, not after; 「标签是真实机制」→ the premise lines 306–307 rest on (a gate reads the label); 「派发词可收窄或禁写」→ line 304 verbatim: 范围 = 派发词点名的标签 + …;禁写或交集为空 ⇒ 零写并报告 | | 305 | 写恒经 `scripts/pm/label-write.mjs`:取现集、加法 POST、回读比 union、缺者重挂一次并报告 | 该腿的回读是中继回报的行:答案须列出每个送出的标签,缺者该笔 FAILED、不发后续动作 | the four steps are what `label-write.mjs` itself performs and prints on every invocation (its header: 「the four-step label write, as ONE spelling … Every invocation performs all four … re-added ONCE and REPORTED」), so the contract line was a mirror of the tool header, which AGENTS.md makes the authority; 「写恒经 scripts/pm/」→ line 55 (GitHub 写一律经 `scripts/pm/` 写工具) and line 303 names the tool for the late-label case | | 309 | 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位代做;⛔ 不换路重发(curl/MCP/手工) | 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位只转维护者定夺;⛔ 不换路重发 | 「席位代做」→ replaced by design (the card's finding: the seat's harness forbids substituting for a refused action); 「(curl/MCP/手工)」→ line 308 「不走 MCP」, AGENTS.md 「⛔ Never a bare `curl` / `fetch` / `gh api -X POST…` write」, and 「不换路重发」 covers any manual route | Lines 61 / 303 / 305 / 309 measure 104 / 118 / 119 / 117 bytes against the 120-byte line budget. No re-wrap, no merge, no ceiling raise. ## Mechanism assumptions, measured - **(a) confirmed** at the base `bd4276a91` (origin/main had advanced from the order's `697301491` when the worktree was cut): `pr_create` had `optional: ['body', 'assignees']` only; the filer's 「the relay's `pr_create` op takes `labels`」 was false, so option 1 needed the op leg. - **(b) stands, and nothing here can prove more:** the classifier refusal was measured once (PR #24153 refused, PR #24139 allowed, identical `label-write.mjs` command). This PR cannot show the classifier will accept a `pr_create` carrying `labels`; the measured fact is that the `pr_create` stroke has not been refused since the assignee precedent #22467. This PR's own `skip-changeset` had to go the OLD way (`label-write.mjs`) because a PR cannot use the leg it introduces. - **(c) confirmed:** `pr-automation.yml` writes `size/*` and the path labels by additive POST (the `Auto Label` and `Check PR Size` jobs), so the two never overwrite each other. `Check Changeset` reads the label live (a re-read plus a settling read, because the frozen `opened` payload cannot see a label applied after creation; its own comment measures label latency at 10–45 s after creation). The leg lands inside the relay's own stroke, seconds after the create, so it falls inside that settling window — and the `labeled` event re-triggers the job in any case. - **(d) run** — see Verification. **(e) read** the current lines (the card's quotation of line 61 and the labels block matched the tree at the base). - **Falsified:** 「read `execute.mjs` … to confirm nothing else must learn the new leg」 — it did: without `labelled` the executor would have passed a 200 whose answer lacks the label (ablation B, below, shows exactly that quiet partial), and the fill source had to move from the previous answer to the create's (ablation A). ## Verification (final commit `f9e63d6c3`) Relay self-tests (rule ⑤ — the edited scripts' own suites; no `*.test.*` file names them): - `node scripts/pm/fleet-write/validate.mjs --self-test` → exit 0, 158 cases / 11 batteries (was 152). - `node scripts/pm/fleet-write/execute.mjs --self-test` → exit 0, 128 cases / 16 batteries (was 120; the assignees-leg battery is re-declared in `SELF_TEST_BATTERIES` as the assignees-and-labels battery, pin 11 → 19). - `node scripts/pm/fleet-write/dispatch.mjs --self-test` → exit 0, 222 cases / 20 batteries (unchanged). - `ops.mjs` has no `--self-test` of its own (its pins live in the validate and execute suites). Ablations — each via `node scripts/ablation-replace.mjs` (anchor hit ×1, blob changed, command run, restore proven by blob == HEAD blob and `git diff HEAD` empty; no `dist/` is involved, these are plain node scripts): - **A** `execute.mjs`: `if (opener === null) opener = r;` → `opener = r;` (back to previous-answer fill). Expected red; observed 2 of 128 red — 「at the number the CREATE answered, not the assignee answer's」 and the row-text case. Blob `f246498…` → `af59de9…` → restored `f246498…`. - **B** `execute.mjs`: `const missing = req.labelled.filter(` → `const missing = [].filter(`. Expected red; observed 1 of 128 red — 「a 200 whose answer lacks a name sent is a FAILED action … never a quiet partial」 (the run passed with exit 0 and 3 writes under the mutation: the quiet partial). Blob `f246498…` → `69e1572…` → restored `f246498…`. - **C** `ops.mjs`: `optional: ['body', 'assignees', 'labels']` → `['body', 'assignees']`. Expected red; observed 6 of 158 validator cases red (the labels acceptance cases, the minimal-shape ledger, the two-descriptor pin). Blob `6ad2cb6…` → `83deec1…` → restored `6ad2cb6…`. Gates — derived at `f9e63d6c3` with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; 48 families, the path-named derivation at the base plus `check:pm-write-pace`, which names `execute.mjs`). Every command run in the foreground with its exit code captured before any pipe: all 48 exit 0. One of them, `pnpm --filter @objectstack/lint run check:doc-formula-expressions`, first answered exit 3 (PREREQUISITE NOT MET: no `@objectstack/formula` / `@objectstack/lint` build in the fresh worktree — a non-measurement, not counted); after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` behind `scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0) it re-ran to exit 0. Reconciled with `--ran`: `node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack` → exit 0, 「48 derived, 48 run, 0 NOT-MEASURED, 0 UNRUN」, every family carrying a recorded exit code. The five families the order named (`check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:pm-governed-prose`, `check:agent-model-declared`, `check:pm-settings-deny-roster`) are each exit 0. Repo-wide scans (`pnpm lint`, `test:repo`) are CI's; the PR's check-runs are the authority for every family CI runs. ## Acceptance notes - `.claude/skills/pm-dispatch/references/review-checklist.md` line 22 still tells the seat 「dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位自核 tests/docs-only 即自挂」 — the seat-side twin of the line this PR rewrites, outside this card's file surface. Reported in the dev report for the seat; not changed here. - `dispatch.mjs`'s seat-side read-back (`assigneesReading`) has no labels twin; the relay row (`execute.mjs`) is the read-back line 305 now names. Observation only, nothing filed. - Governed surface, Tier S (`.claude/**`): this PR stays draft and awaits a `## Contract review` record; `scripts/pm/**` and `.claude/**` publish nothing → `skip-changeset`, applied the old way (reported in the dev report with endpoint and status if refused). ## 维护者速读(草稿) **改了什么**:中继的 `pr_create` 操作新增可选 `labels`,开 PR 的同一笔里顺手贴标签(和已有的 assignee 腿同一形状);执行器只认「答案里列出了每个送出的标签」才算落地,缺一个就报 FAILED 停下。`os-dev.md` 四行原地改写:写预算四笔变三笔;`skip-changeset` 在开 PR 那一笔申报,事后才发现的标签才用 `label-write.mjs`;分类器拒写时席位只转维护者定夺、不代做。行数 404 → 404,未抬上限。 **为什么改**:同一条 `label-write` 命令,一次放行一次被拒(PR #24139 / PR #24153),被拒后原文让「席位代做」,而席位的 harness 把替子代理补做被拒动作视为洗权限,于是每次被拒都变成一次向您提问。把标签并入从未被拒的 `pr_create` 一笔,是 #22467 处理 assignee 时走过的同一条路。 **风险与代价(含回滚)**:不能证明分类器一定放行带 `labels` 的 `pr_create`,只能说该笔自 #22467 以来未被拒过。本 PR 自身的 `skip-changeset` 仍按旧路贴(新腿要合并后才可用)。回滚 = revert 本 PR 一个 commit;中继无状态,不影响已开 PR。 **席位意见**:(留空,席位定稿) **你要做的**:本 PR 触及 `.claude/**`(Tier S),由席位做达档复核后入队落地,无需您点击;如希望顺手也改 `review-checklist.md` 第 22 行的席位侧措辞,请一句话说明,席位另立卡。 --- _Generated by [Claude Code](https://claude.ai/code/session_012PxNHU3dfov2hbpg9ycZ8q)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f1c0b57 commit cb8756b

5 files changed

Lines changed: 130 additions & 74 deletions

File tree

‎.claude/agents/os-dev.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ model: opus
5858
- 写工具自读所需变量;⛔ 永不打印环境变量的值,转录是 AGENTS.md 禁落令牌的日志行。
5959
- 拼法不限于 `env`、`printenv`、`set`、`export -p`、`echo $VAR`、`cat /proc/*/environ`。
6060
- 判存在只用不印值形式 `[ -n "${X+x}" ] && echo set`;已印出 ⇒ `deviations` 只点名变量名。
61-
- 写预算四笔:`git push`、`pr_create`(draft,含 assignee)、`label-write`(仅标签)、`os-dev-report` 评论。
61+
- 写预算三笔:`git push`、`pr_create`(draft,含 assignee 与 labels)、`os-dev-report` 评论。
6262
- 卡与线程只走 payload 档(公开仓单卡网页内嵌 JSON,拼写住 platform-readings)或单卡 REST 读。
6363
- 三类发现进报告交席位代立,不 `POST /issues`、⛔ 不静默弃报;PM 去重读数当既有事实用。
6464
- PR 正文 dev 只写一次,在开 PR 那一笔,⛔ 不 `PATCH`;事后要改的报告点名改法,席位代写。
@@ -300,13 +300,13 @@ model: opus
300300
- 快速通道:`docs/adr/**` · `.claude/**` · `scripts/pm/**` · 仓根配置 · 私有包 · 注释,不发布。
301301
- 其余实测:构建后 grep `files[]` 所列路径找符号,带正控;符号零命中、正控命中 ⇒ 不发布。
302302
- PR 改到已有的 `.changeset/*.md`:⛔ 永不打 `skip-changeset`,门禁红着是对的,确认走 PR 文字。
303-
- objectstack:标签是真实机制,打标签是你的默认步骤,PR 一开出就打;派发词可收窄或禁写。
303+
- objectstack:`skip-changeset` 以 `pr_create` 的 `labels` 同笔申报;后发现的标签才走 `label-write.mjs`。
304304
- 范围 = 派发词点名的标签 + 上文判据下的 `skip-changeset`;禁写或交集为空 ⇒ 零写并报告。
305-
- 写恒经 `scripts/pm/label-write.mjs`:取现集、加法 POST、回读比 union、缺者重挂一次并报告。
305+
- 该腿的回读是中继回报的行:答案须列出每个送出的标签,缺者该笔 FAILED、不发后续动作。
306306
- 读回只检测剥除防不了(size-labeler 整组 PUT),门标签被剥恰成绿灯;加法写同样必要不充分。
307307
- 关此步骤的是读回不是写入;读回只验写落了,验不出有门在读:幻影门标签读回照样成功。
308308
- 被拒 ⇒ 报端点与状态码、席位代挂,⛔ 不报 `blocked`、不走 MCP;收尾再读一次。
309-
- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位代做;⛔ 不换路重发(curl/MCP/手工)。
309+
- 分类器拒外部写 ⇒ 停手,`deviations` 记命令与拒因,席位只转维护者定夺;⛔ 不换路重发。
310310
- objectui:路径标签归 `labeler.yml`,逐 push 同步;无门禁读你打的标签 ⇒ 派发词未点名即零写。
311311
- objectui 的 `skip-changeset` 零读者;空 frontmatter changeset 即声明,门禁判定行为准,⛔ 永不施加。
312312
- 报告在本地验证走完时交付,CI 收敛等待归 PM 不归你:⛔ 不为等 CI 结论推迟报告。

‎.claude/skills/pm-dispatch/references/review-checklist.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
- changeset/CHANGELOG、`content/docs/**`、`apps/docs/**`:逐句核 diff,ACCEPT 点名所核句,⛔ 不起子代理。
2020
- Tests/docs-only 按仓库分流:本仓库走 `skip-changeset` 标签,⛔ 不走空 changeset。
2121
- 空 changeset 在本仓库滞留发布;含读者可见生成产物时 dev 改选 changeset 亦对。
22-
- dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位自核 tests/docs-only 即自挂,评论写依据是席位复核。
23-
- 此标是席位结论,⛔ 非替 dev 转发,自核不成立不挂;dev 报告 `deviations` 须逐字载被拒命令。
22+
- dev 挂 `skip-changeset` 遭分类器拒 ⇒ 席位转维护者定夺,⛔ 不径自代挂:代做即洗权限。
23+
- 其言与席位自核 tests/docs-only 俱备方挂,评论载两者;dev 报告 `deviations` 须逐字载被拒命令。
2424
- objectui 无此标签,空 frontmatter changeset 即正确形态;⛔ 永不要求或铸出该标签。
2525
- 就地修范围外的邻接缺陷 ⇒ 四条件逐条核:同缺陷类、机械、无他人认领、同门禁族。
2626
- 再核 claim 文件面同轮已修订、PR 正文点名该修并载证据;缺一条即判 REWORK。

‎scripts/pm/fleet-write/execute.mjs‎

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,30 @@
9595
* Any other status is FAILED as every REST 4xx is. The PUT is paced like
9696
* every other write verb.
9797
*
98-
* ## The assignees leg — `pr_create` with `assignees`, the one request that reads an earlier answer
98+
* ## The assignees and labels legs — `pr_create` with `assignees` / `labels`, the requests that read an earlier answer
9999
*
100-
* The op table spells that leg's path with a placeholder and `fromAnswer:
100+
* The op table spells each leg's path with a placeholder and `fromAnswer:
101101
* 'number'` (`ops.mjs`, its header). Within one action the requests run in
102-
* order and each answer is kept; a descriptor carrying `fromAnswer` is
103-
* filled from the PREVIOUS request's answer — `fillFromAnswer`: the field
104-
* must be a positive integer, and the path is the placeholder replaced by
105-
* it, so the pull the assignee lands on is the one the platform answered,
106-
* ⛔ never one the request named. An answer without it is a FAILED action
107-
* with the leg NOT SENT — its row says so, status `not sent` — and the
108-
* stroke stops there as on any failure. The leg's landing is judged by its
109-
* answer's `assignees` against `assigned`, the logins sent (`requestLanded`):
110-
* GitHub documents a login the caller cannot apply as "silently ignored",
111-
* the 201 still answered, so an answer listing fewer than sent is a FAILED
112-
* action naming the missing login, never a quiet partial — and the row
113-
* prints the assignees as the platform lists them (`resultOf`). The leg is
114-
* paced like every other write verb.
102+
* order and the FIRST answer — the create's — is kept; a descriptor carrying
103+
* `fromAnswer` is filled from that answer, ⛔ never from a later leg's
104+
* (the assignee POST answers the issue object, which happens to carry a
105+
* number, and the label POST answers an array, which carries none; neither
106+
* is the create) — `fillFromAnswer`: the field must be a positive integer,
107+
* and the path is the placeholder replaced by it, so the pull the assignee
108+
* or label lands on is the one the platform answered, ⛔ never one the
109+
* request named. An answer without it is a FAILED action with the leg NOT
110+
* SENT — its row says so, status `not sent` — and the stroke stops there as
111+
* on any failure. Each leg's landing is judged by its answer against what
112+
* was sent (`requestLanded`): the assignee leg's `assignees` against
113+
* `assigned` — GitHub documents a login the caller cannot apply as "silently
114+
* ignored", the 201 still answered; the label leg's answer, the pull's whole
115+
* label set, against `labelled` — a name sent and not listed did not land.
116+
* An answer listing fewer than sent is a FAILED action naming the missing
117+
* login or name, never a quiet partial; MORE than sent is not judged (the
118+
* Auto Label workflow writes `documentation` and `size/*` on open, additively,
119+
* so both sets land). The row prints the assignees, or the labels, as the
120+
* platform lists them (`resultOf`). Both legs are paced like every other
121+
* write verb.
115122
*
116123
* ## The step summary — what a person reads back
117124
*
@@ -246,6 +253,14 @@ export function requestLanded(req, { status, json } = {}) {
246253
return { ok: false, why: `HTTP ${status}, but the answer lists ${listed.length ? listed.join(', ') : 'no assignees'} — ${missing.join(', ')} not assigned (GitHub ignores a login the repository cannot assign without a word: it must be a collaborator the repository can assign)` };
247254
}
248255
}
256+
// The labels leg (header): the answer is the pull's whole label set; a name sent and not listed did not land.
257+
if (Array.isArray(req.labelled)) {
258+
const listed = labelNames(json);
259+
const missing = req.labelled.filter((name) => !listed.some((l) => l.toLowerCase() === name.toLowerCase()));
260+
if (missing.length) {
261+
return { ok: false, why: `HTTP ${status}, but the answer lists ${listed.length ? listed.join(', ') : 'no labels'} — ${missing.join(', ')} not applied (the labels endpoint answers the pull's whole label set, so a name it lacks was not written; read the pull on the board before anything else)` };
262+
}
263+
}
249264
return { ok: true, why: '' };
250265
}
251266
if (status === 404 && req.idempotent404) return { ok: true, why: 'already absent', idempotent: true };
@@ -257,11 +272,16 @@ export function assigneeLogins(json) {
257272
return Array.isArray(json?.assignees) ? json.assignees.map((u) => (typeof u?.login === 'string' ? u.login : null)).filter(Boolean) : [];
258273
}
259274

275+
/** The names a labels answer (an array of label objects) lists, in the platform's order; `[]` when it lists none. Pure. */
276+
export function labelNames(json) {
277+
return Array.isArray(json) ? json.map((l) => (typeof l?.name === 'string' ? l.name : null)).filter(Boolean) : [];
278+
}
279+
260280
/**
261-
* A descriptor carrying `fromAnswer` filled from the PREVIOUS request's answer
262-
* (header): `{ req }` with the placeholder replaced by the positive integer
263-
* that answer carries at the field, or `{ fail }` — the sentence the row
264-
* prints — when no answer carries it. Pure; never throws.
281+
* A descriptor carrying `fromAnswer` filled from its action's FIRST request's
282+
* answer — the create's (header): `{ req }` with the placeholder replaced by
283+
* the positive integer that answer carries at the field, or `{ fail }` — the
284+
* sentence the row prints — when no answer carries it. Pure; never throws.
265285
*/
266286
export function fillFromAnswer(req, prev) {
267287
const field = req.fromAnswer;
@@ -350,7 +370,8 @@ export function resultOf(req, json, status = null) {
350370
if (!pr) return 'ok';
351371
return pullStateText(pr);
352372
}
353-
if (Array.isArray(json)) return `${json.length} label(s) now on the issue`;
373+
// The labels leg's row prints the labels as the platform lists them (header); a plain labels_add is counted.
374+
if (Array.isArray(json)) return Array.isArray(req.labelled) ? `${json.length} label(s) now on the pull · labels ${labelNames(json).join(', ') || 'none'}` : `${json.length} label(s) now on the issue`;
354375
if (!json || typeof json !== 'object') return 'ok';
355376
const bits = [];
356377
if (Number.isInteger(json.number)) bits.push(`#${json.number}`);
@@ -555,10 +576,10 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF
555576
for (let i = 0; i < payload.actions.length && stoppedAt === null; i++) {
556577
const action = payload.actions[i];
557578
const requests = OPS[action.op].requests(action, payload.repo);
558-
let prev = null;
579+
let opener = null;
559580
for (const spelled of requests) {
560-
// A leg that reads an earlier answer (header) is filled from the previous request's answer, or NOT SENT.
561-
const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, prev);
581+
// A leg that reads an earlier answer (header) is filled from the action's FIRST answer — the create's — or NOT SENT.
582+
const filled = spelled.fromAnswer === undefined ? { req: spelled } : fillFromAnswer(spelled, opener);
562583
if (filled.fail) {
563584
const why = scrub(filled.fail, [token]);
564585
rows.push({ action: i + 1, op: action.op, call: `${spelled.verb} ${spelled.path}`, status: 'not sent', result: `FAILED: ${why}` });
@@ -581,7 +602,7 @@ export async function executeFleetWrite({ payload: raw, sender, token, api = DEF
581602
}
582603
// The GraphQL leg is spelled with its literal verb: every mutation is a POST to one path.
583604
const r = req.graphql ? await rest(api, '/graphql', { method: 'POST', body }, t) : await rest(api, req.path, { method: req.verb, body }, t);
584-
prev = r;
605+
if (opener === null) opener = r;
585606
// The base sync is confirmed by MEASURE (header): the head polled off the expected sha, or a 422 judged by the compare.
586607
const landed = req.updateBranch ? await confirmUpdateBranch(req, r, payload, api, t, ub) : requestLanded(req, r);
587608
const call = req.graphql ? `POST /graphql ${req.graphql.mutation}` : r.call;
@@ -624,7 +645,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
624645
'the GraphQL ops: the node id first, then the mutation; an errors array is a failure': 5,
625646
'the PR-state landing: a 200 lands only when the answer SHOWS the state the op asked for — armed or queued for auto-merge, off for its disable, ready and draft for the flips': 12,
626647
"the base sync: the PUT carries the expected head; a 202 is confirmed by polling the head off that sha inside the window and reading the merge commit's author; a 422 is a no-op only when the compare measures the head not behind its base; a moved head, a head still behind, an unreadable measure or an expired window is FAILED with nothing after it attempted": 10,
627-
"the assignees leg: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent": 11,
648+
"the assignees and labels legs: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent; with labels, ONE POST to the issue-labels endpoint at that same number — the create's, never the assignee answer's — whose answer must list every name sent": 19,
628649
'the summary: request, sender and role, session, target, one row per request': 5,
629650
'redaction: the token reaches no summary line, log line or error': 3,
630651
'the wiring: both halves around every write verb, on the roster': 4,
@@ -890,7 +911,7 @@ export async function selfTest() {
890911
}
891912

892913
// ── the assignees leg ───────────────────────────────────────────────────
893-
battery("the assignees leg: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent");
914+
battery("the assignees and labels legs: pr_create with assignees is the create, then ONE POST to the created pull's issue-assignees endpoint at the number the create ANSWERED — never the request; the answer must list every login sent; no assignees, no second request; an answer without a number stops the stroke with the leg not sent; with labels, ONE POST to the issue-labels endpoint at that same number — the create's, never the assignee answer's — whose answer must list every name sent");
894915
{
895916
const PULLS = `POST /repos/${REPO}/pulls`;
896917
const ASSIGN = (n) => `POST /repos/${REPO}/issues/${n}/assignees`;
@@ -916,6 +937,25 @@ export async function selfTest() {
916937
const refused = await run(create(['marchtian']), { ...allowed, [PULLS]: opened, [ASSIGN(22700)]: { status: 403, json: { message: 'Resource not accessible by integration' } } });
917938
t("a refused assignee POST is a FAILED action with the platform's sentence, the pull already open", [refused.exit, refused.rows.map((r) => r.status), refused.rows[1]?.result.includes('Resource not accessible by integration')], [EXIT_ACTION_FAILED, [201, 403], true]);
918939
t('no annotation is emitted for it — pr_create is not an annotated op, assignees or not', [ok.annotations, ok.logs.some((l) => l.startsWith('::notice'))], [[], false]);
940+
941+
// The labels leg — the same shape, a dev's `skip-changeset` riding the create.
942+
const LABEL = (n) => `POST /repos/${REPO}/issues/${n}/labels`;
943+
const createWith = (fields, extra = []) => base([{ op: 'pr_create', title: 'T', head: 'claude/issue-1-x', base: 'main', body: 'B', ...fields }, ...extra]);
944+
const labelsOn = (names) => ({ status: 200, json: names.map((name) => ({ name })) });
945+
const both = OPS.pr_create.requests({ op: 'pr_create', title: 'T', head: 'h', base: 'main', assignees: ['marchtian'], labels: ['skip-changeset'] }, REPO);
946+
t('the table half: with labels the row carries a POST to the issue-labels path at the placeholder, fromAnswer number, the names as labelled — after the assignees leg when both are sent', both.map((r) => [r.verb, r.path, r.fromAnswer ?? null, r.assigned ?? null, r.labelled ?? null]), [['POST', `/repos/${REPO}/pulls`, null, null, null], ['POST', `/repos/${REPO}/issues/${answerPlaceholder('number')}/assignees`, 'number', ['marchtian'], null], ['POST', `/repos/${REPO}/issues/${answerPlaceholder('number')}/labels`, 'number', null, ['skip-changeset']]]);
947+
// The assignee answer here carries a DIFFERENT number: the label leg must still land on the pull the CREATE answered.
948+
const labelled = await run(createWith({ assignees: ['marchtian'], labels: ['skip-changeset'] }), { ...allowed, [PULLS]: opened, [ASSIGN(22700)]: assignedTo(['marchtian'], 99999), [LABEL(22700)]: labelsOn(['skip-changeset', 'documentation']) }, { file: paceFile });
949+
t("the run: the create, the assignee POST, then the label POST — at the number the CREATE answered, not the assignee answer's — exit 0", [labelled.exit, writes(labelled.seen).map((s) => [s.call, s.body])], [EXIT_OK, [[PULLS, { title: 'T', head: 'claude/issue-1-x', base: 'main', body: 'B', draft: true }], [ASSIGN(22700), { assignees: ['marchtian'] }], [LABEL(22700), { labels: ['skip-changeset'] }]]]);
950+
t("…and the leg's row prints the labels as the platform lists them — more than sent (the Auto Label workflow's additive writes) is a landing, never a failure", labelled.rows[2], { action: 1, op: 'pr_create', call: LABEL(22700), status: 200, result: '2 label(s) now on the pull · labels skip-changeset, documentation' });
951+
const labelsOnly = await run(createWith({ labels: ['skip-changeset'] }), { ...allowed, [PULLS]: opened, [LABEL(22700)]: labelsOn(['skip-changeset']) }, { file: paceFile });
952+
t('labels without assignees: the create, then the label POST alone', [labelsOnly.exit, writes(labelsOnly.seen).map((s) => s.call)], [EXIT_OK, [PULLS, LABEL(22700)]]);
953+
const unlabelled = await run(createWith({ labels: ['skip-changeset', 'no-such-label'] }, [{ op: 'comment', issue: 1, body: 'after' }]), { ...allowed, [PULLS]: opened, [LABEL(22700)]: labelsOn(['skip-changeset']), [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 1 } } });
954+
t('⛔ a 200 whose answer lacks a name sent is a FAILED action naming it — never a quiet partial — and the stroke stops there', [unlabelled.exit, unlabelled.rows[1]?.result.includes('no-such-label not applied'), unlabelled.rows[1]?.result.includes('lists skip-changeset'), writes(unlabelled.seen).length, unlabelled.summary.includes('1 later action(s) NOT attempted')], [EXIT_ACTION_FAILED, true, true, 2, true], unlabelled.rows[1]?.result);
955+
t('…a name is matched as GitHub matches one, case-insensitively', requestLanded({ verb: 'POST', path: '/x', labelled: ['Skip-Changeset'] }, { status: 200, json: [{ name: 'skip-changeset' }] }).ok, true);
956+
t("…and a plain labels_add answer is counted, not judged — labelled is the leg's alone", [requestLanded({ verb: 'POST', path: '/x' }, { status: 200, json: [] }).ok, resultOf({ verb: 'POST', path: '/x' }, [{ name: 'a' }], 200)], [true, '1 label(s) now on the issue']);
957+
const blankLabel = await run(createWith({ labels: ['skip-changeset'] }, [{ op: 'comment', issue: 1, body: 'after' }]), { ...allowed, [PULLS]: { status: 201, json: { html_url: 'https://github.test/x' } }, [`POST /repos/${REPO}/issues/1/comments`]: { status: 201, json: { id: 1 } } });
958+
t('a create whose answer carries no number: the label leg is NOT SENT, exit 5, the later action never attempted', [blankLabel.exit, blankLabel.rows.map((r) => r.status), writes(blankLabel.seen).map((s) => s.call)], [EXIT_ACTION_FAILED, [201, 'not sent'], [PULLS]]);
919959
}
920960

921961
// ── the transfer ────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)