@bli-cockpit/cli 0.2.244 → 0.2.245
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/dist/commands/issue-contracts.js +65 -4
- package/dist/commands/issue-list-page.js +34 -6
- package/dist/commands/issue-page-args.js +64 -0
- package/dist/commands/issue-relations.js +3 -2
- package/dist/commands/issue-show.js +13 -2
- package/dist/commands/issue-versions.js +5 -8
- package/dist/commands/issue-write.js +71 -16
- package/dist/commands/issue.js +22 -3
- package/dist/commands/local-args-tower-milestone.js +120 -0
- package/dist/commands/local-args-tower-work.js +44 -51
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-command-shapes-milestone.js +1 -0
- package/dist/commands/local-help-commands-tower.js +6 -3
- package/dist/commands/local-help-milestone.js +23 -0
- package/dist/commands/local-help-project.js +1 -0
- package/dist/commands/local-help.js +2 -0
- package/dist/commands/local.js +2 -0
- package/dist/commands/milestone.js +157 -0
- package/dist/commands/project-args.js +78 -2
- package/dist/commands/project.js +52 -0
- package/dist/commands/public-root.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit milestone` argument parsing (BLI-22360).
|
|
3
|
+
*
|
|
4
|
+
* list [--project <ref>] [--archived]
|
|
5
|
+
* show <ref> [--project <ref>] [--limit <n>] [--cursor <c>]
|
|
6
|
+
* create --project <ref> --name "<n>" [--description <text|->] [--target YYYY-MM-DD]
|
|
7
|
+
* update <ref> [--project <ref>] [--name <n>] [--description <text|-|none>] [--target <day|none>] [--expected-updated-at <ts>]
|
|
8
|
+
* archive|restore <ref> [--project <ref>] [--expected-updated-at <ts>]
|
|
9
|
+
* add|remove <ref> <ticket> [<ticket> ...] [--project <ref>]
|
|
10
|
+
*
|
|
11
|
+
* A milestone ref is an id or an exact, case-insensitive name; two milestones with one name are refused by the door
|
|
12
|
+
* (`milestone_ref_ambiguous`) with their ids, never guessed. `--request-id` belongs to every write.
|
|
13
|
+
*/
|
|
14
|
+
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
|
|
15
|
+
const ACTIONS = ["list", "show", "create", "update", "archive", "restore", "add", "remove"];
|
|
16
|
+
const WRITES = ["create", "update", "archive", "restore", "add", "remove"];
|
|
17
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
18
|
+
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
19
|
+
export const MILESTONE_TICKETS_MAX = 50;
|
|
20
|
+
function target(value, action) {
|
|
21
|
+
if (value === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (value.trim().toLowerCase() === "none") {
|
|
24
|
+
if (action !== "update")
|
|
25
|
+
throw new Error("--target none clears a date on `milestone update`; leave --target off instead.");
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (!DAY.test(value) || new Date(`${value}T12:00:00Z`).toISOString().slice(0, 10) !== value) {
|
|
29
|
+
throw new Error("--target must be a calendar date as YYYY-MM-DD, or none.");
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
export function parseMilestoneArgs(args) {
|
|
34
|
+
const values = parseNamedArgs(args, {
|
|
35
|
+
allowedFlags: [
|
|
36
|
+
"--home", "--dashboard-url", "--json", "--archived", "--project", "--name", "--description", "--target",
|
|
37
|
+
"--limit", "--cursor", "--request-id", "--expected-updated-at",
|
|
38
|
+
],
|
|
39
|
+
valueFlags: [
|
|
40
|
+
"--home", "--dashboard-url", "--project", "--name", "--description", "--target",
|
|
41
|
+
"--limit", "--cursor", "--request-id", "--expected-updated-at",
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
const first = values.positionals[0];
|
|
45
|
+
const action = (first === undefined ? "list" : first);
|
|
46
|
+
if (!ACTIONS.includes(action)) {
|
|
47
|
+
throw new Error(`Unknown milestone command: ${first}. Try list, show, create, update, archive, restore, add or remove.`);
|
|
48
|
+
}
|
|
49
|
+
const rest = values.positionals.slice(first === undefined ? 0 : 1);
|
|
50
|
+
const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
|
|
51
|
+
let ref;
|
|
52
|
+
let tickets = [];
|
|
53
|
+
if (action === "list" || action === "create") {
|
|
54
|
+
if (rest.length > 0)
|
|
55
|
+
throw new Error(`milestone ${action} does not take "${rest[0]}".`);
|
|
56
|
+
}
|
|
57
|
+
else if (action === "add" || action === "remove") {
|
|
58
|
+
if (rest.length < 2)
|
|
59
|
+
throw new Error(`milestone ${action} takes a milestone and at least one ticket: milestone ${action} <milestone> BLI-123 [BLI-124 ...].`);
|
|
60
|
+
ref = rest[0];
|
|
61
|
+
tickets = [...new Set(rest.slice(1).flatMap((t) => t.split(",")).map((t) => t.trim()).filter(Boolean))];
|
|
62
|
+
if (tickets.length > MILESTONE_TICKETS_MAX)
|
|
63
|
+
throw new Error(`At most ${MILESTONE_TICKETS_MAX} tickets per call.`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
if (rest.length !== 1)
|
|
67
|
+
throw new Error(`milestone ${action} takes exactly one milestone id or name.`);
|
|
68
|
+
ref = rest[0];
|
|
69
|
+
}
|
|
70
|
+
if (action !== "create" && action !== "update" && (given("--name") || given("--description") || given("--target"))) {
|
|
71
|
+
throw new Error("--name, --description and --target belong to milestone create and update.");
|
|
72
|
+
}
|
|
73
|
+
if (action !== "list" && given("--archived"))
|
|
74
|
+
throw new Error("--archived belongs to milestone list.");
|
|
75
|
+
if (action !== "show" && (given("--limit") || given("--cursor")))
|
|
76
|
+
throw new Error("--limit and --cursor belong to milestone show.");
|
|
77
|
+
if (!WRITES.includes(action) && given("--request-id"))
|
|
78
|
+
throw new Error("--request-id belongs to the milestone writes.");
|
|
79
|
+
if (!["update", "archive", "restore"].includes(action) && given("--expected-updated-at")) {
|
|
80
|
+
throw new Error("--expected-updated-at belongs to milestone update, archive and restore.");
|
|
81
|
+
}
|
|
82
|
+
const project = optionalNonEmpty(values.flags.get("--project"));
|
|
83
|
+
const name = optionalNonEmpty(values.flags.get("--name"));
|
|
84
|
+
if (action === "create" && (!project || !name))
|
|
85
|
+
throw new Error('milestone create needs --project <project> and --name "<name>".');
|
|
86
|
+
const rawDescription = values.flags.get("--description");
|
|
87
|
+
const description = rawDescription === undefined ? undefined
|
|
88
|
+
: action === "update" && rawDescription.trim().toLowerCase() === "none" ? null : rawDescription;
|
|
89
|
+
const targetDate = target(values.flags.get("--target"), action);
|
|
90
|
+
if (action === "update" && name === undefined && description === undefined && targetDate === undefined) {
|
|
91
|
+
throw new Error("milestone update needs --name, --description (or none) or --target (or none). Archive with `milestone archive`.");
|
|
92
|
+
}
|
|
93
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
94
|
+
if (requestId && !UUID.test(requestId))
|
|
95
|
+
throw new Error("--request-id must be a UUID.");
|
|
96
|
+
const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
|
|
97
|
+
if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
98
|
+
throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
|
|
99
|
+
}
|
|
100
|
+
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
101
|
+
const cursor = optionalNonEmpty(values.flags.get("--cursor"));
|
|
102
|
+
return {
|
|
103
|
+
kind: "milestone",
|
|
104
|
+
action,
|
|
105
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
106
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
107
|
+
...(ref === undefined ? {} : { ref }),
|
|
108
|
+
...(project ? { project } : {}),
|
|
109
|
+
includeArchived: values.booleans.has("--archived"),
|
|
110
|
+
...(name ? { name } : {}),
|
|
111
|
+
...(description === undefined ? {} : { description }),
|
|
112
|
+
...(targetDate === undefined ? {} : { targetDate }),
|
|
113
|
+
tickets,
|
|
114
|
+
...(limit === undefined ? {} : { limit }),
|
|
115
|
+
...(cursor ? { cursor } : {}),
|
|
116
|
+
...(requestId ? { requestId } : {}),
|
|
117
|
+
...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
|
|
118
|
+
json: values.booleans.has("--json"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -23,6 +23,7 @@ import { NOTIFY_ACTIONS, NOTIFY_VALUE_FLAGS, isNotifyAction, parseNotifyArgs } f
|
|
|
23
23
|
import { VIEW_BOOLEAN_FLAGS, VIEW_VALUE_FLAGS, parseViewArgs } from "./view-args.js";
|
|
24
24
|
import { parseTemplateArgs } from "./template-args.js";
|
|
25
25
|
import { CYCLE_BOOLEAN_FLAGS, CYCLE_VALUE_FLAGS, parseCycleArgs, readCycleField } from "./cycle-args.js";
|
|
26
|
+
import { readArchivedFlag, readIssuePage } from "./issue-page-args.js";
|
|
26
27
|
const ISSUE_ACTIONS = new Set([
|
|
27
28
|
"mutation-status",
|
|
28
29
|
"board",
|
|
@@ -37,6 +38,9 @@ const ISSUE_ACTIONS = new Set([
|
|
|
37
38
|
"restore",
|
|
38
39
|
"label",
|
|
39
40
|
"batch",
|
|
41
|
+
// BLI-22367: reversible archive. `restore` stays the description-version verb.
|
|
42
|
+
"archive",
|
|
43
|
+
"unarchive",
|
|
40
44
|
"relations",
|
|
41
45
|
"relate",
|
|
42
46
|
"unrelate",
|
|
@@ -56,6 +60,8 @@ const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
|
|
|
56
60
|
"versions",
|
|
57
61
|
"restore",
|
|
58
62
|
"label",
|
|
63
|
+
"archive",
|
|
64
|
+
"unarchive",
|
|
59
65
|
"relations",
|
|
60
66
|
"relate",
|
|
61
67
|
"unrelate",
|
|
@@ -99,6 +105,7 @@ const ISSUE_ALLOWED_FLAGS = [
|
|
|
99
105
|
"--sort",
|
|
100
106
|
"--dir",
|
|
101
107
|
"--all",
|
|
108
|
+
"--archived",
|
|
102
109
|
"--body-stdin",
|
|
103
110
|
"--json",
|
|
104
111
|
...VIEW_BOOLEAN_FLAGS,
|
|
@@ -174,9 +181,10 @@ export function parseIssueArgs(args) {
|
|
|
174
181
|
throw new Error("--request-id must be a UUID.");
|
|
175
182
|
if (action === "mutation-status" && !UUID_PATTERN.test(issueRef ?? ""))
|
|
176
183
|
throw new Error("mutation-status needs a request UUID.");
|
|
177
|
-
if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore", "label", "batch", "relate", "unrelate"].includes(action)) {
|
|
178
|
-
throw new Error("Write-safety flags belong to create, update, move, comment, restore, label, relate, unrelate or batch.");
|
|
184
|
+
if ((requestId || expectedRevision !== undefined) && !["create", "update", "move", "comment", "restore", "label", "batch", "relate", "unrelate", "archive", "unarchive"].includes(action)) {
|
|
185
|
+
throw new Error("Write-safety flags belong to create, update, move, comment, restore, label, relate, unrelate, archive, unarchive or batch.");
|
|
179
186
|
}
|
|
187
|
+
const archived = readArchivedFlag(action, values);
|
|
180
188
|
validateIssueState(moveState, "issue move state must be one of: ");
|
|
181
189
|
const stateFilter = readIssueStateFilter(action, values);
|
|
182
190
|
const fields = readIssueFields(action, values);
|
|
@@ -192,61 +200,18 @@ export function parseIssueArgs(args) {
|
|
|
192
200
|
if (action === "board" && boardProject && !UUID_PATTERN.test(boardProject))
|
|
193
201
|
throw new Error("board --project requires a uuid.");
|
|
194
202
|
const priority = readIssuePriority(values);
|
|
195
|
-
const estimate = readIssueSize(values);
|
|
203
|
+
const estimate = readIssueSize(action, values);
|
|
204
|
+
checkNoneClears(action, values);
|
|
196
205
|
const assignee = readIssueAssignee(action, values);
|
|
197
206
|
const limit = readIssueLimit(action, values);
|
|
198
207
|
const versionNumber = readIssueVersion(action, values);
|
|
199
|
-
return { ...buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page, fields), ...readRelationFlags(action, values) };
|
|
200
|
-
}
|
|
201
|
-
const INSTANT = /^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d:\d\d))?$/;
|
|
202
|
-
/** BLI-21315: the cursor contract's flags. Filters and sort belong to `list`; `--cursor` also to `history`. */
|
|
203
|
-
function readIssuePage(action, values) {
|
|
204
|
-
const named = ["--q", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"];
|
|
205
|
-
for (const flag of named) {
|
|
206
|
-
if (values.flags.has(flag) && action !== "list")
|
|
207
|
-
throw new Error(`${flag} belongs to \`cockpit ticket list\`.`);
|
|
208
|
-
}
|
|
209
|
-
if (values.booleans.has("--all") && action !== "list")
|
|
210
|
-
throw new Error("--all belongs to `cockpit ticket list`.");
|
|
211
|
-
if (values.flags.has("--cursor") && action !== "list" && action !== "history") {
|
|
212
|
-
throw new Error("--cursor belongs to `cockpit ticket list` and `cockpit ticket history`.");
|
|
213
|
-
}
|
|
214
|
-
const instants = {};
|
|
215
|
-
for (const flag of ["--created-after", "--created-before", "--updated-after", "--updated-before"]) {
|
|
216
|
-
const value = optionalNonEmpty(values.flags.get(flag));
|
|
217
|
-
if (value !== undefined && (!INSTANT.test(value) || !Number.isFinite(Date.parse(value)))) {
|
|
218
|
-
throw new Error(`${flag} must be a UTC instant like 2026-10-01T00:00:00Z or a date like 2026-10-01.`);
|
|
219
|
-
}
|
|
220
|
-
instants[flag] = value;
|
|
221
|
-
}
|
|
222
|
-
const sort = optionalNonEmpty(values.flags.get("--sort"));
|
|
223
|
-
if (sort !== undefined && sort !== "updated_at" && sort !== "created_at")
|
|
224
|
-
throw new Error("--sort must be updated_at or created_at.");
|
|
225
|
-
const dir = optionalNonEmpty(values.flags.get("--dir"));
|
|
226
|
-
if (dir !== undefined && dir !== "asc" && dir !== "desc")
|
|
227
|
-
throw new Error("--dir must be asc or desc.");
|
|
228
|
-
const all = values.booleans.has("--all");
|
|
229
|
-
const cursor = optionalNonEmpty(values.flags.get("--cursor"));
|
|
230
|
-
if (all && cursor)
|
|
231
|
-
throw new Error("--all walks from the start; drop --cursor, or pass --cursor without --all to read one page.");
|
|
232
|
-
const q = optionalNonEmpty(values.flags.get("--q"));
|
|
233
|
-
return {
|
|
234
|
-
...(cursor ? { cursor } : {}),
|
|
235
|
-
...(q ? { q } : {}),
|
|
236
|
-
...(instants["--created-after"] ? { createdAfter: instants["--created-after"] } : {}),
|
|
237
|
-
...(instants["--created-before"] ? { createdBefore: instants["--created-before"] } : {}),
|
|
238
|
-
...(instants["--updated-after"] ? { updatedAfter: instants["--updated-after"] } : {}),
|
|
239
|
-
...(instants["--updated-before"] ? { updatedBefore: instants["--updated-before"] } : {}),
|
|
240
|
-
...(sort ? { sort } : {}),
|
|
241
|
-
...(dir ? { dir } : {}),
|
|
242
|
-
...(all ? { all } : {}),
|
|
243
|
-
};
|
|
208
|
+
return { ...buildIssueCommand(values, action, issueRef, issueRefs, moveState, stateFilter, title, priority, estimate, assignee, limit, versionNumber, page, fields), ...readRelationFlags(action, values), ...(archived ? { archived } : {}) };
|
|
244
209
|
}
|
|
245
210
|
function readIssueAction(values) {
|
|
246
211
|
const first = values.positionals[0];
|
|
247
212
|
const action = (first === undefined ? "list" : first);
|
|
248
213
|
if (!ISSUE_ACTIONS.has(action)) {
|
|
249
|
-
throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, label, relations, relate, unrelate, views, templates, cycles, inbox, read, unread, subscribe, unsubscribe, notifications, history, versions, restore, batch, or mutation-status.`);
|
|
214
|
+
throw new Error(`Unknown issue command: ${first}. Try board, list, show, create, update, move, comment, label, archive, unarchive, relations, relate, unrelate, views, templates, cycles, inbox, read, unread, subscribe, unsubscribe, notifications, history, versions, restore, batch, or mutation-status.`);
|
|
250
215
|
}
|
|
251
216
|
return { action, rest: values.positionals.slice(first === undefined ? 0 : 1) };
|
|
252
217
|
}
|
|
@@ -318,9 +283,37 @@ function readIssuePriority(values) {
|
|
|
318
283
|
}
|
|
319
284
|
return priority;
|
|
320
285
|
}
|
|
321
|
-
|
|
286
|
+
/**
|
|
287
|
+
* `--size S|M|L`, or `none` on update to clear the size (BLI-22367). Only ever what the person typed: no surface
|
|
288
|
+
* picks a size for them.
|
|
289
|
+
*/
|
|
290
|
+
function readIssueSize(action, values) {
|
|
322
291
|
const raw = optionalNonEmpty(values.flags.get("--size"));
|
|
323
|
-
|
|
292
|
+
if (raw === undefined)
|
|
293
|
+
return undefined;
|
|
294
|
+
if (raw.toLowerCase() === "none") {
|
|
295
|
+
if (action !== "update")
|
|
296
|
+
throw new Error("--size none clears a size on `cockpit ticket update`; leave --size off to create without one.");
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return estimateForSize(raw);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* ONE spelling clears a field on update: `none` (BLI-22367). `--parent none` and `--project none` join the existing
|
|
303
|
+
* `--assignee none`, `--due none`, `--cycle none` and `--size none`. On create there is nothing to clear, so `none`
|
|
304
|
+
* is refused rather than looked up as a ticket or project called "none".
|
|
305
|
+
*/
|
|
306
|
+
function checkNoneClears(action, values) {
|
|
307
|
+
for (const flag of ["--parent", "--project"]) {
|
|
308
|
+
const value = optionalNonEmpty(values.flags.get(flag));
|
|
309
|
+
if (value?.toLowerCase() !== "none")
|
|
310
|
+
continue;
|
|
311
|
+
if (action === "update")
|
|
312
|
+
continue;
|
|
313
|
+
throw new Error(action === "create"
|
|
314
|
+
? `${flag} none clears it on \`cockpit ticket update\`; leave ${flag} off to create without one.`
|
|
315
|
+
: `${flag} none clears it on \`cockpit ticket update\` only.`);
|
|
316
|
+
}
|
|
324
317
|
}
|
|
325
318
|
function readIssueAssignee(action, values) {
|
|
326
319
|
// An assignee is `me`, `unassigned`, or a person's uuid. A name or an
|
|
@@ -33,6 +33,7 @@ export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_
|
|
|
33
33
|
export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
|
|
34
34
|
export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
|
|
35
35
|
export { parseGithubArgs } from "./local-args-tower-github.js";
|
|
36
|
+
export { parseMilestoneArgs } from "./local-args-tower-milestone.js";
|
|
36
37
|
export { parseSearchArgs } from "./local-args-tower-search.js";
|
|
37
38
|
export { parseModelsArgs } from "./local-args-tower-models.js";
|
|
38
39
|
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
@@ -19,6 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
|
|
|
19
19
|
import { parseSpendArgs } from "./spend.js";
|
|
20
20
|
import { parsePushArgs } from "./push.js";
|
|
21
21
|
import { parseRootsArgs } from "./roots.js";
|
|
22
|
+
import { parseMilestoneArgs } from "./local-args-tower-milestone.js";
|
|
22
23
|
import { SURFACE_ALIASES } from "./local-help.js";
|
|
23
24
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
24
25
|
import { parseCiArgs, parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseHitsArgs, parseIntentionsArgs, parseWhoArgs, parsePeopleArgs, parseApprovalsArgs, parseDrillsArgs, parseManagerArgs, parseImageArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
|
|
@@ -90,6 +91,7 @@ const COMMAND_PARSERS = new Map([
|
|
|
90
91
|
["mail", (_command, rest) => parseMailArgs(rest)],
|
|
91
92
|
["cal", (_command, rest) => parseCalArgs(rest)],
|
|
92
93
|
["project", (_command, rest) => parseProjectArgs(rest)],
|
|
94
|
+
["milestone", (_command, rest) => parseMilestoneArgs(rest)],
|
|
93
95
|
["search", (_command, rest) => parseSearchArgs(rest)],
|
|
94
96
|
["github", (_command, rest) => parseGithubArgs(rest)],
|
|
95
97
|
["release", (_command, rest) => parseReleaseArgs(rest)],
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -18,6 +18,7 @@ import { DOCS_FORMATTING_HELP } from "./local-help-docs-formatting.js";
|
|
|
18
18
|
import { DOCS_INBOX_HELP } from "./local-help-docs-inbox.js";
|
|
19
19
|
import { DOCS_RECOVERY_HELP } from "./local-help-docs-recovery.js";
|
|
20
20
|
import { PROJECT_HELP } from "./local-help-project.js";
|
|
21
|
+
import { MILESTONE_HELP } from "./local-help-milestone.js";
|
|
21
22
|
import { INBOX_HELP_LINES } from "./local-help-inbox.js";
|
|
22
23
|
import { WORK_NOTIFY_HELP_LINES } from "./local-help-work-notify.js";
|
|
23
24
|
import { VIEWS_HELP_LINES } from "./local-help-views.js";
|
|
@@ -122,7 +123,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
122
123
|
[
|
|
123
124
|
"issue",
|
|
124
125
|
[
|
|
125
|
-
"Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|label <id>|relations <id>|relate <id>|unrelate <id>|views [list|show|save|update|favorite|unfavorite|settings]|templates [list|show|versions|save|update|apply]|cycles [list|show|create|update|map]|inbox|read <notification-id>...|unread <notification-id>...|subscribe <id>|unsubscribe <id>|notifications [set|receipts]|history <id>|versions <id>|restore <id> --version <n>|batch|mutation-status <request-uuid>] [flags]",
|
|
126
|
+
"Usage: cockpit issue [board|list|show <id> [<id>...]|create|update <id>|move <id> <state>|comment <id>|label <id>|archive <id>|unarchive <id>|relations <id>|relate <id>|unrelate <id>|views [list|show|save|update|favorite|unfavorite|settings]|templates [list|show|versions|save|update|apply]|cycles [list|show|create|update|map]|inbox|read <notification-id>...|unread <notification-id>...|subscribe <id>|unsubscribe <id>|notifications [set|receipts]|history <id>|versions <id>|restore <id> --version <n>|batch|mutation-status <request-uuid>] [flags]",
|
|
126
127
|
"",
|
|
127
128
|
"Tower's issue tracker. <id> is a BLI-#### identifier (BLI-3654) or an issue's uuid — both work everywhere an issue is named.",
|
|
128
129
|
"list [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--label <name>] [--q <text>] [--created-after|--created-before|--updated-after|--updated-before <utc instant|date>] [--sort updated_at|created_at] [--dir desc|asc] [--limit <n>] [--cursor <c>] [--all] — the issues you may see, most recently updated first. A page is 100 by default and at most 500. Filters run in the database BEFORE the page, so a label or title match deep in the set is found. --q is a case-insensitive title contains (% and _ are literal); *-after is inclusive, *-before is exclusive. --json adds next_cursor, has_more, coverage (complete|capped|failed_partial), requested, returned, unique: pass next_cursor back as --cursor with the same other flags. --all walks every page and streams ONE JSON OBJECT PER LINE (NDJSON; rows carry assignee_id, no names), then a final {\"type\":\"coverage\",...} line naming the coverage; an interrupted walk ends with coverage failed_partial, the reason and the resume cursor, and exits 1. Each row carries assignee_name/assignee_email alongside the bare assignee_id (BLI-10450, one extra `cockpit team members` call for the whole page, never per row), and a `size` label (S/M/L) alongside the raw `estimate` when it matches Tower's own scale (BLI-10575). --json shape: { issues: [{ id, identifier, title, state, priority, estimate, size, assignee_id, assignee_name, assignee_email, project_id, parent_id, updated_at }] }.",
|
|
@@ -130,14 +131,15 @@ export const TOWER_COMMAND_HELP = [
|
|
|
130
131
|
"show <id> [<id>...] — one or more issues: title, state, priority, size, assignee (name and email, not just its id), description, and every comment on each. Several ids read in one call — one bad id never loses the rest.",
|
|
131
132
|
" --json shape: one id answers { issue, comments }, unchanged from before BLI-10450 (plus `estimate`/`size`, BLI-10575). Several ids answer { issues: [{ ref, ok, issue, comments } | { ref, ok: false, error, detail }] }.",
|
|
132
133
|
"create --title \"<title>\" [--project <name|id>] [--priority 0-4] [--size S|M|L] [--assignee me|<uuid>] [--parent <id>] [--state <state>] [--due YYYY-MM-DD] [--file <path>] — --state is the starting state (default backlog); --due sets a due date. The DESCRIPTION comes from --file or stdin (`cat plan.md | cockpit issue create --title \"...\"`); neither means no description. --size maps onto the existing `estimate` column, Tower's own scale: S=1, M=3, L=5 (BLI-10575) — an estimate outside that scale (an imported Linear point value, say) just has no size label on a read. --json shape: { issue }.",
|
|
133
|
-
"update <id> [--title \"<t>\"] [--priority 0-4] [--size S|M|L] [--assignee me|<uuid
|
|
134
|
+
"update <id> [--title \"<t>\"] [--priority 0-4] [--size S|M|L|none] [--assignee me|<uuid>|none] [--project <name|id>|none] [--parent <id>|none] [--cycle <name|id>|none] [--due YYYY-MM-DD|none] [--file <path>|--body-stdin] — only the fields you pass change: a field you leave off is never touched. `none` is the ONE spelling that clears: --size none, --assignee none, --project none, --parent none (top-level again), --cycle none and --due none (BLI-22367). A committed write prints \"Saved in Tower (revision N). Linear copy: pending|delivered|failed|not mirrored (reason)\"; a refusal says \"not committed: nothing changed\" unless the answer was genuinely lost (then check mutation-status). A size already on the ticket that is not S, M or L (an imported estimate such as 8) stays as it is until you pass --size. State does NOT move here; use move. --json shape: { issue }.",
|
|
134
135
|
"label <id> [--add <label>]... [--remove <label>]... [--request-id <uuid>] [--expected-revision <n>] — adds and removes EXISTING labels (names match exactly, ignoring case; a uuid also works) in one receipt, one history row and one revision step. An unknown name is refused by name and nothing changes; labels are never created here. Any member may detach. --json shape: { issue, labels, mutation }. relations <id> [--json] — what blocks what: Blocks, Blocked by, Related, Duplicate of, Duplicates, and Not in Tower (an imported Linear edge to a ticket Tower does not have, shown as text). A sub-ticket is NOT a blocker and is not listed here. Edges to a deleted or unreadable ticket are left out and counted (coverage.hidden). --json shape: { relations: { blocks, blocked_by, related, duplicates, duplicate_of, unmapped, coverage } }, each item { relation_id, kind, source: native|linear, issue: { id, identifier, title, state } }. relate <id> --blocks <id> | --blocked-by <id> | --related <id> | --duplicate-of <id> [--request-id <uuid>] [--expected-revision <n>] — exactly one kind flag; records one edge as one receipt, one history row and one revision step (--blocked-by stores the edge from the other ticket). Refusals by name: relation_exists, relation_inverse_blocks, relation_self, relation_target_not_found_or_unreadable. unrelate <id> --relation <relation-id> | the same kind flag plus the other ticket — removes the edge (kept as removed, never re-imported). Refusal: relation_not_found. Setting --parent so a ticket would sit under its own sub-ticket is refused as circular_parent, a chain deeper than 200 as hierarchy_too_deep. --json shape: { issue, relation_id, mutation }. Also: move <id> <state> — moves an issue and records the move. States: backlog, todo, in_progress, in_review, done, canceled.",
|
|
136
|
+
"archive <id> | unarchive <id> [--request-id <uuid>] [--expected-revision <n>] — archive HIDES a ticket from lists, the board and counts; nothing is deleted, `show` still reads it (marked ARCHIVED), and unarchive brings it back with every field, comment and history row (BLI-22367). One receipt, one history row, one revision step. Tower-only: the Linear copy (if any) is not touched, so the receipt says not mirrored (archive_is_tower_only). Refusals: issue_already_archived, issue_not_archived, revision_conflict, request_id_reused. `list --archived [--q <text>] [--project <name|id>] [--limit <n>] [--cursor <c>] [--all]` lists archived tickets only, most recently archived first. --json shape: { issue, mutation }.",
|
|
135
137
|
"comment <id> — posts a comment. The body is never taken on the command line: `echo \"shipped\" | cockpit issue comment BLI-3654`.",
|
|
136
138
|
"Writes accept --request-id <uuid> and --expected-revision <n>. Keep the UUID and identical fields across retries; JSON includes mutation receipt and mirror delivery separately.",
|
|
137
139
|
"mutation-status <request-uuid> — check your own local commit and mirror delivery after a lost response; no receipt means unknown_check_again.",
|
|
138
140
|
"history <id> [--limit <n>] [--cursor <c>] — every recorded state move and reassignment, oldest first; --json carries the same page fields as list.",
|
|
139
141
|
"versions <id> [--limit <n>] — every saved description, newest first, each with its number, id, author, source and time (BLI-21300). --json shape: { versions: [{ id, version, markdown, author_id, source, restored_from_version, created_at }] }.",
|
|
140
|
-
"restore <id> --version <n> [--request-id <uuid>] [--expected-revision <n>] — writes version <n>'s text back as a NEW version; nothing is rewritten, so the restore can itself be undone. A stale revision conflicts and changes nothing. --json shape: { issue, mutation }. batch [--file <path>] [--request-id <uuid>] — changes up to 50 tickets in one call (BLI-21551). Reads { \"items\": [...] } (or a bare array) from stdin or --file, never argv. Each item is { issue, expected_revision?, request_id?, state? | priority? | assignee? | size? | project? | due_date? }: a state move OR field changes, not both, and one item per ticket. It is NOT all-or-nothing: every item is its own change with its own receipt, one stderr line each. Exit 0 only when every item committed, 1 otherwise; the batch request id is printed, and a retry with --request-id writes nothing twice. --json shape: { ok, request_id, requested, committed, refused, unknown, items: [{ index, issue, outcome, reason?, message?, request_id, revision?, row? }] }. Example: echo '{\"items\":[{\"issue\":\"BLI-1\",\"priority\":2},{\"issue\":\"BLI-2\",\"state\":\"done\"}]}' | cockpit ticket batch --json.",
|
|
142
|
+
"restore <id> --version <n> [--request-id <uuid>] [--expected-revision <n>] — writes version <n>'s text back as a NEW version; nothing is rewritten, so the restore can itself be undone. A stale revision conflicts and changes nothing. --json shape: { issue, mutation }. batch [--file <path>] [--request-id <uuid>] — changes up to 50 tickets in one call (BLI-21551). Reads { \"items\": [...] } (or a bare array) from stdin or --file, never argv. Each item is { issue, expected_revision?, request_id?, state? | priority? | assignee? | size? | project? | parent? | cycle? | due_date? } (\"none\" or null clears assignee, size, project, parent, cycle and due_date): a state move OR field changes, not both, and one item per ticket. It is NOT all-or-nothing: every item is its own change with its own receipt, one stderr line each. Exit 0 only when every item committed, 1 otherwise; the batch request id is printed, and a retry with --request-id writes nothing twice. --json shape: { ok, request_id, requested, committed, refused, unknown, items: [{ index, issue, outcome, reason?, message?, request_id, revision?, row? }] }. Example: echo '{\"items\":[{\"issue\":\"BLI-1\",\"priority\":2},{\"issue\":\"BLI-2\",\"state\":\"done\"}]}' | cockpit ticket batch --json.",
|
|
141
143
|
...VIEWS_HELP_LINES,
|
|
142
144
|
...TEMPLATES_HELP_LINES,
|
|
143
145
|
...CYCLES_HELP_LINES,
|
|
@@ -216,6 +218,7 @@ export const TOWER_COMMAND_HELP = [
|
|
|
216
218
|
],
|
|
217
219
|
],
|
|
218
220
|
PROJECT_HELP,
|
|
221
|
+
MILESTONE_HELP,
|
|
219
222
|
[
|
|
220
223
|
"speed",
|
|
221
224
|
[
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** `cockpit milestone` help (BLI-22360), kept beside the parser in local-args-tower-milestone.ts. */
|
|
2
|
+
export const MILESTONE_HELP = [
|
|
3
|
+
"milestone",
|
|
4
|
+
[
|
|
5
|
+
"Usage: cockpit milestone [list] [--project <id|name>] [--archived] [--json]",
|
|
6
|
+
" cockpit milestone show <id|name> [--project <id|name>] [--limit <n>] [--cursor <c>] [--json]",
|
|
7
|
+
' cockpit milestone create --project <id|name> --name "<name>" [--description <text|->] [--target YYYY-MM-DD] [--request-id <uuid>] [--json]',
|
|
8
|
+
" cockpit milestone update <id|name> [--project <p>] [--name <n>] [--description <text|-|none>] [--target <day|none>] [--expected-updated-at <ts>] [--request-id <uuid>] [--json]",
|
|
9
|
+
" cockpit milestone archive|restore <id|name> [--project <p>] [--expected-updated-at <ts>] [--request-id <uuid>] [--json]",
|
|
10
|
+
" cockpit milestone add|remove <id|name> <ticket> [<ticket> ...] [--project <p>] [--request-id <uuid>] [--json]",
|
|
11
|
+
"",
|
|
12
|
+
"Project milestones: a dated goal inside ONE project, with the tickets that make it up. Not Tower Manager's timeline milestones.",
|
|
13
|
+
"list — a project's milestones (every project's without --project), target date first, each with its progress. --archived includes archived ones.",
|
|
14
|
+
"show <ref> — the milestone, its progress and a page of its tickets. <ref> is an id or an exact name (case-folded, never fuzzy); --project narrows a name to one project; two milestones with one name are milestone_ref_ambiguous and the message lists their ids.",
|
|
15
|
+
"Progress is a count: done of (done + open) tickets. Canceled tickets are shown but left out of the total; estimates never weight it. Points (estimate sum over the estimated tickets only) and how many tickets carry an estimate are printed beside it. A milestone with more than 5,000 tickets says its counts are a floor.",
|
|
16
|
+
"create / update — name 1 to 120 characters, unique among the project's active milestones; `--description -` reads stdin, `--description none` and `--target none` clear (update). A milestone never moves to another project.",
|
|
17
|
+
"archive / restore — soft: tickets keep their milestone, nothing is deleted, there is no delete verb. An archived milestone refuses new tickets (milestone_archived).",
|
|
18
|
+
"add / remove <milestone> <tickets...> — up to 50 ticket ids or identifiers, one receipt each; one refusal never stops the others. A ticket must already be in the milestone's project (milestone_project_mismatch); a ticket is in at most one milestone, so add moves it from another. remove only takes out tickets that are in this milestone. Moving a ticket to another project takes it out of its milestone.",
|
|
19
|
+
"Milestones are Tower-only during the Linear dual run: a milestone write is delivery tower_only, and a ticket's milestone change says Linear copy not_mirrored (milestone_not_mirrored). The same --request-id replays the first receipts.",
|
|
20
|
+
"Refusals keep Tower's label: milestone_not_found_or_unreadable, milestone_ref_ambiguous, milestone_name_required, milestone_name_taken, milestone_date_invalid, milestone_already_archived, milestone_not_archived, milestone_archived, milestone_project_mismatch, project_archived, project_not_found_or_unreadable, revision_conflict, request_id_reused, invalid_body.",
|
|
21
|
+
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
22
|
+
],
|
|
23
|
+
];
|
|
@@ -16,6 +16,7 @@ export const PROJECT_HELP = [
|
|
|
16
16
|
"Every write returns a receipt with delivery tower_only: a project write never reaches Linear and never queues a Linear change. The same --request-id replays the first receipt; with another body it is request_id_reused.",
|
|
17
17
|
"--expected-updated-at is the updated_at you last read (shown by show and --json); a stale one is revision_conflict with the current project and changes nothing.",
|
|
18
18
|
"Refusals keep Tower's label: project_name_required, project_name_taken, project_owned_by_linear (a project imported from Linear is named there; archive and restore still work), project_already_archived, project_not_archived, project_not_found_or_unreadable, revision_conflict, request_id_reused, invalid_body.",
|
|
19
|
+
"BLI-22360 planning fields on create and update: --lead <email|me|none>, --member <email[,email]|none> (the whole set; repeatable), --add-member / --remove-member (update), --status backlog|planned|started|paused|completed|canceled|none, --priority none|urgent|high|medium|low (or 0-4), --start / --target <YYYY-MM-DD|none> with --start-resolution / --target-resolution month|quarter|halfYear|year|day (a date without one is an exact day), --content-file <path> or --content <text|none> (the long body; --description stays the short summary). People are matched by exact email, never by name. show prints them, the project's milestones and their progress (see `cockpit milestone`). Refusals: project_status_invalid, project_dates_invalid, project_person_not_found.",
|
|
19
20
|
"--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
|
|
20
21
|
],
|
|
21
22
|
];
|
|
@@ -79,6 +79,7 @@ export const rootCommandNames = new Set([
|
|
|
79
79
|
"mail",
|
|
80
80
|
"cal",
|
|
81
81
|
"project",
|
|
82
|
+
"milestone",
|
|
82
83
|
"search",
|
|
83
84
|
"github",
|
|
84
85
|
"release",
|
|
@@ -147,6 +148,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
147
148
|
" cockpit mail [accounts|add-imap --address <a>|inbox|read <thread>|search \"<words>\"|send --account <id> --to <a> --subject <s>|draft --account <id> --to <a> --subject <s> [--reply-to <message id>]|attachment <id> --out <path>|sync <account>|detach <account>] [--account <id>] [--limit <n>] [--unread] [--label <l>] [--file <path>] [--attach <path>]... [--dashboard-url <url>] [--json]",
|
|
148
149
|
" cockpit cal [event <id> [--occurrence-start <iso>]|export <id> [--occurrence-start <iso>]|today|week|conflicts|calendar-change <id> --color <hex-or-provider>|view-settings|view-settings-change|people <label>|availability --with <label|email> --from <d> --to <d>|overlays [--pin <label|email>|--remove <email>]|next|find \"<words>\"|calendars|add-ical|create --calendar <id> --title <t> --at <iso> --until <iso>|duplicate <event id> [--keep-invitees]|batch|undo [<change id>]|share <id> --org-visible|--private|sync <id> [--full]|detach <id>] [--tz <zone>] [--offset <n>] [--hours <n>] [--from <d> --to <d>] [--calendar <id>] [--limit <n>] [--all] [--dashboard-url <url>] [--json]",
|
|
149
150
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
151
|
+
" cockpit milestone [list|show|create|update|archive|restore|add|remove] [--project <p>] [--json]",
|
|
150
152
|
" cockpit github <pr|merge|retitle|rerun> <owner/repo> <n> [--claim <state>] [--method squash|merge|rebase] [--title \"<text>\"] [--run <id>] [--confirm <code>] [--dashboard-url <url>] [--json]",
|
|
151
153
|
` cockpit search "<words>" [--kind <category,...>] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
152
154
|
" cockpit search --categories [--dashboard-url <url>] [--json]",
|
package/dist/commands/local.js
CHANGED
|
@@ -47,6 +47,7 @@ import { runIssue } from "./issue.js";
|
|
|
47
47
|
import { runCal } from "./cal.js";
|
|
48
48
|
import { runMail } from "./mail.js";
|
|
49
49
|
import { runProject } from "./project.js";
|
|
50
|
+
import { runMilestone } from "./milestone.js";
|
|
50
51
|
import { runGithub } from "./github.js";
|
|
51
52
|
import { runSearch } from "./search.js";
|
|
52
53
|
import { runCareers } from "./careers.js";
|
|
@@ -203,6 +204,7 @@ async function runTowerCommand(command, io) {
|
|
|
203
204
|
case "issue": return await runIssue(command, io);
|
|
204
205
|
case "mail": return await runMail(command, io);
|
|
205
206
|
case "project": return await runProject(command, io);
|
|
207
|
+
case "milestone": return await runMilestone(command, io);
|
|
206
208
|
case "search": return await runSearch(command, io);
|
|
207
209
|
case "github": return await runGithub(command, io);
|
|
208
210
|
case "careers": return await runCareers(command, io);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
3
|
+
import { BODY_MAX_CHARS, READ_DEADLINE_MS, WRITE_DEADLINE_MS } from "./issue-contracts.js";
|
|
4
|
+
const TAG = "[milestone cli]";
|
|
5
|
+
export const milestoneLine = (m) => `${m.id} ${m.name} target ${m.target_date ?? "none"}${m.archived_at ? " [archived]" : ""} ${m.progress?.summary ?? ""}`.trimEnd();
|
|
6
|
+
function refuse(door, answer) {
|
|
7
|
+
const mutation = answer.body?.mutation;
|
|
8
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
|
|
9
|
+
}
|
|
10
|
+
function projectQuery(command) {
|
|
11
|
+
return command.project ? `?project=${encodeURIComponent(command.project)}` : "";
|
|
12
|
+
}
|
|
13
|
+
/** `--description -` reads the text from stdin; anything else is the text itself. */
|
|
14
|
+
async function readDescription(value, io) {
|
|
15
|
+
if (value !== "-")
|
|
16
|
+
return { ok: true, text: value };
|
|
17
|
+
if (isInteractiveStdin(io))
|
|
18
|
+
return { ok: false, detail: "--description - reads the text from stdin, and nothing is piped in." };
|
|
19
|
+
try {
|
|
20
|
+
return { ok: true, text: await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A milestone description is limited to ${BODY_MAX_CHARS} characters.` }) };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function runMilestone(command, io) {
|
|
27
|
+
const door = await openAgentDoor("milestone", command, io);
|
|
28
|
+
const ref = command.ref ? encodeURIComponent(command.ref) : "";
|
|
29
|
+
const writeIds = command.requestId ? { request_id: command.requestId } : {};
|
|
30
|
+
switch (command.action) {
|
|
31
|
+
case "list": {
|
|
32
|
+
const query = new URLSearchParams();
|
|
33
|
+
if (command.project)
|
|
34
|
+
query.set("project", command.project);
|
|
35
|
+
if (command.includeArchived)
|
|
36
|
+
query.set("include_archived", "true");
|
|
37
|
+
const answer = await askAgentDoor(door, {
|
|
38
|
+
path: `/api/work/milestones${query.size > 0 ? `?${query}` : ""}`, method: "GET", label: "milestone list", timeoutMs: READ_DEADLINE_MS,
|
|
39
|
+
});
|
|
40
|
+
if (!answer.ok)
|
|
41
|
+
return refuse(door, answer);
|
|
42
|
+
const body = answer.body;
|
|
43
|
+
const rows = body.milestones ?? [];
|
|
44
|
+
console.error(`${TAG} list done`, JSON.stringify({ returned: rows.length, coverage: body.coverage ?? null }));
|
|
45
|
+
if (door.json)
|
|
46
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
47
|
+
if (rows.length === 0)
|
|
48
|
+
writeLine(door.io.stdout, "No milestones here. Create one with `cockpit milestone create --project <p> --name <n>`.");
|
|
49
|
+
for (const row of rows)
|
|
50
|
+
writeLine(door.io.stdout, milestoneLine(row));
|
|
51
|
+
if (body.coverage === "capped")
|
|
52
|
+
writeLine(door.io.stdout, "\nOnly the first 500 milestones are listed.");
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
case "show": {
|
|
56
|
+
const query = new URLSearchParams();
|
|
57
|
+
if (command.project)
|
|
58
|
+
query.set("project", command.project);
|
|
59
|
+
if (command.limit !== undefined)
|
|
60
|
+
query.set("limit", String(command.limit));
|
|
61
|
+
if (command.cursor)
|
|
62
|
+
query.set("cursor", command.cursor);
|
|
63
|
+
const answer = await askAgentDoor(door, {
|
|
64
|
+
path: `/api/work/milestones/${ref}${query.size > 0 ? `?${query}` : ""}`, method: "GET", label: "milestone show", timeoutMs: READ_DEADLINE_MS,
|
|
65
|
+
});
|
|
66
|
+
if (!answer.ok)
|
|
67
|
+
return refuse(door, answer);
|
|
68
|
+
const body = answer.body;
|
|
69
|
+
console.error(`${TAG} show done`, JSON.stringify({ milestone_id: body.milestone.id, members: body.issues?.length ?? 0, has_more: Boolean(body.has_more) }));
|
|
70
|
+
if (door.json)
|
|
71
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
72
|
+
const out = door.io.stdout;
|
|
73
|
+
writeLine(out, milestoneLine(body.milestone));
|
|
74
|
+
writeLine(out, `project ${body.project?.name ?? body.milestone.project_id} updated ${body.milestone.updated_at ?? ""}`);
|
|
75
|
+
if (body.milestone.description)
|
|
76
|
+
writeLine(out, `\n${body.milestone.description}`);
|
|
77
|
+
writeLine(out, `\nProgress: ${body.progress?.summary ?? "no progress summary"}`);
|
|
78
|
+
for (const issue of body.issues ?? [])
|
|
79
|
+
writeLine(out, `${(issue.identifier ?? "").padEnd(9)} ${(issue.state ?? "").padEnd(11)} ${issue.title ?? ""}`);
|
|
80
|
+
if (body.has_more && body.next_cursor)
|
|
81
|
+
writeLine(out, `More tickets: --cursor ${body.next_cursor}`);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
case "create":
|
|
85
|
+
case "update": {
|
|
86
|
+
const description = await readDescription(command.description, io);
|
|
87
|
+
if (!description.ok)
|
|
88
|
+
return failAgentDoor(door, TAG, "invalid_body", description.detail);
|
|
89
|
+
const payload = {
|
|
90
|
+
...writeIds,
|
|
91
|
+
...(command.action === "create" ? { project: command.project } : {}),
|
|
92
|
+
...(command.action === "update" && command.expectedUpdatedAt ? { expected_updated_at: command.expectedUpdatedAt } : {}),
|
|
93
|
+
...(command.name === undefined ? {} : { name: command.name }),
|
|
94
|
+
...(description.text === undefined ? {} : { description: description.text }),
|
|
95
|
+
...(command.targetDate === undefined ? {} : { target_date: command.targetDate }),
|
|
96
|
+
};
|
|
97
|
+
return await write(command, door, command.action === "create"
|
|
98
|
+
? { path: "/api/work/milestones", method: "POST", body: payload }
|
|
99
|
+
: { path: `/api/work/milestones/${ref}${projectQuery(command)}`, method: "PATCH", body: payload });
|
|
100
|
+
}
|
|
101
|
+
case "archive":
|
|
102
|
+
case "restore":
|
|
103
|
+
return await write(command, door, {
|
|
104
|
+
path: `/api/work/milestones/${ref}${projectQuery(command)}`,
|
|
105
|
+
method: "PATCH",
|
|
106
|
+
body: { ...writeIds, ...(command.expectedUpdatedAt ? { expected_updated_at: command.expectedUpdatedAt } : {}), archived: command.action === "archive" },
|
|
107
|
+
});
|
|
108
|
+
case "add":
|
|
109
|
+
case "remove":
|
|
110
|
+
return await membership(command, door, ref, writeIds);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function write(command, door, req) {
|
|
114
|
+
const answer = await askAgentDoor(door, { ...req, label: `milestone ${command.action}`, timeoutMs: WRITE_DEADLINE_MS });
|
|
115
|
+
if (!answer.ok)
|
|
116
|
+
return refuse(door, answer);
|
|
117
|
+
const body = answer.body;
|
|
118
|
+
console.error(`${TAG} ${command.action} committed`, JSON.stringify({ milestone_id: body.milestone.id, request_id: body.mutation?.request_id ?? null, delivery: body.mutation?.delivery?.status ?? null }));
|
|
119
|
+
if (door.json)
|
|
120
|
+
return emitAgentDoor(door, { ok: true, ...body });
|
|
121
|
+
const label = { create: "Created", update: "Updated", archive: "Archived", restore: "Restored" }[command.action] ?? "Saved";
|
|
122
|
+
writeLine(door.io.stdout, `${label}: ${milestoneLine(body.milestone)}`);
|
|
123
|
+
writeLine(door.io.stdout, `Request ID ${body.mutation?.request_id ?? "?"}, delivery ${body.mutation?.delivery?.status ?? "tower_only"} (milestones never reach Linear).`);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
async function membership(command, door, ref, writeIds) {
|
|
127
|
+
const answer = await askAgentDoor(door, {
|
|
128
|
+
path: `/api/work/milestones/${ref}/issues${projectQuery(command)}`,
|
|
129
|
+
method: "POST",
|
|
130
|
+
label: `milestone ${command.action}`,
|
|
131
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
132
|
+
body: { ...writeIds, [command.action]: command.tickets },
|
|
133
|
+
});
|
|
134
|
+
// 207 means some items were refused: still a readable answer with every receipt.
|
|
135
|
+
const body = (("body" in answer ? answer.body : undefined) ?? {});
|
|
136
|
+
if (!answer.ok && !Array.isArray(body.items))
|
|
137
|
+
return refuse(door, answer);
|
|
138
|
+
const items = body.items ?? [];
|
|
139
|
+
console.error(`${TAG} ${command.action} done`, JSON.stringify({
|
|
140
|
+
request_id: body.request_id ?? null, asked: items.length, committed: body.committed ?? 0, refused: body.refused ?? 0, skipped: body.skipped ?? 0, unknown: body.unknown ?? 0,
|
|
141
|
+
}));
|
|
142
|
+
const failed = (body.refused ?? 0) + (body.unknown ?? 0) > 0;
|
|
143
|
+
if (door.json) {
|
|
144
|
+
emitAgentDoor(door, { ok: !failed, ...body });
|
|
145
|
+
return failed ? 1 : 0;
|
|
146
|
+
}
|
|
147
|
+
for (const item of items) {
|
|
148
|
+
const linear = item.delivery?.status ? ` Linear copy: ${item.delivery.status}${item.delivery.reason ? ` (${item.delivery.reason})` : ""}` : "";
|
|
149
|
+
writeLine(door.io.stdout, `${(item.identifier ?? item.ref).padEnd(9)} ${item.action} ${item.outcome}${item.reason ? ` ${item.reason}` : ""}${linear}`);
|
|
150
|
+
if (item.message && item.outcome !== "committed")
|
|
151
|
+
writeLine(door.io.stdout, ` ${item.message}`);
|
|
152
|
+
}
|
|
153
|
+
writeLine(door.io.stdout, `\n${body.committed ?? 0} committed, ${body.refused ?? 0} refused, ${body.skipped ?? 0} skipped, ${body.unknown ?? 0} unknown. Request ID ${body.request_id ?? "?"} (retry with it to replay).`);
|
|
154
|
+
if (body.progress?.summary)
|
|
155
|
+
writeLine(door.io.stdout, `Progress: ${body.progress.summary}`);
|
|
156
|
+
return failed ? 1 : 0;
|
|
157
|
+
}
|