@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.
- package/NOTICE +15 -0
- package/README.md +108 -0
- package/dist/bin.js +8 -0
- package/dist/commands/doctor.js +135 -0
- package/dist/commands/meta.js +283 -0
- package/dist/commands/project.js +645 -0
- package/dist/commands/time.js +555 -0
- package/dist/commands/version.js +47 -0
- package/dist/commands/wp.js +1586 -0
- package/dist/context/metadata.js +528 -0
- package/dist/context/profile.js +218 -0
- package/dist/context/resolve.js +96 -0
- package/dist/core/define.js +155 -0
- package/dist/core/duration.js +151 -0
- package/dist/core/errors.js +102 -0
- package/dist/core/filters.js +133 -0
- package/dist/core/hal.js +114 -0
- package/dist/core/http.js +119 -0
- package/dist/core/paginate.js +55 -0
- package/dist/core/usage.js +58 -0
- package/dist/io/node-io.js +89 -0
- package/dist/output/table.js +52 -0
- package/dist/run.js +300 -0
- package/package.json +53 -0
|
@@ -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
|