pingcode-cli-unofficial 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,386 @@
1
+ # 测试管理 (testhub) — `testhub`
2
+
3
+ > Part of the `pingcode` skill. Read [`../SKILL.md`](../SKILL.md) first — it carries the
4
+ > authentication gate, the stdout/stderr contract, the exit-code table and the rules that
5
+ > apply to every module. This file is only the testhub command surface plus the testhub-specific traps.
6
+
7
+ ### Test libraries 测试库 — `testhub libraries`
8
+
9
+ A **test library** is testhub's parent scope, the way a product is ship's. Resolve it first: case
10
+ states, case types, run results, the module tree and the plan list are **all library-scoped**.
11
+
12
+ ```bash
13
+ pingcode testhub libraries list --json
14
+ pingcode testhub libraries list --keywords payment --json
15
+ pingcode testhub libraries get LIB --json # name, identifier such as LIB, or id
16
+ pingcode testhub libraries create --name "Payments" --identifier PAY --json
17
+ ```
18
+
19
+ `--keywords` searches library **names** only — the identifier is not searchable server-side. The
20
+ `--identifier` given to `create` must be unique across the organisation and the server enforces it,
21
+ and `--name` is capped at **32 characters** (verified live 2026-08-02: a longer name is rejected with
22
+ code `100019`, exit 7).
23
+ There is no `libraries update` and no `libraries delete` leaf, for **two different reasons**: the
24
+ API publishes no library DELETE at all, so a library created here is permanent — while a library
25
+ PATCH *does* exist upstream and is simply not wrapped:
26
+
27
+ ```bash
28
+ pingcode api PATCH /v1/testhub/libraries/<id> --set description="…" # works (verified live)
29
+ ```
30
+
31
+ Note that a `PATCH` with `description: ""` answers 200 and **keeps the old value** (verified live
32
+ 2026-08-04): this API has no way to clear that field.
33
+
34
+ ### `testhub meta` — the ids a testhub write cannot be built without
35
+
36
+ ```bash
37
+ pingcode testhub meta case-states --library LIB --json # --state / state_id
38
+ pingcode testhub meta case-types --library LIB --json # --type / type_id
39
+ pingcode testhub meta run-statuses --library LIB --json # --status / status_id
40
+ pingcode testhub meta plan-types --library LIB --json # --type on `plans create`
41
+ pingcode testhub meta suites --library LIB --json # --suite; PATH is what --suite takes
42
+ pingcode testhub meta important-levels --json # --important-level; org-wide
43
+ pingcode testhub meta plan-states --json # --state on `plans update`; org-wide
44
+ pingcode testhub meta case-properties --library LIB --json # the field keys behind --set
45
+ ```
46
+
47
+ `important-levels` and `plan-states` are the two lookups with **no per-library variant**, so both
48
+ *refuse* `--library` with exit 2 rather than ignoring it (the flag is hidden from `--help`, which is
49
+ why it is spelled out here). The others require `--library`.
50
+
51
+ **Three different vocabularies answer to the word "state" here, and they are not interchangeable:**
52
+
53
+ | Command | Vocabulary | Scope | Used by |
54
+ |---|---|---|---|
55
+ | `meta case-states` | 设计 / 就绪 / 废弃 | per library | `cases update --state`, `cases bulk-update --state` |
56
+ | `meta plan-states` | 未开始 / 进行中 / 已完成 | organisation | `plans update --state` |
57
+ | `meta run-statuses` | 未测 / 通过 / 失败 / 受阻 / 跳过 | per library | `runs update --status`, `runs bulk-update --status` |
58
+
59
+ `case-properties` lists the fields **effective in a library**, and its `KEY` column is what a write
60
+ addresses. Read rule 11 before using one with `--set`: on this tenant every row is a *built-in*
61
+ field, and pushing a built-in through the properties map either answers HTTP 500 or rewrites the
62
+ top-level field of the same name.
63
+
64
+ `suites` accepts `--parent-id root` for the top level only, or a node id for that node's children.
65
+
66
+ ### Test cases 用例 — `testhub cases`
67
+
68
+ ```bash
69
+ pingcode testhub cases list --library LIB --json
70
+ pingcode testhub cases list --library LIB --state 已评审 --type 功能测试 --json
71
+ pingcode testhub cases list --library LIB --suite "登录 / 双因素" --keywords sso --page-size 20 --page 0 --json
72
+ pingcode testhub cases list --library LIB --all --limit 200 --json
73
+
74
+ pingcode testhub cases get 5f0e1a2b3c4d5e6f70819200 --json # an id or a short_id
75
+ pingcode testhub cases get aB3dEf9h --json
76
+
77
+ pingcode testhub cases create --library LIB --title "SSO login" --dry-run --json
78
+ pingcode testhub cases create --library LIB --title "SSO login" \
79
+ --suite "登录 / 双因素" --type 功能测试 --important-level 高 --json
80
+
81
+ pingcode testhub cases update aB3dEf9h --title "SSO login (v2)" --json
82
+ pingcode testhub cases update aB3dEf9h --state 已评审 --json
83
+ pingcode testhub cases update aB3dEf9h --set 自动化=5cb7e763fda1ce4ca0010002 --json
84
+
85
+ # import many at once, and fix them up in one call afterwards
86
+ pingcode testhub cases bulk-create --library LIB --file cases.json --dry-run --json
87
+ pingcode testhub cases bulk-create --library LIB --important-level P1 --file cases.json --json
88
+ pingcode testhub cases bulk-update --case aB3dEf9h --case-id 5f0e…9200 --library LIB --state 就绪 --json
89
+ pingcode testhub cases bulk-update --library LIB --file fixes.json --json
90
+
91
+ pingcode testhub cases delete aB3dEf9h --yes --json # ⚠️ deletes the case's RUNS too
92
+ pingcode testhub cases history list aB3dEf9h --json # latest result of every run of this case
93
+ ```
94
+
95
+ `cases.json` for `bulk-create` is a JSON array (or `{"cases": [ … ]}`), one object per case:
96
+
97
+ ```json
98
+ [{"title": "SSO login", "description": "…", "precondition": "…",
99
+ "type": "功能测试", "important_level": "P1", "maintenance": "wangxiao",
100
+ "steps": [{"description": "open /login", "expected_value": "200"}]}]
101
+ ```
102
+
103
+ Names are resolved for you (`type`, `important_level`, `maintenance`, and `state` on
104
+ `bulk-update`); every key also has an `_id` twin that is sent verbatim. **`suite`/`suite_id` and
105
+ `state`/`state_id` are refused on `bulk-create`** — the API accepts them and lands nothing, so the
106
+ CLI stops you instead of letting a 60-case import land in the wrong module. Unknown keys are refused
107
+ for the same reason. Up to **100** entries per call.
108
+
109
+ `testhub cases list` is `POST /v1/testhub/cases/search`; the plain list endpoint is never used
110
+ (unfiltered it scans every visible library). `--state` is **PATCH-only**: a case is always created in
111
+ the library's initial state, so `cases create` has no `--state`.
112
+
113
+ ### Test plans 测试计划 — `testhub plans`
114
+
115
+ ```bash
116
+ pingcode testhub plans list --library LIB --json
117
+ pingcode testhub plans list --library LIB --name "2026 S1 回归" --json
118
+ pingcode testhub plans get "2026 S1 回归" --library LIB --json # name, id or short_id
119
+ pingcode testhub plans create --library LIB --name "2026 S2 回归" \
120
+ --type 普通 --start 2026-08-10 --end 2026-08-31 --assignee 张三 --json
121
+
122
+ pingcode testhub plans update "2026 S2 回归" --library LIB --state 进行中 --json
123
+ pingcode testhub plans update "2026 S2 回归" --library LIB --summary "42/50 passed, 3 blocked" --json
124
+ pingcode testhub plans update "2026 S2 回归" --library LIB --end 2026-09-07 --json
125
+ ```
126
+
127
+ `create` takes all five: `--name` (unique within the library), `--type` (from
128
+ `testhub meta plan-types`), `--start`, `--end` and `--assignee`.
129
+ **Read §4c rule 12 before passing a date** — `--end` lands on 23:59:59, not midnight.
130
+
131
+ `update` is partial: only the flags you pass are sent. It is the only way to move a plan's state and
132
+ the only way to write the test-report `summary`. Three things to know:
133
+
134
+ - `--state` takes an **organisation-level** plan state (`meta plan-states`), not a case state;
135
+ - the dates are stored **verbatim** — unlike a pjm sprint or release window, the server does **not**
136
+ snap them to whole days (verified live 2026-08-04);
137
+ - an empty patch is refused locally (exit 2) because the API answers 200 to one and changes nothing,
138
+ and `--summary ""` is refused too: the server rejects an empty summary (`100003`), so a summary can
139
+ be replaced but never cleared.
140
+
141
+ There is still **no plan delete** — that endpoint does not exist.
142
+
143
+ ### Runs 执行用例 — `testhub runs`
144
+
145
+ A **run** is one case scheduled inside one plan; recording a result means updating the run.
146
+
147
+ ```bash
148
+ pingcode testhub runs list --library LIB --plan "2026 S1 回归" --json
149
+ pingcode testhub runs list --library LIB --plan "2026 S1 回归" --status 失败 --executor wangxiao --json
150
+ pingcode testhub runs list --plan-id 5f0e1a2b3c4d5e6f70819200 --all --limit 200 --json
151
+
152
+ pingcode testhub runs update 7hK2mQ9x --status 通过 --remark "retested on iOS" --dry-run --json
153
+ pingcode testhub runs update 7hK2mQ9x --status 通过 --executor wangxiao --json
154
+ pingcode testhub runs update 7hK2mQ9x --status 失败 \
155
+ --step s1=通过 --step s2=失败 --step-actual s2="500 from /login" --json
156
+
157
+ pingcode testhub runs bulk --library LIB --plan "2026 S1 回归" \
158
+ --add-case 5f0e1a2b3c4d5e6f70819200 --executor wangxiao --dry-run --json
159
+ pingcode testhub runs bulk --library LIB --plan "2026 S1 回归" --set-status 7hK2mQ9x=通过 --json
160
+ pingcode testhub runs bulk --library LIB --plan "2026 S1 回归" --remove-run 7hK2mQ9x --json
161
+
162
+ # add runs and record results without the plan-scoped bulk
163
+ pingcode testhub runs create --library LIB --plan "2026 S1 回归" --case aB3dEf9h --executor wangxiao --json
164
+ pingcode testhub runs bulk-create --library LIB --plan "2026 S1 回归" --case aB3dEf9h --case-id 5f0e…9200 --json
165
+ pingcode testhub runs bulk-update --run 7hK2mQ9x --run-id 5f0e…9200 --library LIB --status 通过 --json
166
+ pingcode testhub runs bulk-update --library LIB --file results.json --json
167
+
168
+ pingcode testhub runs history list 7hK2mQ9x --json # every result ever recorded
169
+ pingcode testhub runs history get 7hK2mQ9x <history-id> --json
170
+ ```
171
+
172
+ `results.json` for `bulk-update` is one object per run; `status` (or `status_id`) is **required** on
173
+ every entry, and `steps` is refused — use `runs update --step` for those:
174
+
175
+ ```json
176
+ {"runs": [{"run": "7hK2mQ9x", "status": "通过", "remark": "retested"},
177
+ {"run_id": "5f0e…9200", "status_id": "68ff…9bdb"}]}
178
+ ```
179
+
180
+ **The two bulk-run halves fail in opposite ways, and this is the single most important thing to know
181
+ before scripting them:**
182
+
183
+ | Command | Endpoint | On a bad entry |
184
+ |---|---|---|
185
+ | `runs bulk-create` | `POST /v1/testhub/runs/bulk` | **per-element best effort** — HTTP 200, one row per case, failures marked `failure` with a message |
186
+ | `runs bulk-update` | `PATCH /v1/testhub/runs/bulk` | **atomic** — one unknown run id rejects the whole batch (`100016`) and nothing is applied |
187
+ | `runs bulk` | `POST …/plans/{plan}/runs/bulk` | counts only; a bogus `--add-case` is skipped silently (see rule 16) |
188
+
189
+ So: read the `STATE` column of `bulk-create`, and trust `bulk-update` to be all-or-nothing. Both cap
190
+ at **100** entries per call, checked locally. `runs bulk-update` carries no plan or library in its
191
+ URL, so one call can span plans; it cannot delete anything.
192
+
193
+ **Why one of the three is called just `bulk`, and that is deliberate.** `bulk-create` adds cases and
194
+ `bulk-update` records results, so each says what it does to a row. Plain `runs bulk` is the
195
+ **composite** call — `inserts` + `updates` + `deletes` in one request — and no create/update pair
196
+ names that, so it keeps the bare word. It is the **only** way to *delete* a run. This is the one
197
+ place in the CLI where `bulk` does not mean "create many": the pjm leaves that did mean that were
198
+ renamed to `project sprint bulk-create` / `project version bulk-create` in the G3 closeout precisely
199
+ so the word is unambiguous everywhere else. The `runs` group help says the same thing.
200
+
201
+ Every name-resolvable flag has an `--x-id` twin (`--status-id`, `--executor-id`, `--plan-id`,
202
+ `--library-id`, …) that is sent verbatim with no lookup; the two forms are mutually exclusive.
203
+
204
+ ## 4c. Testhub rules that will bite you
205
+
206
+ These are on top of §4. Testhub is the same machinery again, with a different parent scope and a
207
+ sharper write path.
208
+
209
+ 1. **Resolve the test library first, and never carry an id across libraries.** `state_id`,
210
+ `type_id`, `status_id`, `suite_id` and the plan list are all **library-scoped**: two libraries
211
+ never share a state, type or status id, even when the names are identical. `testhub cases list`,
212
+ `plans list`, `plans get`, `plans create`, `runs bulk` and the five library-scoped `meta` leaves
213
+ (`case-states`, `case-types`, `run-statuses`, `plan-types`, `suites`) all require
214
+ `--library <name|id>` and refuse to guess (exit 2). `cases get`, `cases update` and `runs update`
215
+ do not: they read the resource first and inherit its library. `runs list` needs one only to
216
+ resolve a `--plan` or `--status` **by name** — `--plan-id` / `--status-id` work without it.
217
+ `libraries create` is the one testhub command with no parent at all.
218
+ 2. **`--json` search is the read path.** `testhub cases list` is `POST /v1/testhub/cases/search` and
219
+ `testhub runs list` is `POST /v1/testhub/runs/search`; the plain `GET` lists are never called
220
+ (unfiltered, `GET /v1/testhub/cases` scans every library you can see). One operator per field, no
221
+ `$and`/`$or`, filters AND-ed, and no sorting anywhere.
222
+ 3. **`steps[]` replaces, it never merges — so `--step` is all-or-nothing.** A run's step array is
223
+ overwritten wholesale, and a step that arrives without its `step_id` is re-created with a fresh
224
+ id, orphaning its execution history. Re-emitting an untouched step is impossible: a run step
225
+ reports an English status **slug** while the write needs a status **id**, and nothing joins the
226
+ two except the localized name, which a tenant may have renamed. Rather than guess, the CLI
227
+ refuses a partial `--step` edit and lists every step id you must supply. Pass a status for
228
+ **every** step, or none at all. The same "replaces, never merges" applies to `--set`/`properties`
229
+ on a case.
230
+ 4. **`testhub runs update` always sends `status_id`, and carries the executor over for you.**
231
+ `status_id` is required by the API even on PATCH, so the CLI reads the run first and re-sends the
232
+ run's current result when you do not name one — patching only a remark is safe here, and would
233
+ not be if you called the API directly. The run's own executor is re-sent the same way. When the
234
+ run has **no** executor and you name none, `executor_id` is omitted from the body and the CLI
235
+ warns on stderr that the run **stays unassigned** — an omitted `executor_id` is a verified no-op
236
+ on PATCH (2026-08-02), it neither clears the field nor reassigns the run. If the run has no
237
+ recorded result at all, the CLI asks for `--status` (exit 2) instead of sending a half-formed
238
+ body.
239
+ 5. **`pingcode testhub runs bulk` is the only way to delete a run** — there is no `runs delete` and
240
+ no run DELETE endpoint. Each of `--add-case`, `--set-status` and `--remove-run` is capped at
241
+ **50** entries per call (checked locally, exit 2), and the response carries **counts only**,
242
+ never the ids of the runs it created: re-list the plan to see them.
243
+ It is no longer the only way to *add* one: `runs create` and `runs bulk-create` do that and
244
+ report what they created.
245
+ **The caps differ per endpoint, and only some are real.** Verified live 2026-08-04:
246
+ `cases/bulk` (both halves) and `runs/bulk` (both halves) are capped at **100** by the server
247
+ itself — a 101st entry answers `100039` before any field is validated, *including* on the two
248
+ halves whose docs declare no limit. The plan-scoped `…/plans/{plan}/runs/bulk`, by contrast,
249
+ enforces **nothing**: 1001 entries sail past the length gate and `updates[50]` is validated, so
250
+ the documented 50 is not a server rule. The CLI keeps 50 there anyway — an unenforced documented
251
+ limit on an insert/update/delete batch is exactly the shape that silently half-applies — and uses
252
+ the API's own 100 everywhere else, because refusing 51–100 entries the server accepts would be
253
+ the CLI inventing a restriction.
254
+ 6. **`testhub runs list` cannot filter by library.** `library.id` is on the API's exclusion list for
255
+ run search, so scope runs with `--plan` instead. Passing `--library` without `--plan` resolves
256
+ names but does not narrow the result, and the CLI warns on stderr when you do it.
257
+ 7. **`testhub meta important-levels` takes no `--library`.** Importance levels are organisation-wide
258
+ — the only testhub lookup with no per-library variant — so the flag is refused with exit 2 rather
259
+ than accepted and ignored. It is hidden from `--help`; this line is the documentation.
260
+ 8. **The `pcp:read:testhub:configuration` trap.** `testhub meta case-states` and `testhub meta
261
+ run-statuses` need that scope while their sibling `testhub meta case-types` does not. A token
262
+ granted only `testcase` + `testplan` can list cases, plans and runs but gets a bare 403 from
263
+ those two — and since they are the only source of a `state_id` and a `status_id`, that token
264
+ **cannot write a run at all**. The CLI rewrites the 403 to say so.
265
+ 9. **`cases create` takes the library as `test_library_id`, and `--state` is PATCH-only.** The
266
+ create body field is `test_library_id` (not `library_id`), which the CLI fills from `--library`;
267
+ a case is created in the library's initial state and can only be moved with
268
+ `pingcode testhub cases update <case> --state <s>`.
269
+ 10. **`short_id` is read-only.** `testhub cases get`, `plans get` and the run read accept an id or a
270
+ `short_id`, but every write documents `id` only. `testhub cases update` and `testhub runs update`
271
+ therefore read the resource first and use the real id — which is also where they learn the
272
+ library, so a name lookup works without repeating `--library`.
273
+ 11. **`--set` keys now have a discovery command, and it is a warning as much as a lookup.**
274
+ `pingcode testhub meta case-properties --library LIB` lists the fields effective in a library.
275
+ Verified live 2026-08-04, on this tenant **all 8 rows are built-in fields** whose key is the
276
+ field's own name (`state_id`, `description`, `steps`, `type`, `important_level`,
277
+ `maintenance_uid`, `precondition`, `test_type`), and pushing one of them through the properties
278
+ map is worse than useless:
279
+ - `--set important_level=…` → **HTTP 500**;
280
+ - `--set description=x` → **200, and it rewrites the top-level `description`**;
281
+ - a *custom* property that exists organisation-wide but is not in this library's scheme →
282
+ **HTTP 500** as well;
283
+ - a key that exists nowhere → 400, refused rather than dropped.
284
+
285
+ So set built-ins with their own flags (`--state`, `--type`, `--important-level`,
286
+ `--description`, `--precondition`) and reserve `--set` for a custom property that appears in
287
+ **that library's** list. Values for select-typed properties are option ids, not labels — the
288
+ same trap as ship. This is also why `pingcode resolve` has **no** `testhub-case-property` kind:
289
+ resolving a name would hand `--set` a key that edits a different field.
290
+ The library-scoped list cannot even tell you which rows are custom (it returns only
291
+ `{id, name, type, options}`); the organisation-level list can, one call away:
292
+ `pingcode api GET /v1/testhub/case_properties`.
293
+ Still missing: there is no `--maintenance` flag anywhere, so cases cannot be filtered by
294
+ maintainer (only *set*, through a bulk entry's `maintenance`).
295
+ 12. **`--start` and `--end` on `plans create`: the end date is inclusive, and that is asymmetric.**
296
+ Both flags accept either form:
297
+ - `YYYY-MM-DD`, zero-padded. `--start 2026-08-10` becomes **00:00:00 local** on that date;
298
+ `--end 2026-08-31` becomes **23:59:59 local** on it.
299
+ - a **10-digit unix seconds** integer, passed through **verbatim** on both flags — no
300
+ end-of-day adjustment is applied to it. Use this when you want an exact instant.
301
+
302
+ The asymmetry is deliberate: a date range means the plan runs *through* the end date, and mapping
303
+ both ends to midnight would silently shorten every plan by a day — an error you would never see,
304
+ because the CLI echoes back exactly what it sent. Local time, not UTC, so that a `plans get`
305
+ agrees with the `plans create` that produced it.
306
+
307
+ Everything else is refused with exit 2 **before any request**: an unpadded `2026-8-1`, slashes
308
+ (`08/31/2026`), an ISO string with a time in it, a 13-digit **milliseconds** value, and an
309
+ impossible date such as `2026-02-30` (which JavaScript would otherwise roll silently into
310
+ March). `--end` earlier than `--start` is refused client-side too, and the message prints both
311
+ resolved unix values so you can see which end moved.
312
+ 13. **`plans create` needs all five fields, and `--assignee` has no default.** `--library`,
313
+ `--name`, `--type`, `--start`, `--end` and `--assignee` are all required. There is deliberately
314
+ no "assign it to me": an enterprise (client-credentials) token acts as the **bot user**, so a
315
+ default would quietly make a bot the owner 负责人 of every plan the CLI creates, and nobody would
316
+ notice until they went looking for whom to ask. Name a real person —
317
+ `pingcode settings users --keywords <name> --json` is the candidate set.
318
+ 14. **A plan type carries no `kind`, so the CLI cannot tell you which types need more.** Iteration
319
+ and release plan types additionally require `sprint_id` / `version_id` (and the `project_id`
320
+ they make mandatory), but the plan-type resource exposes only `id` / `name` / `url` / `library`
321
+ — there is **no `kind` discriminator**, and guessing from the localized name is not safe because
322
+ tenants rename them. `testhub meta plan-types` therefore lists names only, `plans create` sends
323
+ just the five fields, and if you pick a type that needs more, **the server's refusal is what you
324
+ see** — not a local warning. Pick the plain (普通) type unless you know the tenant's setup.
325
+ 15. **A library can be created but never updated or deleted.** `--identifier` on
326
+ `testhub libraries create` must be **unique across the organisation** and the server enforces it
327
+ (a duplicate is rejected server-side; the CLI does not pre-check, because a probe would race).
328
+ Testhub publishes **no library DELETE**, so a library created here is permanent: get the name
329
+ and identifier right the first time, and mark throwaway ones (for example `[CLI smoke] …`)
330
+ *before* creating them. The CLI says so on stderr after each create.
331
+ A library **PATCH does exist** upstream, though — this was previously documented the other way
332
+ round and is corrected here (verified live 2026-08-04). It is not wrapped as a leaf, so rename
333
+ or re-describe through the generic layer:
334
+ `pingcode api PATCH /v1/testhub/libraries/<id> --set name="…"`. Note it cannot *clear* a field:
335
+ `description: ""` answers 200 and keeps the old value.
336
+ 16. **`runs bulk --add-case` ignores a case id that does not exist — silently, at exit 0.** Verified
337
+ live 2026-08-02: a bogus `--add-case` id returns `{"inserts":0,"updates":0,"deletes":0}` and
338
+ succeeds. There is no error and no per-entry report, because the endpoint answers with counts
339
+ only. **Read the counts**, and if they do not match what you asked for, re-list the plan with
340
+ `pingcode testhub runs list --plan <plan> --json` to see what actually landed. A bogus id in
341
+ `--remove-run` does fail loudly (code `100619`, exit 7), so the leniency is specific to inserts.
342
+ 17. **`testhub cases delete` takes the case's runs with it.** Verified live 2026-08-04: deleting a
343
+ case with a run in a plan removed the run from the plan, and the run id stopped resolving
344
+ (`100603`). The CLI counts the runs before the `--yes` gate and names the number, so the
345
+ confirmation tells you the blast radius rather than just the case title. The case itself is only
346
+ **soft**-deleted — `cases list --include-deleted` still finds it with `is_deleted: 1` — but this
347
+ API publishes no undelete, so treat it as one-way. It is also the only DELETE in the module, and
348
+ the case path is **id-only**: the CLI resolves a `short_id` before sending.
349
+ 18. **Two "history" reads, two different questions.** `runs history list <run>` is every result ever
350
+ recorded on one run, oldest first — the audit trail a test report needs. `cases history list
351
+ <case>` is one row per **run** of that case, carrying only that run's *latest* result, so its
352
+ row count is the number of runs and not the number of attempts. Each of its rows names the run
353
+ it came from, which is how you drill down. There is no per-case history *detail* path: a row is
354
+ a run-history record, so read it with `runs history get <run> <id>`.
355
+ Both paths are **id-only** (a `short_id` answers 404), so the CLI resolves the reference first.
356
+ A history id that belongs to a different run is reported as a **mismatch** (`100643`, exit 7),
357
+ not as missing; a genuinely unknown history id is exit 5.
358
+ Contrary to the vendor docs, the two endpoints return the **same** item shape — the case side
359
+ carries `executed_status` and `remark` too (verified live 2026-08-04).
360
+ 19. **A bulk result is auditable; a pjm bulk update is not.** Every entry `runs bulk-update` applies
361
+ appends a row to that run's history, so `runs history list` shows batch-recorded results exactly
362
+ like hand-recorded ones. Do not generalise this from module to module: `project work-item
363
+ bulk-update` appears in no feed at all.
364
+ 20. **`runs create` refuses a case the plan already contains** (`100605`, exit 7) rather than
365
+ deduplicating, and an omitted `--executor` leaves the run **unassigned** — it is not defaulted
366
+ to the creator. When you are adding several cases and some may already be there, prefer
367
+ `runs bulk-create`: it lands the new ones and reports the duplicates instead of failing the call.
368
+
369
+ ## 5. Deliberately left to `pingcode api`
370
+
371
+ Reachable, just not as named commands — say that rather than reporting a limitation:
372
+
373
+ | Left out | Why | Reach it with |
374
+ |---|---|---|
375
+ | library **members** (4 endpoints) | no command needs library membership: `--executor` and `--assignee` resolve against the organisation directory | `pingcode api GET /v1/testhub/libraries/<id>/members` |
376
+ | library **update** | exists upstream and cannot clear a field; renaming a library is rare enough not to earn a leaf (rule 15) | `pingcode api PATCH /v1/testhub/libraries/<id> --set name="…"` |
377
+ | case-module (**suite**) writes | the suite tree is read to resolve `--suite`; editing it is a configuration act | `pingcode api list --module testhub --search suites` |
378
+ | plan **delete** | no test plan DELETE exists upstream at all | — |
379
+ | every configuration **write** (case states, types, property schemes) | tenant configuration, and a wrong write is felt by everyone in the library | `pingcode api list --module testhub --search plans` |
380
+ | `PUT /v1/testhub/runs/{run_id}` | full replacement, and documented to blank the executor when the field is omitted — `runs update` covers the same ground without that risk | `pingcode api PUT /v1/testhub/runs/<id>` |
381
+ | `GET /v1/testhub/cases` and `GET /v1/testhub/runs` (the simple lists) | unfiltered they scan every library the token can see; `POST …/search` is the only sane read path, and it is what `cases list` / `runs list` use | `pingcode api GET /v1/testhub/cases` |
382
+ | `GET /v1/testhub/plan_states/{state_id}` | the *list* (`meta plan-states`) is wired and is the only thing a plan write needs | `pingcode api GET /v1/testhub/plan_states/<id>` |
383
+
384
+ Library and plan **creation** *are* covered (`testhub libraries create`, `testhub plans create`), so
385
+ the CLI can bootstrap its own fixtures — but read rules 13–15 first: only the plain (普通) plan type is
386
+ reachable without a sprint or release, and a library cannot be deleted afterwards.