@zumino/cli 2.1.0 → 2.2.0

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.
@@ -1,18 +1,57 @@
1
1
  import { api } from "../client.js";
2
2
  import { resolveContext } from "../config.js";
3
3
  import { CliError } from "../errors.js";
4
- import { codeOf, givenFlag, json, out, pick, positiveInt, safeText } from "../output.js";
4
+ import {
5
+ clip,
6
+ codeOf,
7
+ dim,
8
+ json,
9
+ out,
10
+ pageLine,
11
+ pick,
12
+ safeText,
13
+ table,
14
+ } from "../output.js";
15
+ import {
16
+ assertUserRef,
17
+ csv,
18
+ givenFlag,
19
+ nonNegativeInt,
20
+ oneValue,
21
+ positiveInt,
22
+ searchText,
23
+ } from "../flags.js";
24
+ import { listEvents } from "../events.js";
5
25
  import { projectPath, resolveItem } from "../items.js";
26
+ import { resolveUser, resolveUserRef } from "../users.js";
6
27
 
7
28
  /*
8
- * Everything that writes a task.
29
+ * Tasks: the project's work, read and written.
9
30
  *
10
- * Per-kind because `DOMAIN.md` says writes are per-kind: each has its own Zod
11
- * schema and its own rate-limit bucket, so a run filing twenty-five tasks is
12
- * never the reason naming the epic they belong to is refused.
31
+ * Per-kind because `DOMAIN.md` says writes are per-kind: each kind has its own
32
+ * Zod schema and its own rate-limit bucket, so a run filing twenty-five tasks is
33
+ * never the reason naming the epic they belong to is refused. The *list* lives
34
+ * here for the same reason — it reads the task endpoint, with the task
35
+ * vocabulary, and publishes priority, readiness and tags, which the read that
36
+ * spans every kind deliberately does not (`docs/decisions/0003`).
13
37
  */
14
38
 
15
- const SUB = { create, status, assign, spec, comment, link, ref, attention, show };
39
+ export const SUB = {
40
+ list,
41
+ show,
42
+ events,
43
+ create,
44
+ edit,
45
+ status,
46
+ assign,
47
+ spec,
48
+ comment,
49
+ link,
50
+ ref,
51
+ attention,
52
+ tag,
53
+ untag,
54
+ };
16
55
 
17
56
  export async function run(args, flags) {
18
57
  const [sub, ...rest] = args;
@@ -25,40 +64,164 @@ export async function run(args, flags) {
25
64
  return fn(rest, flags);
26
65
  }
27
66
 
67
+ /**
68
+ * `--epic` as the API takes it, in its two places.
69
+ *
70
+ * An **epic** reference in this project, and nothing else. `epicNumber` is
71
+ * project-local, so a key prefix cannot express a foreign epic — its only effect
72
+ * was to let one through silently: `OPS-E14` targeted epic 14 *here*. And
73
+ * stripping leading letters off anything turned the task code `ONS-14` into epic
74
+ * 14, which is legal because task and epic numbers are separate namespaces, so
75
+ * the task was created under the wrong parent and reported success. Accept `E3`
76
+ * or `3`; refuse everything else and say why.
77
+ *
78
+ * @param {string} raw
79
+ * @param {{allowNone: boolean}} opts `none` is a filter value and a detach
80
+ */
81
+ function epicRef(raw, { allowNone }) {
82
+ const v = String(raw).trim();
83
+ if (allowNone && (v === "none" || v === "-")) return null;
84
+ const m = /^[Ee]?(\d+)$/.exec(v);
85
+ if (!m) {
86
+ throw new CliError(`--epic "${v}" is not an epic in this project.`, {
87
+ hint:
88
+ "Write E3 or 3" +
89
+ (allowNone ? ", or none" : "") +
90
+ ". A task code like ONS-14 is not an epic, and an epic in another " +
91
+ "project cannot be a parent — move the task instead.",
92
+ });
93
+ }
94
+ return positiveInt(m[1], "epic");
95
+ }
96
+
97
+ async function list(args, flags) {
98
+ const ctx = resolveContext(flags);
99
+ const base = await projectPath(ctx);
100
+
101
+ // `!== undefined`, not truthiness: `--assignee ""` and `--epic ""` are given
102
+ // and empty, and a falsy test drops them into an unfiltered list.
103
+ const assignee =
104
+ flags.assignee !== undefined
105
+ ? await resolveUser(ctx, oneValue(flags, "assignee"), { flag: "--assignee" })
106
+ : undefined;
107
+ const epic =
108
+ flags.epic !== undefined ? epicRef(oneValue(flags, "epic"), { allowNone: true }) : undefined;
109
+ const offset = nonNegativeInt(flags.offset, "offset");
110
+
111
+ const res = await api(ctx, "GET", `${base}/tasks`, {
112
+ query: {
113
+ q: searchText(args, flags),
114
+ status: csv(flags, "status"),
115
+ type: oneValue(flags, "type"),
116
+ priority: oneValue(flags, "priority"),
117
+ assignee,
118
+ tag: oneValue(flags, "tag"),
119
+ // `none` is the ungrouped remainder, which no number can express — and it
120
+ // is the one filter on this endpoint that refuses what it cannot parse,
121
+ // because an unfiltered list is indistinguishable from a filter that
122
+ // matched everything.
123
+ epic: epic === null ? "none" : epic,
124
+ needsInput: flags["needs-input"] ? "true" : undefined,
125
+ open: flags.open ? "true" : undefined,
126
+ sort: oneValue(flags, "sort"),
127
+ offset,
128
+ limit: positiveInt(flags.limit, "limit"),
129
+ },
130
+ });
131
+
132
+ const tasks = res?.tasks ?? [];
133
+ if (flags.json) return json(tasks), 0;
134
+ if (tasks.length === 0) return out("No tasks match."), 0;
135
+
136
+ table(
137
+ tasks.map((t) => [
138
+ codeOf(t) || `#${t.number}`,
139
+ t.status ?? "",
140
+ t.priority ?? "",
141
+ t.type ?? "",
142
+ safeText(t.assignee?.name) || "—",
143
+ clip(t.title, 48),
144
+ ]),
145
+ { head: ["CODE", "STATUS", "PRIORITY", "TYPE", "ASSIGNEE", "TITLE"] },
146
+ );
147
+ out(dim(`\n${pageLine(res, tasks.length, offset)}`));
148
+ return 0;
149
+ }
150
+
151
+ async function show(args, flags) {
152
+ const [itemRef] = args;
153
+ if (!itemRef) throw new CliError("zumino task show <CODE>");
154
+ const ctx = resolveContext(flags);
155
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
156
+ const res = pick(await api(ctx, "GET", path), "task");
157
+ if (flags.json) return json(res), 0;
158
+ out(`${codeOf(res)} ${safeText(res.title)}`);
159
+ out(`status ${res.status} priority ${res.priority ?? "-"} assignee ${safeText(res.assignee?.name) || "-"}`);
160
+ if (res.description) out(`\n${safeText(res.description)}`);
161
+ return 0;
162
+ }
163
+
164
+ async function events(args, flags) {
165
+ const [itemRef] = args;
166
+ if (!itemRef) throw new CliError("zumino task events <CODE>");
167
+ const ctx = resolveContext(flags);
168
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
169
+ return listEvents(ctx, path, flags);
170
+ }
171
+
172
+ /**
173
+ * The fields a task write carries, built once for `create` and for `edit`.
174
+ *
175
+ * `!== undefined`, not truthiness: `--description ""` is an instruction to leave
176
+ * it blank, and dropping it silently left whatever was there. A field nobody
177
+ * named is absent from the body, so a patch touches exactly what was asked for —
178
+ * which is also what makes `edit` one audit group rather than several.
179
+ *
180
+ * @param {any} ctx
181
+ * @param {Record<string, any>} flags
182
+ * @param {{patch: boolean}} opts a patch may clear; a create has nothing to clear
183
+ */
184
+ async function fields(ctx, flags, { patch }) {
185
+ /** @type {Record<string, any>} */
186
+ const body = {};
187
+ const text = (name) => {
188
+ const v = givenFlag(flags[name]);
189
+ if (v !== undefined) body[name] = v;
190
+ };
191
+ text("title");
192
+ text("description");
193
+
194
+ for (const name of ["type", "status", "priority"]) {
195
+ const v = oneValue(flags, name);
196
+ if (v !== undefined) body[name] = v;
197
+ }
198
+ // Nullable enums: `-` clears them, which is only meaningful on a patch.
199
+ for (const name of ["size", "clarity"]) {
200
+ const v = oneValue(flags, name);
201
+ if (v === undefined) continue;
202
+ body[name] = patch && (v === "-" || v === "") ? null : v;
203
+ }
204
+ for (const [name, key] of [["assignee", "assigneeId"], ["reviewer", "reviewerId"]]) {
205
+ const v = oneValue(flags, name);
206
+ if (v === undefined) continue;
207
+ body[key] = await resolveUser(ctx, v, { flag: `--${name}`, allowClear: true });
208
+ }
209
+ if (flags.epic !== undefined) {
210
+ body.epicNumber = epicRef(oneValue(flags, "epic"), { allowNone: patch });
211
+ }
212
+ return body;
213
+ }
214
+
28
215
  async function create(args, flags) {
29
216
  const ctx = resolveContext(flags);
30
- const title = flags.title ?? args.join(" ").trim();
217
+ const title = oneValue(flags, "title") ?? args.join(" ").trim();
31
218
  if (!title) {
32
219
  throw new CliError("A task needs a title.", {
33
220
  hint: 'zumino task create --title "…"',
34
221
  });
35
222
  }
36
223
  const base = await projectPath(ctx);
37
- const body = { title };
38
- // `!== undefined`, not truthiness: `--description ""` is an instruction to
39
- // leave it blank, and dropping it silently left whatever was there.
40
- if (givenFlag(flags.description) !== undefined) body.description = flags.description;
41
- if (givenFlag(flags.status) !== undefined) body.status = flags.status;
42
- // An **epic** reference in this project, and nothing else.
43
- //
44
- // `epicNumber` is project-local, so a key prefix cannot express a foreign
45
- // epic — its only effect was to let one through silently: `OPS-E14` targeted
46
- // epic 14 *here*. And stripping leading letters off anything turned the task
47
- // code `ONS-14` into epic 14, which is legal because task and epic numbers are
48
- // separate namespaces, so the task was created under the wrong parent and
49
- // reported success. Accept `E3` or `3`; refuse everything else and say why.
50
- if (givenFlag(flags.epic) !== undefined) {
51
- const raw = String(flags.epic).trim();
52
- const m = /^[Ee]?(\d+)$/.exec(raw);
53
- if (!m) {
54
- throw new CliError(`--epic "${raw}" is not an epic in this project.`, {
55
- hint:
56
- "Write E3 or 3. A task code like ONS-14 is not an epic, and an epic in " +
57
- "another project cannot be a parent — move the task instead.",
58
- });
59
- }
60
- body.epicNumber = positiveInt(m[1], "epic");
61
- }
224
+ const body = { ...(await fields(ctx, flags, { patch: false })), title };
62
225
 
63
226
  const res = pick(await api(ctx, "POST", `${base}/tasks`, { body }), "task");
64
227
  if (flags.json) return json(res), 0;
@@ -66,15 +229,41 @@ async function create(args, flags) {
66
229
  return 0;
67
230
  }
68
231
 
232
+ /**
233
+ * One PATCH carrying every field that was named.
234
+ *
235
+ * It exists because the alternative was five calls and five audit groups for one
236
+ * decision: the history renders a group as one sentence, so `--priority critical
237
+ * --status in_progress` should read as one move and not two. `status` and
238
+ * `assign` keep their own subcommands — they are the two an agent does
239
+ * constantly, and they want to be three words.
240
+ */
241
+ async function edit(args, flags) {
242
+ const [itemRef] = args;
243
+ if (!itemRef) throw new CliError("zumino task edit <CODE> [fields]");
244
+ const ctx = resolveContext(flags);
245
+ const body = await fields(ctx, flags, { patch: true });
246
+ if (Object.keys(body).length === 0) {
247
+ throw new CliError("Nothing to change.", {
248
+ hint: "Name at least one field. See: zumino help task edit",
249
+ });
250
+ }
251
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
252
+ const res = pick(await api(ctx, "PATCH", path, { body }), "task");
253
+ if (flags.json) return json(res), 0;
254
+ out(`${codeOf(res)} ${Object.keys(body).join(", ")} written`);
255
+ return 0;
256
+ }
257
+
69
258
  async function status(args, flags) {
70
- const [ref, value] = args;
71
- if (!ref || !value) {
259
+ const [itemRef, value] = args;
260
+ if (!itemRef || !value) {
72
261
  throw new CliError("zumino task status <CODE> <status>", {
73
- hint: "Statuses are tokens: backlog, shaping, todo, in_progress, done, wont_do.",
262
+ hint: "Statuses are tokens: backlog, shaping, todo, in_progress, in_review, done, wont_do.",
74
263
  });
75
264
  }
76
265
  const ctx = resolveContext(flags);
77
- const { path } = await resolveItem(ctx, ref, { kind: "task" });
266
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
78
267
  const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "task");
79
268
  if (flags.json) return json(res), 0;
80
269
  out(`${codeOf(res)} ${res.status}`);
@@ -82,28 +271,20 @@ async function status(args, flags) {
82
271
  }
83
272
 
84
273
  async function assign(args, flags) {
85
- const [ref, who] = args;
86
- if (!ref || !who) {
87
- throw new CliError("zumino task assign <CODE> <userId|->", {
274
+ const [itemRef, who] = args;
275
+ if (!itemRef || !who) {
276
+ throw new CliError("zumino task assign <CODE> <userId|me|->", {
88
277
  hint: "`-` clears the assignee. An assignee is always a person, even when an agent does the work.",
89
278
  });
90
279
  }
91
- // An email is the natural guess and the server cannot say so: it looks the
92
- // value up as a user id and refuses with "not a member of this workspace",
93
- // which reads as "that person has no access" rather than "that is the wrong
94
- // kind of value". Caught here, where the difference is still visible.
95
- if (who !== "-" && /@/.test(who)) {
96
- throw new CliError(`"${who}" looks like an email; assign takes a user id.`, {
97
- hint: "Find one on an item you can read: zumino task show <CODE> --json | jq -r '.assignee.id'",
98
- });
99
- }
100
-
280
+ // The shape of the argument is checked before anything is resolved: an email
281
+ // here is wrong whether or not a credential exists, and "No Zumino account is
282
+ // configured" is the less useful of the two answers.
283
+ const person = assertUserRef(who, { flag: "assign", allowClear: true });
101
284
  const ctx = resolveContext(flags);
102
- const { path } = await resolveItem(ctx, ref, { kind: "task" });
103
- const res = pick(
104
- await api(ctx, "PATCH", path, { body: { assigneeId: who === "-" ? null : who } }),
105
- "task",
106
- );
285
+ const assigneeId = await resolveUserRef(ctx, person);
286
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
287
+ const res = pick(await api(ctx, "PATCH", path, { body: { assigneeId } }), "task");
107
288
  if (flags.json) return json(res), 0;
108
289
  out(`${codeOf(res)} ${safeText(res.assignee?.name) || "unassigned"}`);
109
290
  return 0;
@@ -115,9 +296,9 @@ async function assign(args, flags) {
115
296
  * be judged against.
116
297
  */
117
298
  async function spec(args, flags) {
118
- const [ref] = args;
299
+ const [itemRef] = args;
119
300
  const section = flags.plan !== undefined ? "plan" : flags.acceptance !== undefined ? "acceptance" : null;
120
- if (!ref || !section) {
301
+ if (!itemRef || !section) {
121
302
  throw new CliError("zumino task spec <CODE> --plan TEXT | --acceptance TEXT", {
122
303
  hint: "One section per call, on purpose.",
123
304
  });
@@ -128,41 +309,41 @@ async function spec(args, flags) {
128
309
  });
129
310
  }
130
311
  const ctx = resolveContext(flags);
131
- const { path } = await resolveItem(ctx, ref, { kind: "task" });
312
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
132
313
  const body = section === "plan" ? flags.plan : flags.acceptance;
133
314
  const res = pick(
134
315
  await api(ctx, "PUT", `${path}/spec/${section}`, { body: { body: body === "" ? null : body } }),
135
316
  "task",
136
317
  );
137
318
  if (flags.json) return json(res), 0;
138
- out(`${ref} ${section} written`);
319
+ out(`${itemRef} ${section} written`);
139
320
  return 0;
140
321
  }
141
322
 
142
323
  async function comment(args, flags) {
143
- const [ref, ...text] = args;
324
+ const [itemRef, ...text] = args;
144
325
  const bodyText = flags.body ?? text.join(" ").trim();
145
- if (!ref || !bodyText) throw new CliError("zumino task comment <CODE> <text>");
326
+ if (!itemRef || !bodyText) throw new CliError("zumino task comment <CODE> <text>");
146
327
  const ctx = resolveContext(flags);
147
- const { path } = await resolveItem(ctx, ref, { kind: "task" });
328
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
148
329
  const res = pick(await api(ctx, "POST", `${path}/comments`, { body: { body: bodyText } }), "comment");
149
330
  if (flags.json) return json(res), 0;
150
- out(`${ref} commented`);
331
+ out(`${itemRef} commented`);
151
332
  return 0;
152
333
  }
153
334
 
154
335
  const LINK_TYPES = { blocks: "blocks", "blocked-by": "blockedBy", blockedby: "blockedBy", related: "related", answers: "answers" };
155
336
 
156
337
  async function link(args, flags) {
157
- const [ref, kind, other] = args;
338
+ const [itemRef, kind, other] = args;
158
339
  const type = LINK_TYPES[String(kind ?? "").toLowerCase()];
159
- if (!ref || !type || !other) {
340
+ if (!itemRef || !type || !other) {
160
341
  throw new CliError("zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>", {
161
342
  hint: "`answers` says this work exists because of a request, and takes project#N.",
162
343
  });
163
344
  }
164
345
  const ctx = resolveContext(flags);
165
- const { path } = await resolveItem(ctx, ref, { kind: "task" });
346
+ const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
166
347
  // A far end written `project#42` is a request; anything else is a task. The
167
348
  // API takes them under different keys because only a request can be answered.
168
349
  const body = other.includes("#") && !/^#?\d+$/.test(other)
@@ -170,7 +351,7 @@ async function link(args, flags) {
170
351
  : { type, task: other };
171
352
  const res = pick(await api(ctx, "POST", `${path}/links`, { body }), "task");
172
353
  if (flags.json) return json(res), 0;
173
- out(`${ref} ${type} ${other}`);
354
+ out(`${itemRef} ${type} ${other}`);
174
355
  return 0;
175
356
  }
176
357
 
@@ -185,7 +366,7 @@ async function ref(args, flags) {
185
366
  const ctx = resolveContext(flags);
186
367
  const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
187
368
  const body = { url };
188
- if (givenFlag(flags.title) !== undefined) body.title = flags.title;
369
+ if (givenFlag(flags.title) !== undefined) body.title = givenFlag(flags.title);
189
370
  const res = pick(await api(ctx, "POST", `${path}/refs`, { body }), "ref");
190
371
  if (flags.json) return json(res), 0;
191
372
  out(`${itemRef} ${url}`);
@@ -209,15 +390,39 @@ async function attention(args, flags) {
209
390
  return 0;
210
391
  }
211
392
 
212
- async function show(args, flags) {
213
- const [itemRef] = args;
214
- if (!itemRef) throw new CliError("zumino task show <CODE>");
393
+ async function tag(args, flags) {
394
+ return setTag(args, flags, true);
395
+ }
396
+
397
+ async function untag(args, flags) {
398
+ return setTag(args, flags, false);
399
+ }
400
+
401
+ /**
402
+ * A tag on or off, by id.
403
+ *
404
+ * By id and not by name because a palette belongs to a project and names repeat
405
+ * across them — `zumino project tags` is the read that turns one into the other,
406
+ * and it is the only reason that command exists.
407
+ */
408
+ async function setTag(args, flags, on) {
409
+ const [itemRef, tagId] = args;
410
+ const verb = on ? "tag" : "untag";
411
+ if (!itemRef || !tagId) {
412
+ throw new CliError(`zumino task ${verb} <CODE> <tagId>`, {
413
+ hint: "List the project's tag ids with: zumino project tags",
414
+ });
415
+ }
215
416
  const ctx = resolveContext(flags);
216
417
  const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
217
- const res = pick(await api(ctx, "GET", path), "task");
218
- if (flags.json) return json(res), 0;
219
- out(`${codeOf(res)} ${safeText(res.title)}`);
220
- out(`status ${res.status} priority ${res.priority ?? "-"} assignee ${safeText(res.assignee?.name) || "-"}`);
221
- if (res.description) out(`\n${safeText(res.description)}`);
418
+ // Written as two calls with literal methods rather than one with a ternary:
419
+ // `test/cli-routes.test.ts` reads these call sites out of the source to check
420
+ // each against the server, and a method it cannot read is an endpoint nothing
421
+ // checks.
422
+ const res = on
423
+ ? await api(ctx, "PUT", `${path}/tags/${encodeURIComponent(tagId)}`)
424
+ : await api(ctx, "DELETE", `${path}/tags/${encodeURIComponent(tagId)}`);
425
+ if (flags.json) return json(res ?? { ok: true }), 0;
426
+ out(`${itemRef} ${on ? "tagged" : "untagged"}`);
222
427
  return 0;
223
428
  }
@@ -0,0 +1,51 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { clip, json, note, out, table } from "../output.js";
5
+
6
+ /*
7
+ * The workspaces a token can reach.
8
+ *
9
+ * **The bootstrap read.** Every other path on this API carries a `{workspace}`
10
+ * segment, so a client holding a fresh token cannot build a single URL until
11
+ * this one has answered — which is exactly why it was the first thing missing: a
12
+ * CLI with no way to ask "what am I looking at" sends its user to
13
+ * `zumino api GET /workspaces`, and an escape hatch is not an answer to the
14
+ * first question anybody has.
15
+ *
16
+ * There is nothing else here, and that is the API's shape rather than an
17
+ * omission: creating a workspace, membership and roles are governance, and an
18
+ * agent changes the work and not the workspace (`DOMAIN.md` § Rules).
19
+ */
20
+
21
+ export const SUB = { list };
22
+
23
+ export async function run(args, flags) {
24
+ const [sub, ...rest] = args;
25
+ const fn = SUB[sub];
26
+ if (!fn) {
27
+ throw new CliError(`zumino workspace: unknown subcommand "${sub ?? ""}".`, {
28
+ hint: `One of: ${Object.keys(SUB).join(", ")}`,
29
+ });
30
+ }
31
+ return fn(rest, flags);
32
+ }
33
+
34
+ async function list(args, flags) {
35
+ const ctx = resolveContext(flags);
36
+ const res = await api(ctx, "GET", "/workspaces");
37
+ const workspaces = res?.workspaces ?? [];
38
+ if (flags.json) return json(workspaces), 0;
39
+
40
+ if (workspaces.length === 0) {
41
+ out("No workspaces.");
42
+ note(" A token acts for its owner, so this is every workspace they belong to.");
43
+ return 0;
44
+ }
45
+
46
+ table(
47
+ workspaces.map((w) => [w.slug ?? "", w.role ?? "", clip(w.name)]),
48
+ { head: ["SLUG", "ROLE", "NAME"] },
49
+ );
50
+ return 0;
51
+ }
package/src/events.js ADDED
@@ -0,0 +1,104 @@
1
+ import { api } from "./client.js";
2
+ import { oneValue, positiveInt } from "./flags.js";
3
+ import { clip, dim, json, out, safeText, table } from "./output.js";
4
+
5
+ /*
6
+ * The activity log, for whatever the activity is about.
7
+ *
8
+ * One renderer rather than one per kind, because the endpoint is one pager: a
9
+ * task and a request produce structurally identical events — same kinds, same
10
+ * two sides, same actor — and the server publishes them through the same view.
11
+ *
12
+ * **What this is for.** The comment thread records what people *said*; this
13
+ * records what anyone, person or agent, actually *did*. "What was tried and then
14
+ * reverted" is the context an executor cannot recover any other way, and the
15
+ * usual cost of not having it is re-doing an approach somebody abandoned for a
16
+ * reason nobody wrote down.
17
+ */
18
+
19
+ /**
20
+ * @param {any} ctx
21
+ * @param {string} itemPath the item's own path, as the server published it
22
+ * @param {Record<string, any>} flags
23
+ */
24
+ export async function listEvents(ctx, itemPath, flags) {
25
+ const res = await api(ctx, "GET", `${itemPath}/events`, {
26
+ query: {
27
+ // Single-valued: the endpoint reads one `?kind=` and one `?field=`, so a
28
+ // second would be dropped rather than widen the filter.
29
+ kind: oneValue(flags, "kind"),
30
+ field: oneValue(flags, "field"),
31
+ limit: positiveInt(flags.limit, "limit"),
32
+ before: oneValue(flags, "before"),
33
+ },
34
+ });
35
+
36
+ const events = res?.events ?? [];
37
+ if (flags.json) return json(res), 0;
38
+ if (events.length === 0) return out("No events."), 0;
39
+
40
+ table(
41
+ events.map((e) => [
42
+ when(e.createdAt),
43
+ e.kind ?? "",
44
+ e.field ?? "",
45
+ safeText(e.actor?.name) || "—",
46
+ move(e),
47
+ ]),
48
+ { head: ["WHEN", "KIND", "FIELD", "WHO", "WHAT"] },
49
+ );
50
+
51
+ /*
52
+ * `total` is the history's whole length, NOT the number of rows the filter
53
+ * matched — the endpoint counts every event on the item and pages the filtered
54
+ * set. Printed as "1 of 6" that reads as "five more matches", which is the
55
+ * wrong thing to believe about a filter, so a filtered page says what it is.
56
+ */
57
+ const more = res?.nextBefore;
58
+ const total = typeof res?.total === "number" ? res.total : null;
59
+ const filtered = Boolean(flags.kind || flags.field);
60
+ const count =
61
+ total === null
62
+ ? `${events.length} shown.`
63
+ : filtered
64
+ ? `${events.length} shown — the history has ${total} events in all.`
65
+ : `${events.length} of ${total}.`;
66
+ out(dim(`\n${count}${more ? ` Next page: --before ${more}` : ""}`));
67
+ return 0;
68
+ }
69
+
70
+ /** `2026-09-10 14:02`, in the reader's own zone. Seconds are noise here. */
71
+ function when(iso) {
72
+ const d = new Date(iso);
73
+ if (Number.isNaN(d.getTime())) return safeText(iso);
74
+ const pad = (n) => String(n).padStart(2, "0");
75
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
76
+ }
77
+
78
+ /**
79
+ * `before → after`, short enough for a row.
80
+ *
81
+ * A list response elides long values and says so (`truncated`), so a spec
82
+ * rewrite reads as a length rather than as half a document pasted into a table.
83
+ * The full stored value is one event read away — `zumino api GET …/events/{id}`.
84
+ */
85
+ function move(e) {
86
+ const side = (v) => {
87
+ if (!v) return "";
88
+ if (v.truncated) return `${clip(v.value, 24)} (${v.length} chars)`;
89
+ const value = v.value;
90
+ if (value === null || value === undefined) return "—";
91
+ if (typeof value === "string") return clip(value, 28);
92
+ // A person, as the log records one: `{id, name}`. Printed as JSON it fills
93
+ // the column with an id nobody reads, and the name is the whole point of
94
+ // the row — `assigneeId: — → Yuna Park`.
95
+ if (value && typeof value === "object" && "id" in value && "name" in value) {
96
+ return clip(value.name ?? value.id, 28);
97
+ }
98
+ return clip(JSON.stringify(value), 28);
99
+ };
100
+ const before = side(e.before);
101
+ const after = side(e.after);
102
+ if (!before && !after) return "";
103
+ return before ? `${before} → ${after}` : after;
104
+ }