@tuanhv/op-cli 0.1.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.
@@ -0,0 +1,1586 @@
1
+ import { OpCliError, writeRefusal } from "../core/errors.js";
2
+ import { buildWpFilters, filtersQuery, } from "../core/filters.js";
3
+ import { defineCollectionCommand, emitRows, PAGED_JSON_HELP, } from "../core/define.js";
4
+ import { flattenHalRecord, isFlatLink } from "../core/hal.js";
5
+ import { apiDelete, apiGet, apiPost, apiPatchRaw, apiPostRaw, authenticate, } from "../core/http.js";
6
+ import { allPageSize, DEFAULT_PAGE_SIZE, halElements, parsePageSize, withPageSize, } from "../core/paginate.js";
7
+ import { explicitCustomFieldKey, isIdForm, matchByName, rankByCloseness, resolveName, } from "../context/resolve.js";
8
+ import { parseOptionalId, } from "../context/profile.js";
9
+ import { loadProjectVocabulary, loadStoredMetadata, refreshStoredMetadata, } from "../context/metadata.js";
10
+ import { formatCell, renderTable } from "../output/table.js";
11
+ // Canonical row order for a single-record table; every other key of the
12
+ // flattened record follows in server order.
13
+ const PREFERRED_FIELDS = [
14
+ "id",
15
+ "subject",
16
+ "type",
17
+ "status",
18
+ "priority",
19
+ "assignee",
20
+ "author",
21
+ "project",
22
+ "version",
23
+ "category",
24
+ "startDate",
25
+ "dueDate",
26
+ "createdAt",
27
+ "updatedAt",
28
+ "lockVersion",
29
+ ];
30
+ // The list table is fixed: these are the columns an agent scans first.
31
+ export const LIST_COLUMNS = [
32
+ { title: "ID", field: "id" },
33
+ { title: "SUBJECT", field: "subject" },
34
+ { title: "TYPE", field: "type" },
35
+ { title: "STATUS", field: "status" },
36
+ { title: "PRIORITY", field: "priority" },
37
+ { title: "ASSIGNEE", field: "assignee" },
38
+ { title: "UPDATED", field: "updatedAt" },
39
+ ];
40
+ function defaultFields(record) {
41
+ const preferred = PREFERRED_FIELDS.filter((field) => field in record);
42
+ const rest = Object.keys(record).filter((key) => !preferred.includes(key));
43
+ return [...preferred, ...rest];
44
+ }
45
+ function selectedFields(raw, record,
46
+ /**
47
+ * What already happened, when the record is the result of a completed
48
+ * write. A bad --fields name is still misuse, but the message has to
49
+ * say the write landed or it invites a repeat that writes twice.
50
+ */
51
+ done) {
52
+ if (raw === undefined) {
53
+ return defaultFields(record);
54
+ }
55
+ const requested = [
56
+ ...new Set(raw.split(",").map((name) => name.trim()).filter((name) => name !== "")),
57
+ ];
58
+ const first = requested.find((name) => !(name in record));
59
+ if (first !== undefined) {
60
+ throw new OpCliError("USAGE_ERROR", `${done === undefined ? "" : `${done}; `}`
61
+ + `field "${first}" is not a column. Valid fields, closest first: `
62
+ + `${rankByCloseness(first, Object.keys(record)).join(", ")}.`, "run the command without --fields to list every available column.");
63
+ }
64
+ return requested;
65
+ }
66
+ /** Work packages are addressed by numeric id everywhere on this surface. */
67
+ function requireWpId(reference) {
68
+ if (!isIdForm(reference)) {
69
+ throw new OpCliError("USAGE_ERROR", `work package "${reference}" is not an id.`, "work packages are addressed by their numeric id.");
70
+ }
71
+ }
72
+ /**
73
+ * The one shared tail of every single-record command: --fields plus the
74
+ * two shapes, a FIELD/VALUE table by default.
75
+ */
76
+ function renderRecord(runtime, options, record, done) {
77
+ const fields = selectedFields(options.fields, record, done);
78
+ if (options.json === true) {
79
+ const picked = {};
80
+ for (const field of fields) {
81
+ picked[field] = record[field];
82
+ }
83
+ runtime.write(`${JSON.stringify(picked)}\n`);
84
+ return;
85
+ }
86
+ runtime.write(renderTable(["FIELD", "VALUE"], fields.map((field) => [field, formatCell(record[field])])));
87
+ }
88
+ /**
89
+ * The shared opening of every command on this surface: JSON mode first,
90
+ * then the id check is the caller's, then the profile.
91
+ */
92
+ async function openProfile(runtime, options) {
93
+ runtime.setJsonMode(options.json === true);
94
+ return runtime.resolve({
95
+ profile: options.profile,
96
+ project: parseOptionalId(options.project),
97
+ });
98
+ }
99
+ function collectValue(value, previous) {
100
+ return [...previous, value];
101
+ }
102
+ /**
103
+ * Attach the shared filter surface of `wp list` and `wp count`. Both
104
+ * commands accept exactly the same flags; repeating a value flag ORs.
105
+ */
106
+ function addFilterFlags(command) {
107
+ return command
108
+ .option("--status <name>", "status name or id; repeat to OR values", collectValue, [])
109
+ .option("--type <name>", "type name or id; repeat to OR values", collectValue, [])
110
+ .option("--assignee <name>", "user name, id, or me; repeat to OR values", collectValue, [])
111
+ .option("--author <name>", "user name, id, or me; repeat to OR values", collectValue, [])
112
+ .option("--version <name>", "version name or id; repeat to OR values", collectValue, [])
113
+ .option("--category <name>", "category name or id; repeat to OR values", collectValue, [])
114
+ .option("--priority <name>", "priority name or id; repeat to OR values", collectValue, [])
115
+ .option("--parent <reference>", "parent work package id or exact subject", collectValue, [])
116
+ .option("--updated-after <value>", "today, yesterday, days back such as 7d, or YYYY-MM-DD")
117
+ .option("--open", "shorthand for every open status")
118
+ .option("--closed", "shorthand for every closed status")
119
+ .option("--profile <name>", "use this profile for this command only")
120
+ .option("--project <id>", "override the profile default project");
121
+ }
122
+ function rawListFlags(options) {
123
+ return {
124
+ statuses: options.status,
125
+ types: options.type,
126
+ assignees: options.assignee,
127
+ authors: options.author,
128
+ versions: options.version,
129
+ categories: options.category,
130
+ priorities: options.priority,
131
+ parents: options.parent,
132
+ updatedAfter: options.updatedAfter,
133
+ open: options.open,
134
+ closed: options.closed,
135
+ };
136
+ }
137
+ /** Metadata that lives instance-wide: statuses, types, priorities. */
138
+ function instanceSource(runtime, profile, label, select, memo) {
139
+ return {
140
+ label,
141
+ load: async () => select(await (memo === undefined
142
+ ? loadStoredMetadata(runtime.env, profile)
143
+ : memo.metadata())),
144
+ refresh: async () => {
145
+ // Drop the shared snapshots before and while the store is rebuilt:
146
+ // a concurrent item must never read the stale one.
147
+ memo?.invalidate();
148
+ await refreshStoredMetadata(runtime.env, profile);
149
+ },
150
+ };
151
+ }
152
+ /** Metadata scoped to the profile's project: members, versions, categories. */
153
+ function projectSource(runtime, profile, flag, label, select, memo) {
154
+ return {
155
+ label,
156
+ load: async () => {
157
+ if (profile.project === undefined) {
158
+ throw new OpCliError("USAGE_ERROR", `--${flag} needs a project to look names up in.`, "pass --project <id> or set a default project on the profile.");
159
+ }
160
+ return select(await (memo === undefined
161
+ ? loadProjectVocabulary(runtime.env, profile)
162
+ : memo.vocabulary()));
163
+ },
164
+ refresh: async () => {
165
+ memo?.invalidate();
166
+ await refreshStoredMetadata(runtime.env, profile);
167
+ },
168
+ };
169
+ }
170
+ async function resolveValues(raws, source) {
171
+ const ids = [];
172
+ for (const raw of raws) {
173
+ const value = isIdForm(raw) ? Number(raw) : await resolveName(raw, source);
174
+ const key = String(value);
175
+ if (!ids.includes(key)) {
176
+ ids.push(key);
177
+ }
178
+ }
179
+ return ids;
180
+ }
181
+ async function resolveUserValues(runtime, profile, flag, raws) {
182
+ const source = projectSource(runtime, profile, flag, flag, (vocabulary) => vocabulary.members.map((member) => ({
183
+ name: member.name,
184
+ owner: member.type,
185
+ value: member.user_id,
186
+ })));
187
+ let authenticatedId;
188
+ const ids = [];
189
+ const push = (id) => {
190
+ const key = String(id);
191
+ if (!ids.includes(key)) {
192
+ ids.push(key);
193
+ }
194
+ };
195
+ for (const raw of raws) {
196
+ if (raw.toLowerCase() === "me") {
197
+ authenticatedId ??= (await authenticate(profile.instanceUrl, profile.apiKey)).id;
198
+ push(authenticatedId);
199
+ }
200
+ else if (isIdForm(raw)) {
201
+ push(raw);
202
+ }
203
+ else {
204
+ push(await resolveName(raw, source));
205
+ }
206
+ }
207
+ return ids;
208
+ }
209
+ function membersSource(runtime, profile, flag, memo) {
210
+ return projectSource(runtime, profile, flag, flag, (vocabulary) => vocabulary.members.map((member) => ({
211
+ name: member.name,
212
+ owner: member.type,
213
+ value: member.user_id,
214
+ })), memo);
215
+ }
216
+ function newResolutionMemo(runtime, profile) {
217
+ let metadata;
218
+ let vocabulary;
219
+ let me;
220
+ // A rejected load clears its slot so one failing item cannot poison
221
+ // the shared promise for every later item.
222
+ const guarded = async (build, clear) => {
223
+ try {
224
+ return await build();
225
+ }
226
+ catch (error) {
227
+ clear();
228
+ throw error;
229
+ }
230
+ };
231
+ return {
232
+ metadata: () => {
233
+ metadata ??= guarded(() => loadStoredMetadata(runtime.env, profile), () => {
234
+ metadata = undefined;
235
+ });
236
+ return metadata;
237
+ },
238
+ vocabulary: () => {
239
+ vocabulary ??= guarded(() => loadProjectVocabulary(runtime.env, profile), () => {
240
+ vocabulary = undefined;
241
+ });
242
+ return vocabulary;
243
+ },
244
+ me: () => {
245
+ me ??= guarded(() => authenticate(profile.instanceUrl, profile.apiKey).then((user) => user.id), () => {
246
+ me = undefined;
247
+ });
248
+ return me;
249
+ },
250
+ invalidate: () => {
251
+ metadata = undefined;
252
+ vocabulary = undefined;
253
+ me = undefined;
254
+ },
255
+ };
256
+ }
257
+ // ---------------------------------------------------------------------------
258
+ // Bulk creation from stdin
259
+ /**
260
+ * Everything the two create paths share up to the wire: values resolved
261
+ * by name into a payload with the subject and project link attached.
262
+ */
263
+ async function prepareCreatePayload(runtime, profile, subject, values, memo) {
264
+ const { payload, refs } = await resolveNamedValues(runtime, profile, values, memo);
265
+ payload.subject = subject;
266
+ const links = payload._links;
267
+ links.project = { href: `/api/v3/projects/${String(profile.project)}` };
268
+ return { payload, refs };
269
+ }
270
+ /**
271
+ * The send tail both create paths share: one proof-carrying retry of
272
+ * ADR-0002, then the closed catalogue mapping. Returns the raw body so
273
+ * the single path can still flatten and render it.
274
+ */
275
+ async function submitCreate(runtime, profile, payload, refs, memo) {
276
+ let response = await postCreate(profile, payload);
277
+ if (response.status >= 400) {
278
+ response = await retryWithFreshIds(runtime, profile, response, payload, refs, () => postCreate(profile, payload), memo);
279
+ }
280
+ if (response.status >= 400) {
281
+ throw writeRejection(response.status, response.body);
282
+ }
283
+ return response.body;
284
+ }
285
+ /** Keys one stdin item may carry; everything else is a loud usage error. */
286
+ const BULK_ITEM_KEYS = [
287
+ "subject",
288
+ "type",
289
+ "status",
290
+ "priority",
291
+ "assignee",
292
+ "version",
293
+ "category",
294
+ "parent",
295
+ "description",
296
+ "field",
297
+ ];
298
+ /**
299
+ * One element of the --stdin array. Mirrors the value flags of the
300
+ * single-subject path; every rejection names the offending input by its
301
+ * zero-based index so the NDJSON line and the message agree.
302
+ */
303
+ function parseBulkItem(raw, index) {
304
+ const usage = (message, hint) => new OpCliError("USAGE_ERROR", `input ${index}: ${message}`, hint);
305
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
306
+ throw usage('every input must be an object with a non-empty "subject".');
307
+ }
308
+ const record = raw;
309
+ const unknownKeys = Object.keys(record)
310
+ .filter((key) => !BULK_ITEM_KEYS.includes(key));
311
+ if (unknownKeys.length > 0) {
312
+ throw usage(`unknown key(s): ${unknownKeys.join(", ")}.`, `allowed keys: ${BULK_ITEM_KEYS.join(", ")}.`);
313
+ }
314
+ const subject = record.subject;
315
+ if (typeof subject !== "string" || subject.trim() === "") {
316
+ throw usage('every input needs a non-empty "subject" string.');
317
+ }
318
+ const values = {};
319
+ const scalarKeys = [
320
+ "type",
321
+ "status",
322
+ "priority",
323
+ "assignee",
324
+ "version",
325
+ "category",
326
+ "parent",
327
+ "description",
328
+ ];
329
+ for (const key of scalarKeys) {
330
+ const rawValue = record[key];
331
+ if (rawValue === undefined) {
332
+ continue;
333
+ }
334
+ if (typeof rawValue !== "string" || rawValue === "") {
335
+ throw usage(`"${key}" must be a non-empty name-or-id string.`);
336
+ }
337
+ values[key] = rawValue;
338
+ }
339
+ const field = record.field;
340
+ if (field !== undefined) {
341
+ if (!Array.isArray(field)
342
+ || field.some((entry) => typeof entry !== "string")) {
343
+ throw usage('"field" must be an array of "Name=Value" strings.', 'e.g. "field": ["Estimate=5"]; "Name=" clears the field.');
344
+ }
345
+ values.field = field;
346
+ }
347
+ return { subject, values };
348
+ }
349
+ /**
350
+ * The --stdin batch: one NDJSON result line per input, in input order,
351
+ * continuing past failures unless --fail-fast. Every item shares one
352
+ * resolution memo; any failure ends the run with that failure's catalogue
353
+ * exit code after the last line is out.
354
+ */
355
+ async function runBulkCreate(runtime, subjectArg, options) {
356
+ if (subjectArg !== undefined) {
357
+ throw new OpCliError("USAGE_ERROR", "wp create --stdin takes its subjects from the input array.", "drop the subject argument, or leave out --stdin to create one.");
358
+ }
359
+ if (options.json === true) {
360
+ throw new OpCliError("USAGE_ERROR", "wp create --stdin already reports one NDJSON line per item.", "drop --json; each line is machine-readable as it is.");
361
+ }
362
+ if (options.fields !== undefined) {
363
+ throw new OpCliError("USAGE_ERROR", "wp create --stdin reports a fixed result line per item, not a record.", "drop --fields; run op-cli wp get --fields on an id from the output.");
364
+ }
365
+ const readStdin = runtime.readStdin;
366
+ if (readStdin === undefined) {
367
+ throw new OpCliError("USAGE_ERROR", "stdin is not readable in this environment.", "pipe the JSON array from a file: op-cli wp create --stdin < wps.json.");
368
+ }
369
+ runtime.setJsonMode(false);
370
+ const text = await readStdin();
371
+ let parsed;
372
+ try {
373
+ parsed = JSON.parse(text);
374
+ }
375
+ catch {
376
+ throw new OpCliError("USAGE_ERROR", "stdin did not carry valid JSON.", "wp create --stdin reads one JSON array of work packages.");
377
+ }
378
+ if (!Array.isArray(parsed)) {
379
+ throw new OpCliError("USAGE_ERROR", "wp create --stdin expects a JSON array of work packages.", 'each element is an object like {"subject": "...", "type": "Task"}.');
380
+ }
381
+ const profile = await runtime.resolve({
382
+ profile: options.profile,
383
+ project: parseOptionalId(options.project),
384
+ });
385
+ if (profile.project === undefined) {
386
+ throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <id> or set a default project on the profile.");
387
+ }
388
+ const memo = newResolutionMemo(runtime, profile);
389
+ const dryRun = options.dryRun === true;
390
+ let attempted = 0;
391
+ let failures = 0;
392
+ let firstFailure;
393
+ for (let index = 0; index < parsed.length; index++) {
394
+ attempted += 1;
395
+ try {
396
+ const item = parseBulkItem(parsed[index], index);
397
+ const { payload, refs } = await prepareCreatePayload(runtime, profile, item.subject, item.values, memo);
398
+ if (dryRun) {
399
+ runtime.write(`${JSON.stringify({
400
+ index,
401
+ ok: true,
402
+ status: "would-create",
403
+ subject: item.subject,
404
+ })}\n`);
405
+ continue;
406
+ }
407
+ const body = await submitCreate(runtime, profile, payload, refs, memo);
408
+ const id = typeof body === "object" && body !== null
409
+ ? body.id
410
+ : undefined;
411
+ runtime.write(`${JSON.stringify({
412
+ index,
413
+ ok: true,
414
+ status: "created",
415
+ ...(typeof id === "number" ? { id } : {}),
416
+ subject: item.subject,
417
+ })}\n`);
418
+ }
419
+ catch (error) {
420
+ failures += 1;
421
+ const failure = error instanceof OpCliError
422
+ ? error
423
+ : new OpCliError("INTERNAL_ERROR");
424
+ firstFailure ??= failure;
425
+ runtime.write(`${JSON.stringify({
426
+ ...(failure.details ?? {}),
427
+ index,
428
+ ok: false,
429
+ status: "failed",
430
+ code: failure.code,
431
+ message: failure.message,
432
+ hint: failure.hint,
433
+ })}\n`);
434
+ if (options.failFast === true) {
435
+ break;
436
+ }
437
+ }
438
+ }
439
+ if (firstFailure !== undefined) {
440
+ // The run's exit code comes from the first failure's catalogue code,
441
+ // so scripts matching on codes keep their promise across both paths.
442
+ // The denominator is always the whole batch; an early stop says so
443
+ // explicitly instead of shrinking the total.
444
+ throw new OpCliError(firstFailure.code, attempted < parsed.length
445
+ ? `${failures} of ${parsed.length} work packages failed; stopped after ${attempted}.`
446
+ : `${failures} of ${parsed.length} work packages failed.`, "each failed input carries its own result line above.");
447
+ }
448
+ }
449
+ function splitFieldPair(raw) {
450
+ const at = raw.indexOf("=");
451
+ if (at < 1) {
452
+ throw new OpCliError("USAGE_ERROR", `--field "${raw}" is not Name=Value.`, 'separate the human field name from its value with an equals sign, '
453
+ + 'e.g. --field "Estimate=5"; repeat the flag to add more values.');
454
+ }
455
+ return { name: raw.slice(0, at), value: raw.slice(at + 1) };
456
+ }
457
+ /**
458
+ * One row per (type, custom field) pair. A field attached to several
459
+ * types appears once per type under the same human name; like
460
+ * flattenFields in commands/meta.ts this list is NOT unique by name, and
461
+ * that duplication is exactly what makes a shared name resolve as
462
+ * ambiguous rather than wrong.
463
+ */
464
+ function customFieldRows(metadata, vocabulary) {
465
+ const typeName = new Map(metadata.types.map((entry) => [String(entry.id), entry.name]));
466
+ const rows = [];
467
+ for (const [typeId, fields] of Object.entries(vocabulary.custom_fields)) {
468
+ const owner = `Type ${typeName.get(typeId) ?? typeId}`;
469
+ for (const field of fields) {
470
+ rows.push({ name: field.name, owner, value: field.key });
471
+ }
472
+ }
473
+ return rows;
474
+ }
475
+ async function resolveFieldDefinition(runtime, profile, rawName, memo) {
476
+ if (profile.project === undefined) {
477
+ throw new OpCliError("USAGE_ERROR", "--field needs a project to look field names up in.", "pass --project <id> or set a default project on the profile.");
478
+ }
479
+ const metadata = memo === undefined
480
+ ? loadStoredMetadata(runtime.env, profile)
481
+ : memo.metadata();
482
+ const vocabulary = memo === undefined
483
+ ? loadProjectVocabulary(runtime.env, profile)
484
+ : memo.vocabulary();
485
+ const source = {
486
+ label: "field",
487
+ load: async () => customFieldRows(await metadata, await vocabulary),
488
+ refresh: async () => {
489
+ memo?.invalidate();
490
+ await refreshStoredMetadata(runtime.env, profile);
491
+ },
492
+ };
493
+ const explicit = explicitCustomFieldKey(rawName);
494
+ const key = explicit ?? await resolveName(rawName, source);
495
+ const definitions = Object.values((await vocabulary).custom_fields).flat();
496
+ const field = definitions.find((entry) => entry.key === key);
497
+ if (field === undefined) {
498
+ throw new OpCliError("USAGE_ERROR", `custom field "${rawName}" is not defined in this project.`, `known fields: ${definitions
499
+ .map((entry) => `${entry.key} (${entry.name})`)
500
+ .join(", ")}.`);
501
+ }
502
+ return field;
503
+ }
504
+ const CUSTOM_OPTIONS_HREF = "/api/v3/custom_options/";
505
+ /**
506
+ * Resolve one link-valued option into the payload. An all-digits value is
507
+ * an id and goes straight in; anything else is resolved here and
508
+ * remembered so a rejection that blames this exact attribute can be
509
+ * retried after one real refresh.
510
+ */
511
+ async function resolveLinkOption(links, attribute, hrefBase, flag, raw, source, refs) {
512
+ if (raw === undefined || raw === "") {
513
+ return;
514
+ }
515
+ let id;
516
+ if (isIdForm(raw)) {
517
+ id = Number(raw);
518
+ }
519
+ else {
520
+ id = Number(await resolveName(raw, source));
521
+ refs.push({ attribute, raw, idBefore: id, hrefBase, source, link: true });
522
+ }
523
+ links[attribute] = { href: `${hrefBase}${String(id)}` };
524
+ }
525
+ /** Same contract as resolveLinkOption for members: names, ids, and me. */
526
+ async function resolveUserOption(runtime, profile, links, attribute, hrefBase, flag, raw, refs, memo) {
527
+ if (raw === undefined || raw === "") {
528
+ return;
529
+ }
530
+ let id;
531
+ if (raw.toLowerCase() === "me") {
532
+ id = memo === undefined
533
+ ? (await authenticate(profile.instanceUrl, profile.apiKey)).id
534
+ : await memo.me();
535
+ }
536
+ else if (isIdForm(raw)) {
537
+ id = Number(raw);
538
+ }
539
+ else {
540
+ const source = membersSource(runtime, profile, flag, memo);
541
+ id = Number(await resolveName(raw, source));
542
+ refs.push({ attribute, raw, idBefore: id, hrefBase, source, link: true });
543
+ }
544
+ links[attribute] = { href: `${hrefBase}${String(id)}` };
545
+ }
546
+ function booleanFieldValue(field, raw) {
547
+ const lowered = raw.toLowerCase();
548
+ if (lowered !== "true" && lowered !== "false") {
549
+ throw new OpCliError("USAGE_ERROR", `--field "${field.name}=${raw}" is not true or false.`, 'boolean fields accept only true and false.');
550
+ }
551
+ return lowered === "true";
552
+ }
553
+ async function userFieldValue(runtime, profile, field, raw, payload, refs, memo) {
554
+ let id;
555
+ if (raw.toLowerCase() === "me") {
556
+ id = memo === undefined
557
+ ? (await authenticate(profile.instanceUrl, profile.apiKey)).id
558
+ : await memo.me();
559
+ }
560
+ else if (isIdForm(raw)) {
561
+ id = Number(raw);
562
+ }
563
+ else {
564
+ const source = membersSource(runtime, profile, field.name, memo);
565
+ id = Number(await resolveName(raw, source));
566
+ // A user-typed custom field holds its resolved href at the top level
567
+ // of the payload rather than under _links; record it like any
568
+ // name-resolved value so a rejection can be retried with fresh ids.
569
+ refs.push({
570
+ attribute: field.key,
571
+ raw,
572
+ idBefore: id,
573
+ hrefBase: "/api/v3/users/",
574
+ source,
575
+ link: false,
576
+ });
577
+ }
578
+ payload[field.key] = { href: `/api/v3/users/${String(id)}` };
579
+ }
580
+ /** The options of one list field, as a vocabulary names resolve against. */
581
+ function listFieldSource(runtime, profile, field, memo) {
582
+ return projectSource(runtime, profile, "field", field.name,
583
+ // Read the options back out of the vocabulary rather than off the
584
+ // captured definition: after a refresh the store holds fresh ids, and
585
+ // a field attached to several types repeats one option list.
586
+ (vocabulary) => Object.values(vocabulary.custom_fields)
587
+ .flat()
588
+ .filter((entry) => entry.key === field.key)
589
+ .flatMap((entry) => entry.allowed_options ?? [])
590
+ .map((option) => ({
591
+ name: option.name,
592
+ owner: field.name,
593
+ value: option.id,
594
+ })), memo);
595
+ }
596
+ /**
597
+ * A list field's value is a CustomOption resource: the payload carries its
598
+ * href under _links, never the option's text, which the API accepts and
599
+ * then ignores. Clearing empties the same place, because a value that
600
+ * lives under _links has no top-level form to null out.
601
+ */
602
+ async function listFieldValues(runtime, profile, field, values, links, refs, memo) {
603
+ if (values.length > 1 && field.is_multi !== true) {
604
+ throw new OpCliError("USAGE_ERROR", `--field "${field.name}" holds one value.`, "a single-valued list field takes one option name or id.");
605
+ }
606
+ const source = listFieldSource(runtime, profile, field, memo);
607
+ const hrefs = [];
608
+ for (const value of values) {
609
+ const id = isIdForm(value)
610
+ ? Number(value)
611
+ : Number(await resolveName(value, source));
612
+ if (!isIdForm(value)) {
613
+ refs.push({
614
+ attribute: field.key,
615
+ raw: value,
616
+ idBefore: id,
617
+ hrefBase: CUSTOM_OPTIONS_HREF,
618
+ source,
619
+ link: true,
620
+ });
621
+ }
622
+ const href = `${CUSTOM_OPTIONS_HREF}${String(id)}`;
623
+ if (!hrefs.some((entry) => entry.href === href)) {
624
+ hrefs.push({ href });
625
+ }
626
+ }
627
+ links[field.key] = field.is_multi === true
628
+ ? hrefs
629
+ : hrefs[0] ?? { href: null };
630
+ }
631
+ const RETRYABLE_WRITE_STATUSES = [404, 422];
632
+ /** The attribute an OpenProject rejection blames, e.g. "status". */
633
+ function pointedAttribute(body) {
634
+ if (typeof body !== "object" || body === null) {
635
+ return undefined;
636
+ }
637
+ const record = body;
638
+ const embedded = record._embedded;
639
+ const details = embedded?.details ?? record.details ?? record.attribute;
640
+ const name = typeof details === "string"
641
+ ? details
642
+ : typeof details === "object" && details !== null
643
+ ? details.attribute
644
+ : undefined;
645
+ if (typeof name !== "string") {
646
+ return undefined;
647
+ }
648
+ // Link rejections sometimes spell the target "links/status".
649
+ return name.replace(/^links[./]/i, "");
650
+ }
651
+ /**
652
+ * Catalogue mapping for a write that survived the proof-carrying retry:
653
+ * 404 stays NOT_FOUND, 5xx becomes the unknown-state NETWORK_ERROR, and
654
+ * everything else is the shared refusal mapping.
655
+ */
656
+ function writeRejection(status, body, verb = "create") {
657
+ if (status === 404) {
658
+ return new OpCliError("NOT_FOUND");
659
+ }
660
+ // A 409 is the optimistic-locking catalogue entry: the caller either
661
+ // already handled the retry rule or has nothing safe left to try.
662
+ if (status === 409) {
663
+ return new OpCliError("CONFLICT");
664
+ }
665
+ // A 5xx means the request may still have been applied server-side, so
666
+ // the honest answer is "unknown state", never a retry that could
667
+ // duplicate the work package.
668
+ if (status >= 500) {
669
+ const unknownState = verb === "create"
670
+ ? "whether the work package was created is unknown."
671
+ : "whether the change was applied is unknown.";
672
+ const hint = verb === "create"
673
+ ? "check whether the work package exists before repeating the command."
674
+ : "run op-cli wp get to see the stored values before repeating the command.";
675
+ return new OpCliError("NETWORK_ERROR", `the ${verb} failed with HTTP ${status}; ${unknownState}`, hint);
676
+ }
677
+ return writeRefusal(verb, status, body);
678
+ }
679
+ /**
680
+ * A create request whose failure to complete leaves the state unknown:
681
+ * timeouts and network errors are never retried on writes, and exit 6
682
+ * says so instead of inviting a duplicate.
683
+ */
684
+ async function postCreate(profile, payload) {
685
+ try {
686
+ return await apiPostRaw(profile.instanceUrl, profile.apiKey, "/api/v3/work_packages", payload);
687
+ }
688
+ catch (error) {
689
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
690
+ throw new OpCliError("NETWORK_ERROR", "the create request did not complete; whether the work package was created is unknown.", "check whether the work package exists before repeating the command.");
691
+ }
692
+ throw error;
693
+ }
694
+ }
695
+ /**
696
+ * An update request whose failure to complete leaves the state unknown:
697
+ * like create, timeouts and network errors are never retried on writes.
698
+ */
699
+ async function patchWp(profile, id, payload) {
700
+ try {
701
+ return await apiPatchRaw(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${id}`, payload);
702
+ }
703
+ catch (error) {
704
+ if (error instanceof OpCliError && error.code === "NETWORK_ERROR") {
705
+ throw new OpCliError("NETWORK_ERROR", "the update request did not complete; whether the change was applied is unknown.", "run op-cli wp get to see the stored values before repeating the command.");
706
+ }
707
+ throw error;
708
+ }
709
+ }
710
+ /** The lockVersion a stored record carries; without it there is no safe update. */
711
+ function lockVersionOf(record) {
712
+ const value = typeof record === "object" && record !== null
713
+ ? record.lockVersion
714
+ : undefined;
715
+ if (typeof value !== "number") {
716
+ throw new OpCliError("API_ERROR", "the work package response carried no lockVersion.", "check the instance version with op-cli doctor.");
717
+ }
718
+ return value;
719
+ }
720
+ /** JSON-level equality: scalars, arrays, and link hrefs compare by value. */
721
+ function sameJson(left, right) {
722
+ return JSON.stringify(left) === JSON.stringify(right);
723
+ }
724
+ /**
725
+ * Fields this update writes that somebody else touched between the two
726
+ * reads. Only fields in the patch matter: a colleague's edit elsewhere
727
+ * is exactly the race the single retry exists to absorb, so it never
728
+ * blocks. Link attributes compare by href, scalar and custom-field keys
729
+ * by JSON value. A multi-valued link attribute holds one link per value,
730
+ * so its hrefs compare element-wise.
731
+ */
732
+ function conflictingFields(before, after, payload) {
733
+ const beforeRecord = before;
734
+ const afterRecord = after;
735
+ const conflicts = [];
736
+ for (const key of Object.keys(payload)) {
737
+ if (key === "_links" || key === "lockVersion") {
738
+ continue;
739
+ }
740
+ if (!sameJson(beforeRecord[key], afterRecord[key])) {
741
+ conflicts.push(key);
742
+ }
743
+ }
744
+ const payloadLinks = payload._links;
745
+ if (payloadLinks !== undefined) {
746
+ for (const key of Object.keys(payloadLinks)) {
747
+ if (key === "project") {
748
+ continue;
749
+ }
750
+ const beforeLinks = beforeRecord._links;
751
+ const afterLinks = afterRecord._links;
752
+ const hrefs = (link) => Array.isArray(link)
753
+ ? link.map(hrefs)
754
+ : typeof link === "object" && link !== null
755
+ ? link.href
756
+ : link;
757
+ if (!sameJson(hrefs(beforeLinks?.[key]), hrefs(afterLinks?.[key]))) {
758
+ conflicts.push(key);
759
+ }
760
+ }
761
+ }
762
+ return conflicts;
763
+ }
764
+ function conflictError(fields) {
765
+ return new OpCliError("CONFLICT", `the work package was modified while this update ran: ${fields.join(", ")}.`, "read the current values, merge them with your change, and repeat the command.", { conflicting_fields: [...fields] });
766
+ }
767
+ /**
768
+ * The ADR-0002 resolution retry: after a rejected write, retry once only
769
+ * when all three conditions hold — the status is 404 or 422, OpenProject's
770
+ * body points at an attribute we built from a resolved name, and
771
+ * refreshing metadata really changed that id. Otherwise the original
772
+ * response stands untouched: a refresh that changes nothing means the
773
+ * retry would fail identically, so we keep the honest error and spend no
774
+ * second request.
775
+ */
776
+ async function retryWithFreshIds(runtime, profile, response, payload, refs, resend, memo) {
777
+ if (!RETRYABLE_WRITE_STATUSES.includes(response.status)) {
778
+ return response;
779
+ }
780
+ const pointed = pointedAttribute(response.body);
781
+ if (pointed === undefined) {
782
+ return response;
783
+ }
784
+ const suspects = refs.filter((ref) => ref.attribute === pointed);
785
+ if (suspects.length === 0) {
786
+ return response;
787
+ }
788
+ await refreshStoredMetadata(runtime.env, profile);
789
+ memo?.invalidate();
790
+ const links = payload._links;
791
+ let changedAny = false;
792
+ for (const suspect of suspects) {
793
+ // After the refresh the cache holds the fresh snapshot; a value that
794
+ // no longer resolves uniquely cannot prove its id moved, so it keeps
795
+ // the original error too.
796
+ const match = matchByName(suspect.raw, await suspect.source.load());
797
+ if (match.kind !== "unique") {
798
+ continue;
799
+ }
800
+ const refreshed = Number(match.entry.value);
801
+ if (refreshed === suspect.idBefore) {
802
+ continue;
803
+ }
804
+ changedAny = true;
805
+ const patched = { href: `${suspect.hrefBase}${String(refreshed)}` };
806
+ if (!suspect.link) {
807
+ payload[suspect.attribute] = patched;
808
+ continue;
809
+ }
810
+ // A multi-valued attribute keeps its other values: only the element
811
+ // built from this very name moves.
812
+ const current = links[suspect.attribute];
813
+ links[suspect.attribute] = Array.isArray(current)
814
+ ? current.map((entry) => entry.href === `${suspect.hrefBase}${String(suspect.idBefore)}`
815
+ ? patched
816
+ : entry)
817
+ : patched;
818
+ }
819
+ if (!changedAny) {
820
+ return response;
821
+ }
822
+ return resend();
823
+ }
824
+ async function searchParentByName(profile, raw) {
825
+ const query = encodeURIComponent(JSON.stringify([{ subject: { operator: "=", values: [raw] } }]));
826
+ const collection = (await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages?filters=${query}&pageSize=100`));
827
+ const hits = [];
828
+ for (const element of collection._embedded?.elements ?? []) {
829
+ const record = element;
830
+ if (typeof record.id === "number" && typeof record.subject === "string") {
831
+ hits.push({ id: record.id, subject: record.subject });
832
+ }
833
+ }
834
+ if (hits.length === 0) {
835
+ throw new OpCliError("USAGE_ERROR", `no work package has the subject "${raw}".`, "--parent accepts a work package id or an exact subject.");
836
+ }
837
+ const distinct = [...new Map(hits.map((hit) => [hit.id, hit])).values()];
838
+ if (distinct.length > 1) {
839
+ const listed = distinct.map((hit) => `${hit.id} (${hit.subject})`).join(", ");
840
+ throw new OpCliError("USAGE_ERROR", `parent "${raw}" is ambiguous. Candidates: ${listed}.`, "repeat the value as the explicit id of one candidate.");
841
+ }
842
+ const only = distinct[0];
843
+ if (only === undefined) {
844
+ throw new OpCliError("INTERNAL_ERROR");
845
+ }
846
+ return only.id;
847
+ }
848
+ async function resolveParents(profile, raws) {
849
+ const ids = [];
850
+ for (const raw of raws) {
851
+ if (ids.includes(raw)) {
852
+ continue;
853
+ }
854
+ ids.push(isIdForm(raw) ? raw : String(await searchParentByName(profile, raw)));
855
+ }
856
+ return ids;
857
+ }
858
+ async function resolveListFlags(runtime, profile, options) {
859
+ const flags = {};
860
+ // The project in context scopes the listing itself, not just the
861
+ // vocabularies resolved below; without it every listing is
862
+ // instance-wide (#19).
863
+ if (profile.project !== undefined) {
864
+ flags.project = String(profile.project);
865
+ }
866
+ const status = options.status ?? [];
867
+ if (status.length > 0) {
868
+ flags.statuses = await resolveValues(status, instanceSource(runtime, profile, "status", (metadata) => metadata.statuses.map((entry) => ({
869
+ name: entry.name,
870
+ owner: "Status",
871
+ value: entry.id,
872
+ }))));
873
+ }
874
+ const type = options.type ?? [];
875
+ if (type.length > 0) {
876
+ flags.types = await resolveValues(type, instanceSource(runtime, profile, "type", (metadata) => metadata.types.map((entry) => ({
877
+ name: entry.name,
878
+ owner: "Type",
879
+ value: entry.id,
880
+ }))));
881
+ }
882
+ const assignee = options.assignee ?? [];
883
+ if (assignee.length > 0) {
884
+ flags.assignees = await resolveUserValues(runtime, profile, "assignee", assignee);
885
+ }
886
+ const author = options.author ?? [];
887
+ if (author.length > 0) {
888
+ flags.authors = await resolveUserValues(runtime, profile, "author", author);
889
+ }
890
+ const version = options.version ?? [];
891
+ if (version.length > 0) {
892
+ flags.versions = await resolveValues(version, projectSource(runtime, profile, "version", "version", (vocabulary) => vocabulary.versions.map((entry) => ({
893
+ name: entry.name,
894
+ owner: "Version",
895
+ value: entry.id,
896
+ }))));
897
+ }
898
+ const category = options.category ?? [];
899
+ if (category.length > 0) {
900
+ flags.categories = await resolveValues(category, projectSource(runtime, profile, "category", "category", (vocabulary) => vocabulary.categories.map((entry) => ({
901
+ name: entry.name,
902
+ owner: "Category",
903
+ value: entry.id,
904
+ }))));
905
+ }
906
+ const priority = options.priority ?? [];
907
+ if (priority.length > 0) {
908
+ flags.priorities = await resolveValues(priority, instanceSource(runtime, profile, "priority", (metadata) => metadata.priorities.map((entry) => ({
909
+ name: entry.name,
910
+ owner: "Priority",
911
+ value: entry.id,
912
+ }))));
913
+ }
914
+ const parent = options.parent ?? [];
915
+ if (parent.length > 0) {
916
+ flags.parents = await resolveParents(profile, parent);
917
+ }
918
+ if (options.updatedAfter !== undefined && options.updatedAfter !== "") {
919
+ flags.updatedAfter = options.updatedAfter;
920
+ }
921
+ return { ...flags, open: options.open, closed: options.closed };
922
+ }
923
+ function workPackagesPath(filters) {
924
+ return `/api/v3/work_packages?filters=${filtersQuery(filters)}`;
925
+ }
926
+ function renderWorkPackages(records) {
927
+ return renderTable(LIST_COLUMNS.map((column) => column.title), records.map((record) => LIST_COLUMNS.map((column) => formatCell(record[column.field]))));
928
+ }
929
+ /**
930
+ * Everything create and update share: every value flag resolved by name
931
+ * into payload entries, with the name-resolved ones remembered so a
932
+ * rejection can be retried with fresh ids (the ADR-0002 refs).
933
+ */
934
+ async function resolveNamedValues(runtime, profile, options, memo) {
935
+ const refs = [];
936
+ const links = {};
937
+ await resolveLinkOption(links, "type", "/api/v3/types/", "--type", options.type, instanceSource(runtime, profile, "type", (metadata) => metadata.types.map((entry) => ({
938
+ name: entry.name,
939
+ owner: "Type",
940
+ value: entry.id,
941
+ })), memo), refs);
942
+ await resolveLinkOption(links, "status", "/api/v3/statuses/", "--status", options.status, instanceSource(runtime, profile, "status", (metadata) => metadata.statuses.map((entry) => ({
943
+ name: entry.name,
944
+ owner: "Status",
945
+ value: entry.id,
946
+ })), memo), refs);
947
+ await resolveLinkOption(links, "priority", "/api/v3/priorities/", "--priority", options.priority, instanceSource(runtime, profile, "priority", (metadata) => metadata.priorities.map((entry) => ({
948
+ name: entry.name,
949
+ owner: "Priority",
950
+ value: entry.id,
951
+ })), memo), refs);
952
+ await resolveUserOption(runtime, profile, links, "assignee", "/api/v3/users/", "assignee", options.assignee, refs, memo);
953
+ await resolveLinkOption(links, "version", "/api/v3/versions/", "--version", options.version, projectSource(runtime, profile, "version", "version", (vocabulary) => vocabulary.versions.map((entry) => ({
954
+ name: entry.name,
955
+ owner: "Version",
956
+ value: entry.id,
957
+ })), memo), refs);
958
+ await resolveLinkOption(links, "category", "/api/v3/categories/", "--category", options.category, projectSource(runtime, profile, "category", "category", (vocabulary) => vocabulary.categories.map((entry) => ({
959
+ name: entry.name,
960
+ owner: "Category",
961
+ value: entry.id,
962
+ })), memo), refs);
963
+ // The parent is a live lookup over work packages, not cached metadata,
964
+ // so it never joins the ADR-0002 refs: an id given here is already the
965
+ // caller's own, and a subject search re-runs against current state.
966
+ if (options.parent !== undefined) {
967
+ const parentId = isIdForm(options.parent)
968
+ ? options.parent
969
+ : String(await searchParentByName(profile, options.parent));
970
+ links.parent = { href: `/api/v3/work_packages/${parentId}` };
971
+ }
972
+ const payload = { _links: links };
973
+ // Description is a Formattable on the wire: OpenProject drops a plain
974
+ // string silently, so it always travels as its raw property (#22).
975
+ if (options.description !== undefined) {
976
+ payload.description = { raw: options.description };
977
+ }
978
+ const groups = new Map();
979
+ for (const pair of (options.field ?? []).map(splitFieldPair)) {
980
+ const field = await resolveFieldDefinition(runtime, profile, pair.name, memo);
981
+ const group = groups.get(field.key);
982
+ if (group === undefined) {
983
+ groups.set(field.key, { field, pairs: [pair] });
984
+ }
985
+ else {
986
+ group.pairs.push(pair);
987
+ }
988
+ }
989
+ for (const { field, pairs } of groups.values()) {
990
+ const cleared = pairs.some((pair) => pair.value === "");
991
+ if (cleared && pairs.length > 1) {
992
+ throw new OpCliError("USAGE_ERROR", `--field mixes cleared and set values for "${field.name}".`, 'either clear with "Name=" or give every occurrence a value.');
993
+ }
994
+ if (cleared) {
995
+ if (field.is_list === true) {
996
+ // Nothing to resolve, but the empty value still belongs where the
997
+ // option would have gone.
998
+ await listFieldValues(runtime, profile, field, [], links, refs, memo);
999
+ }
1000
+ else {
1001
+ payload[field.key] = null;
1002
+ }
1003
+ continue;
1004
+ }
1005
+ if (field.is_list === true) {
1006
+ await listFieldValues(runtime, profile, field, pairs.map((pair) => pair.value), links, refs, memo);
1007
+ continue;
1008
+ }
1009
+ if (field.is_boolean === true) {
1010
+ if (pairs.length > 1) {
1011
+ throw new OpCliError("USAGE_ERROR", `--field "${field.name}" takes a single true or false.`, "boolean fields hold one value.");
1012
+ }
1013
+ payload[field.key] = booleanFieldValue(field, pairs[0]?.value ?? "");
1014
+ continue;
1015
+ }
1016
+ if (field.is_user === true) {
1017
+ if (pairs.length > 1) {
1018
+ throw new OpCliError("USAGE_ERROR", `--field "${field.name}" holds one user.`, "user fields take a single name, id, or me.");
1019
+ }
1020
+ await userFieldValue(runtime, profile, field, pairs[0]?.value ?? "", payload, refs, memo);
1021
+ continue;
1022
+ }
1023
+ const values = pairs.map((pair) => pair.value);
1024
+ payload[field.key] = values.length === 1 ? values[0] : values;
1025
+ }
1026
+ return { payload, refs };
1027
+ }
1028
+ // ---------------------------------------------------------------------------
1029
+ // Activities, relations, schemas
1030
+ // The activity's text is called `comment` by the API, by these columns,
1031
+ // and by --fields on every command that renders one. It had two names
1032
+ // before, so --fields refused the name the table itself was using.
1033
+ export const COMMENT_COLUMNS = [
1034
+ { title: "ID", field: "id" },
1035
+ { title: "AUTHOR", field: "user" },
1036
+ { title: "COMMENT", field: "comment" },
1037
+ { title: "CREATED", field: "createdAt" },
1038
+ ];
1039
+ export const HISTORY_COLUMNS = [
1040
+ { title: "ID", field: "id" },
1041
+ { title: "KIND", field: "kind" },
1042
+ { title: "AUTHOR", field: "user" },
1043
+ { title: "COMMENT", field: "comment" },
1044
+ { title: "CREATED", field: "createdAt" },
1045
+ ];
1046
+ export const RELATION_COLUMNS = [
1047
+ { title: "ID", field: "id" },
1048
+ { title: "TYPE", field: "type" },
1049
+ { title: "FROM", field: "from" },
1050
+ { title: "TO", field: "to" },
1051
+ { title: "LAG", field: "lag" },
1052
+ { title: "DESCRIPTION", field: "description" },
1053
+ ];
1054
+ export const SCHEMA_COLUMNS = [
1055
+ { title: "FIELD", field: "field" },
1056
+ { title: "NAME", field: "name" },
1057
+ { title: "TYPE", field: "type" },
1058
+ { title: "REQUIRED", field: "required" },
1059
+ { title: "WRITABLE", field: "writable" },
1060
+ ];
1061
+ // Relations are named by type ("follows"), never by id.
1062
+ const DEFAULT_RELATION_TYPE = "relates";
1063
+ // The API speaks relation type names ("follows"), never ids.
1064
+ const RELATION_TYPES = [
1065
+ "relates",
1066
+ "duplicates",
1067
+ "duplicated",
1068
+ "blocks",
1069
+ "blocked",
1070
+ "precedes",
1071
+ "follows",
1072
+ "includes",
1073
+ "partof",
1074
+ "requires",
1075
+ "required",
1076
+ ];
1077
+ function normalizeRelationType(raw) {
1078
+ const type = raw.trim().toLowerCase();
1079
+ if (!RELATION_TYPES.includes(type)) {
1080
+ throw new OpCliError("USAGE_ERROR", `relation type "${raw}" is not valid.`, `valid types: ${RELATION_TYPES.join(", ")}.`);
1081
+ }
1082
+ return type;
1083
+ }
1084
+ /** The relations endpoint filters by involvement, not by work package path. */
1085
+ function involvedRelationsPath(id) {
1086
+ return `/api/v3/relations?filters=${encodeURIComponent(JSON.stringify([
1087
+ { involved: { operator: "=", values: [Number(id)] } },
1088
+ ]))}`;
1089
+ }
1090
+ function linkIdOf(link) {
1091
+ return isFlatLink(link) ? link.id : null;
1092
+ }
1093
+ /**
1094
+ * One flat row per activity; the raw _type becomes the KIND column and
1095
+ * survives flattening because flattenHalRecord drops _type from records.
1096
+ * The formattable `comment` collapses to its raw text so the one name
1097
+ * means one thing wherever an activity is rendered.
1098
+ */
1099
+ export function activityRow(element) {
1100
+ const resource = element;
1101
+ const kind = typeof resource._type === "string"
1102
+ ? resource._type.replace(/^Activity::/, "")
1103
+ : "";
1104
+ const record = flattenHalRecord(element);
1105
+ const comment = record.comment;
1106
+ return {
1107
+ id: record.id,
1108
+ kind,
1109
+ user: record.user,
1110
+ comment: typeof comment?.raw === "string" ? comment.raw : "",
1111
+ createdAt: record.createdAt,
1112
+ };
1113
+ }
1114
+ /**
1115
+ * The key set of an activityRow output, so `wp comment` can refuse a bad
1116
+ * --fields before it writes. Taken FROM the row builder rather than
1117
+ * restated beside it: a declaration that drifts from the record it
1118
+ * describes is the bug this ticket is about.
1119
+ */
1120
+ const ACTIVITY_ROW_SHAPE = activityRow({});
1121
+ /**
1122
+ * One principal's name, or null when this instance will not give it. The
1123
+ * principals collection is the only one filterable by id, so a single
1124
+ * request answers for one author.
1125
+ *
1126
+ * A name is a nicety: an instance whose key cannot read principals must
1127
+ * still be able to list comments, so a refusal degrades to null. Network
1128
+ * and server failures are NOT swallowed; turning those into a bare id
1129
+ * would hide a broken instance behind a cosmetic fallback.
1130
+ */
1131
+ async function principalName(getPage, id) {
1132
+ const filters = encodeURIComponent(JSON.stringify([{ id: { operator: "=", values: [String(id)] } }]));
1133
+ try {
1134
+ const page = (await getPage(`/api/v3/principals?filters=${filters}`));
1135
+ const name = page._embedded?.elements?.[0]?.name;
1136
+ return typeof name === "string" ? name : null;
1137
+ }
1138
+ catch (error) {
1139
+ if (error instanceof OpCliError
1140
+ && (error.code === "AUTH_FAILED" || error.code === "NOT_FOUND")) {
1141
+ return null;
1142
+ }
1143
+ throw error;
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Fills in the author name an activities collection leaves out: a real
1148
+ * instance links the user by href with no title, and this CLI exists to
1149
+ * show names rather than the ids the API speaks. Each id is looked up at
1150
+ * most once per run, and a row that already carries a name costs nothing.
1151
+ */
1152
+ function authorResolver(getPage) {
1153
+ const named = new Map();
1154
+ return async (row) => {
1155
+ const user = row.user;
1156
+ if (!isFlatLink(user) || user.id === null || user.name !== null) {
1157
+ return row;
1158
+ }
1159
+ const id = user.id;
1160
+ if (!named.has(id)) {
1161
+ named.set(id, await principalName(getPage, id));
1162
+ }
1163
+ const name = named.get(id) ?? null;
1164
+ return name === null ? row : { ...row, user: { id, name } };
1165
+ };
1166
+ }
1167
+ /** Comments are the Comment-kind slice of the same activity stream. */
1168
+ export function commentRow(element) {
1169
+ const row = activityRow(element);
1170
+ return row.kind === "Comment" ? row : undefined;
1171
+ }
1172
+ /** Only the typed fields and both ends; the action links carry no data. */
1173
+ export function relationRow(element) {
1174
+ const record = flattenHalRecord(element);
1175
+ return {
1176
+ id: record.id,
1177
+ type: record.type,
1178
+ reverseType: record.reverseType,
1179
+ lag: record.lag,
1180
+ description: record.description,
1181
+ from: record.from,
1182
+ to: record.to,
1183
+ };
1184
+ }
1185
+ /** One row per available field of the project-and-type schema. */
1186
+ export function schemaRows(schema) {
1187
+ return Object.entries(schema)
1188
+ .filter(([key, value]) => !key.startsWith("_")
1189
+ && typeof value === "object"
1190
+ && value !== null
1191
+ && !Array.isArray(value))
1192
+ .map(([field, definition]) => {
1193
+ const spec = definition;
1194
+ return {
1195
+ field,
1196
+ name: spec.name ?? "",
1197
+ type: spec.type ?? "",
1198
+ required: spec.required ?? false,
1199
+ writable: spec.writable ?? false,
1200
+ };
1201
+ });
1202
+ }
1203
+ /**
1204
+ * The exact key set of a relationRow output; `wp unrelate` validates
1205
+ * --fields against it before deleting anything.
1206
+ */
1207
+ const RELATION_ROW_SHAPE = {
1208
+ id: 0,
1209
+ type: "",
1210
+ reverseType: "",
1211
+ lag: null,
1212
+ description: null,
1213
+ from: { id: 0, name: "" },
1214
+ to: { id: 0, name: "" },
1215
+ };
1216
+ export function registerWpCommands(wp, runtime) {
1217
+ wp.description("Inspect and manage work packages");
1218
+ addFilterFlags(wp.command("list")
1219
+ .description("List work packages by name-resolved filters")
1220
+ .option("--json", PAGED_JSON_HELP)
1221
+ .option("--limit <n>", "maximum number of results to show")
1222
+ .option("--all", "fetch every page instead of one limited page")).action(async (options) => {
1223
+ runtime.setJsonMode(options.json === true);
1224
+ // Refuse impossible flag combinations before any traffic or resolution.
1225
+ buildWpFilters(rawListFlags(options), new Date());
1226
+ const profile = await runtime.resolve({
1227
+ profile: options.profile,
1228
+ project: parseOptionalId(options.project),
1229
+ });
1230
+ const now = new Date();
1231
+ const limit = parsePageSize(options.limit);
1232
+ const filters = buildWpFilters(await resolveListFlags(runtime, profile, options), now);
1233
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
1234
+ // --all sizes pages by the instance's advertised maximum; --limit
1235
+ // only ever sizes the single non-all page.
1236
+ const startPath = withPageSize(workPackagesPath(filters), options.all === true ? await allPageSize(getPage) : limit);
1237
+ if (options.all === true) {
1238
+ const rows = [];
1239
+ for await (const element of halElements(getPage, startPath)) {
1240
+ const record = flattenHalRecord(element);
1241
+ if (options.json === true) {
1242
+ runtime.write(`${JSON.stringify(record)}\n`);
1243
+ }
1244
+ else {
1245
+ rows.push(record);
1246
+ }
1247
+ }
1248
+ if (options.json !== true) {
1249
+ runtime.write(renderWorkPackages(rows));
1250
+ }
1251
+ return;
1252
+ }
1253
+ const page = (await getPage(startPath));
1254
+ const records = (page._embedded?.elements ?? []).map((element) => flattenHalRecord(element));
1255
+ if (options.json === true) {
1256
+ runtime.write(`${JSON.stringify(records)}\n`);
1257
+ }
1258
+ else {
1259
+ runtime.write(renderWorkPackages(records));
1260
+ }
1261
+ const total = typeof page.total === "number" ? page.total : records.length;
1262
+ if (total > records.length) {
1263
+ runtime.writeErr(`Showing ${records.length} of ${total} work packages. `
1264
+ + "Pass --all to fetch every result.\n");
1265
+ }
1266
+ });
1267
+ addFilterFlags(wp.command("count").description("Count work packages matching the filters")).action(async (options) => {
1268
+ buildWpFilters(rawListFlags(options), new Date());
1269
+ const profile = await runtime.resolve({
1270
+ profile: options.profile,
1271
+ project: parseOptionalId(options.project),
1272
+ });
1273
+ const filters = buildWpFilters(await resolveListFlags(runtime, profile, options), new Date());
1274
+ const body = (await apiGet(profile.instanceUrl, profile.apiKey, `${workPackagesPath(filters)}&pageSize=1`));
1275
+ if (typeof body.total !== "number") {
1276
+ throw new OpCliError("API_ERROR", "the response carried no total.", "check the instance version with op-cli doctor.");
1277
+ }
1278
+ runtime.write(`${String(body.total)}\n`);
1279
+ });
1280
+ wp.command("get")
1281
+ .description("Show one work package")
1282
+ .argument("<id>")
1283
+ .option("--json", "emit a flat JSON record")
1284
+ .option("--fields <list>", "comma-separated columns to show")
1285
+ .option("--profile <name>", "use this profile for this command only")
1286
+ .option("--project <id>", "override the profile default project")
1287
+ .action(async (reference, options) => {
1288
+ runtime.setJsonMode(options.json === true);
1289
+ requireWpId(reference);
1290
+ const profile = await runtime.resolve({
1291
+ profile: options.profile,
1292
+ project: parseOptionalId(options.project),
1293
+ });
1294
+ const record = flattenHalRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`));
1295
+ renderRecord(runtime, options, record);
1296
+ });
1297
+ // The clearing convention is a promise about syntax, so it belongs in
1298
+ // this help text and not only in the skill.
1299
+ wp.command("create")
1300
+ .description("Create one work package, or many from a JSON array on stdin")
1301
+ .argument("[subject]", "subject of the one work package to create")
1302
+ .option("--stdin", "read a JSON array of work packages and report one NDJSON line per item")
1303
+ .option("--fail-fast", "with --stdin: stop the batch at the first failing item")
1304
+ .option("--dry-run", "with --stdin: resolve and validate everything, create nothing")
1305
+ .option("--type <name-or-id>", "work package type, by name or id")
1306
+ .option("--status <name-or-id>", "initial status, by name or id")
1307
+ .option("--priority <name-or-id>", "priority, by name or id")
1308
+ .option("--assignee <name-or-id>", "assignee, by name, id, or me")
1309
+ .option("--version <name-or-id>", "version of the project, by name or id")
1310
+ .option("--category <name-or-id>", "category of the project, by name or id")
1311
+ .option("--parent <id-or-subject>", "parent work package, by id or exact subject")
1312
+ .option("--description <text>", "markdown description of the work package")
1313
+ .option("--field <pair>", 'set a custom field by human name as "Name=Value"; repeat the flag '
1314
+ + 'for several values; "Name=" clears the field', collectValue, [])
1315
+ .option("--json", "emit a flat JSON record")
1316
+ .option("--fields <list>", "comma-separated columns to show")
1317
+ .option("--profile <name>", "use this profile for this command only")
1318
+ .option("--project <id>", "override the profile default project")
1319
+ .action(async (subject, options) => {
1320
+ if (options.stdin === true) {
1321
+ await runBulkCreate(runtime, subject, options);
1322
+ return;
1323
+ }
1324
+ runtime.setJsonMode(options.json === true);
1325
+ // --dry-run and --fail-fast are declared on the shared command, so
1326
+ // without this guard a single-subject create would silently ignore
1327
+ // them and still write for real.
1328
+ if (options.dryRun === true || options.failFast === true) {
1329
+ throw new OpCliError("USAGE_ERROR", "--dry-run and --fail-fast belong to wp create --stdin.", "pipe a JSON array with --stdin, or drop both flags to create one.");
1330
+ }
1331
+ if (subject === undefined) {
1332
+ throw new OpCliError("USAGE_ERROR", "wp create needs a subject or --stdin with an array of work packages.", "give <subject> to create one, or pipe a JSON array with --stdin.");
1333
+ }
1334
+ const profile = await runtime.resolve({
1335
+ profile: options.profile,
1336
+ project: parseOptionalId(options.project),
1337
+ });
1338
+ if (profile.project === undefined) {
1339
+ throw new OpCliError("USAGE_ERROR", "wp create needs a project to create the work package in.", "pass --project <id> or set a default project on the profile.");
1340
+ }
1341
+ const { payload, refs } = await prepareCreatePayload(runtime, profile, subject, options);
1342
+ const body = await submitCreate(runtime, profile, payload, refs);
1343
+ const created = flattenHalRecord(body);
1344
+ renderRecord(runtime, options, created, `work package ${String(created.id)} was created`);
1345
+ });
1346
+ wp.command("update")
1347
+ .description("Update one work package with every value given by name")
1348
+ .argument("<id>")
1349
+ .option("--subject <text>", "new subject")
1350
+ .option("--type <name-or-id>", "work package type, by name or id")
1351
+ .option("--status <name-or-id>", "status, by name or id")
1352
+ .option("--priority <name-or-id>", "priority, by name or id")
1353
+ .option("--assignee <name-or-id>", "assignee, by name, id, or me")
1354
+ .option("--version <name-or-id>", "version of the project, by name or id")
1355
+ .option("--category <name-or-id>", "category of the project, by name or id")
1356
+ .option("--parent <id-or-subject>", "re-parent under this work package, by id or exact subject")
1357
+ .option("--description <text>", "new markdown description of the work package")
1358
+ .option("--field <pair>", 'set a custom field by human name as "Name=Value"; repeat the flag '
1359
+ + 'for several values; "Name=" clears the field', collectValue, [])
1360
+ .option("--json", "emit a flat JSON record")
1361
+ .option("--fields <list>", "comma-separated columns to show")
1362
+ .option("--profile <name>", "use this profile for this command only")
1363
+ .option("--project <id>", "override the profile default project")
1364
+ .action(async (reference, options) => {
1365
+ runtime.setJsonMode(options.json === true);
1366
+ requireWpId(reference);
1367
+ const touchesSomething = options.subject !== undefined
1368
+ || options.type !== undefined
1369
+ || options.status !== undefined
1370
+ || options.priority !== undefined
1371
+ || options.parent !== undefined
1372
+ || options.assignee !== undefined
1373
+ || options.version !== undefined
1374
+ || options.category !== undefined
1375
+ || options.description !== undefined
1376
+ || (options.field?.length ?? 0) > 0;
1377
+ if (!touchesSomething) {
1378
+ throw new OpCliError("USAGE_ERROR", "wp update needs at least one value to change.", "pass --subject, an attribute flag like --status, or --field Name=Value.");
1379
+ }
1380
+ const profile = await runtime.resolve({
1381
+ profile: options.profile,
1382
+ project: parseOptionalId(options.project),
1383
+ });
1384
+ const path = `/api/v3/work_packages/${reference}`;
1385
+ // The optimistic-locking read: everything below compares against
1386
+ // this snapshot.
1387
+ const before = await apiGet(profile.instanceUrl, profile.apiKey, path);
1388
+ // The read already names every column, so a bad --fields name is
1389
+ // refused while the work package is still untouched.
1390
+ selectedFields(options.fields, flattenHalRecord(before));
1391
+ const { payload, refs } = await resolveNamedValues(runtime, profile, options);
1392
+ if (options.subject !== undefined) {
1393
+ payload.subject = options.subject;
1394
+ }
1395
+ payload.lockVersion = lockVersionOf(before);
1396
+ let response = await patchWp(profile, reference, payload);
1397
+ if (response.status >= 400) {
1398
+ if (response.status === 409) {
1399
+ // The second retry rule: re-read and compare against the
1400
+ // original read. Fields nobody touched mean the conflict was
1401
+ // a race; one retry with the fresh lockVersion is safe.
1402
+ // A field somebody touched means a blind retry would discard
1403
+ // their work, so stop and name it.
1404
+ const after = await apiGet(profile.instanceUrl, profile.apiKey, path);
1405
+ const conflicts = conflictingFields(before, after, payload);
1406
+ if (conflicts.length > 0) {
1407
+ throw conflictError(conflicts);
1408
+ }
1409
+ payload.lockVersion = lockVersionOf(after);
1410
+ response = await patchWp(profile, reference, payload);
1411
+ }
1412
+ else {
1413
+ response = await retryWithFreshIds(runtime, profile, response, payload, refs, () => patchWp(profile, reference, payload));
1414
+ }
1415
+ }
1416
+ if (response.status >= 400) {
1417
+ throw writeRejection(response.status, response.body, "update");
1418
+ }
1419
+ const updated = flattenHalRecord(response.body);
1420
+ renderRecord(runtime, options, updated);
1421
+ });
1422
+ // ---------------------------------------------------------------------------
1423
+ // wp delete
1424
+ wp.command("delete")
1425
+ .description("Delete one work package after explicit confirmation")
1426
+ .argument("<id>")
1427
+ .option("--yes", "confirm the deletion")
1428
+ .option("--profile <name>", "use this profile for this command only")
1429
+ .option("--project <id>", "override the profile default project")
1430
+ .action(async (reference, options) => {
1431
+ // The guard fires before any resolution or traffic: without --yes
1432
+ // nothing is read, nothing is sent, with or without a terminal.
1433
+ if (options.yes !== true) {
1434
+ throw new OpCliError("USAGE_ERROR", `wp delete refuses to remove work package ${reference} without confirmation.`, "repeat the command with --yes to confirm the deletion.");
1435
+ }
1436
+ requireWpId(reference);
1437
+ const profile = await runtime.resolve({
1438
+ profile: options.profile,
1439
+ project: parseOptionalId(options.project),
1440
+ });
1441
+ await apiDelete(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`);
1442
+ runtime.write(`Deleted work package ${reference}.\n`);
1443
+ });
1444
+ // ---------------------------------------------------------------------------
1445
+ // Discussion, history, relations, schema
1446
+ const collectionRuntime = {
1447
+ connect: async (options) => {
1448
+ const profile = await runtime.resolve({
1449
+ profile: typeof options.profile === "string" ? options.profile : undefined,
1450
+ project: typeof options.project === "string"
1451
+ ? parseOptionalId(options.project)
1452
+ : undefined,
1453
+ });
1454
+ return (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
1455
+ },
1456
+ write: runtime.write,
1457
+ writeErr: runtime.writeErr,
1458
+ setJsonMode: runtime.setJsonMode,
1459
+ };
1460
+ wp.addCommand(defineCollectionCommand({
1461
+ name: "comments",
1462
+ description: "List comments of one work package",
1463
+ path: (id) => `/api/v3/work_packages/${id}/activities`,
1464
+ row: commentRow,
1465
+ columns: COMMENT_COLUMNS,
1466
+ resolve: authorResolver,
1467
+ }, collectionRuntime));
1468
+ wp.addCommand(defineCollectionCommand({
1469
+ name: "history",
1470
+ description: "Show the activity history of one work package",
1471
+ path: (id) => `/api/v3/work_packages/${id}/activities`,
1472
+ row: activityRow,
1473
+ columns: HISTORY_COLUMNS,
1474
+ resolve: authorResolver,
1475
+ }, collectionRuntime));
1476
+ wp.addCommand(defineCollectionCommand({
1477
+ name: "relations",
1478
+ description: "List the relations of one work package",
1479
+ path: involvedRelationsPath,
1480
+ row: relationRow,
1481
+ columns: RELATION_COLUMNS,
1482
+ }, collectionRuntime));
1483
+ const recordOptions = {
1484
+ json: "--json",
1485
+ fields: "--fields <list>",
1486
+ profile: "--profile <name>",
1487
+ project: "--project <id>",
1488
+ };
1489
+ wp.command("comment")
1490
+ .description("Add a comment to one work package")
1491
+ .argument("<id>")
1492
+ .argument("<text>", "markdown body of the comment")
1493
+ .option(recordOptions.json, "emit a flat JSON record")
1494
+ .option(recordOptions.fields, "comma-separated columns to show")
1495
+ .option(recordOptions.profile, "use this profile for this command only")
1496
+ .option(recordOptions.project, "override the profile default project")
1497
+ .action(async (reference, text, options) => {
1498
+ requireWpId(reference);
1499
+ // Writing: --fields misuse is refused before the comment exists, so
1500
+ // a caller who fixes the flag and retries cannot post it twice.
1501
+ selectedFields(options.fields, ACTIVITY_ROW_SHAPE);
1502
+ const profile = await openProfile(runtime, options);
1503
+ const created = activityRow(await apiPost(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}/activities`, { comment: { raw: text } }));
1504
+ const named = await authorResolver((path) => apiGet(profile.instanceUrl, profile.apiKey, path))(created);
1505
+ renderRecord(runtime, options, named);
1506
+ });
1507
+ wp.command("relate")
1508
+ .description("Relate one work package to another")
1509
+ .argument("<id>")
1510
+ .argument("<to>", "work package id this one relates to")
1511
+ .option("--type <name>", `relation type: ${RELATION_TYPES.join(", ")}`, DEFAULT_RELATION_TYPE)
1512
+ .option(recordOptions.json, "emit a flat JSON record")
1513
+ .option(recordOptions.fields, "comma-separated columns to show")
1514
+ .option(recordOptions.profile, "use this profile for this command only")
1515
+ .option(recordOptions.project, "override the profile default project")
1516
+ .action(async (reference, toReference, options) => {
1517
+ requireWpId(reference);
1518
+ requireWpId(toReference);
1519
+ const type = normalizeRelationType(options.type ?? DEFAULT_RELATION_TYPE);
1520
+ const profile = await openProfile(runtime, options);
1521
+ const created = flattenHalRecord(await apiPost(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}/relations`, { type, _links: { to: { href: `/api/v3/work_packages/${toReference}` } } }));
1522
+ renderRecord(runtime, options, created);
1523
+ });
1524
+ wp.command("unrelate")
1525
+ .description("Remove every relation between two work packages")
1526
+ .argument("<id>")
1527
+ .argument("<other>", "work package id to unlink from")
1528
+ .option(recordOptions.json, "emit a flat JSON array")
1529
+ .option(recordOptions.fields, "comma-separated columns to show")
1530
+ .option(recordOptions.profile, "use this profile for this command only")
1531
+ .option(recordOptions.project, "override the profile default project")
1532
+ .action(async (reference, other, options) => {
1533
+ requireWpId(reference);
1534
+ requireWpId(other);
1535
+ // Destructive: --fields misuse is refused before any traffic, so a
1536
+ // typo can never delete relations and then exit 1.
1537
+ selectedFields(options.fields, RELATION_ROW_SHAPE);
1538
+ const profile = await openProfile(runtime, options);
1539
+ const pair = [Number(reference), Number(other)];
1540
+ const removed = [];
1541
+ const getPage = (path) => apiGet(profile.instanceUrl, profile.apiKey, path);
1542
+ for await (const element of halElements(getPage, withPageSize(involvedRelationsPath(reference), DEFAULT_PAGE_SIZE))) {
1543
+ const relation = relationRow(element);
1544
+ const from = linkIdOf(relation.from);
1545
+ const to = linkIdOf(relation.to);
1546
+ if ((from === pair[0] && to === pair[1]) || (from === pair[1] && to === pair[0])) {
1547
+ await apiDelete(profile.instanceUrl, profile.apiKey, `/api/v3/relations/${String(relation.id)}`);
1548
+ removed.push(relation);
1549
+ }
1550
+ }
1551
+ if (removed.length === 0) {
1552
+ throw new OpCliError("NOT_FOUND", `no relation links work package ${reference} with ${other}.`, "run wp relations <id> to see what is related.");
1553
+ }
1554
+ if (options.json === true) {
1555
+ const picked = removed.map((relation) => Object.fromEntries(selectedFields(options.fields, relation).map((field) => [
1556
+ field,
1557
+ relation[field],
1558
+ ])));
1559
+ runtime.write(`${JSON.stringify(picked)}\n`);
1560
+ return;
1561
+ }
1562
+ for (const relation of removed) {
1563
+ renderRecord(runtime, options, relation);
1564
+ }
1565
+ });
1566
+ wp.command("schema")
1567
+ .description("Show the available fields of a work package's project and type")
1568
+ .argument("<id>")
1569
+ .option(recordOptions.json, "emit a flat JSON array")
1570
+ .option(recordOptions.fields, "comma-separated columns to show")
1571
+ .option(recordOptions.profile, "use this profile for this command only")
1572
+ .option(recordOptions.project, "override the profile default project")
1573
+ .action(async (reference, options) => {
1574
+ requireWpId(reference);
1575
+ const profile = await openProfile(runtime, options);
1576
+ const workPackage = flattenHalRecord(await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/${reference}`));
1577
+ const projectId = linkIdOf(workPackage.project);
1578
+ const typeId = linkIdOf(workPackage.type);
1579
+ if (projectId === null || typeId === null) {
1580
+ throw new OpCliError("API_ERROR", "the work package carried no project or type to resolve its schema.", "check the work package with wp get.");
1581
+ }
1582
+ const schema = await apiGet(profile.instanceUrl, profile.apiKey, `/api/v3/work_packages/schemas/${String(projectId)}-${String(typeId)}`);
1583
+ emitRows({ write: runtime.write, writeErr: runtime.writeErr }, SCHEMA_COLUMNS, schemaRows(schema), options);
1584
+ });
1585
+ }
1586
+ //# sourceMappingURL=wp.js.map