@bli-cockpit/cli 0.2.57 → 0.2.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * What `cockpit issue` hands between its own halves (BLI-3716): the row
3
+ * shapes the `/api/work/**` doors answer with, the two deadlines, and the
4
+ * three "which one did you mean" resolvers both the read half (`issue.ts`)
5
+ * and the write half (`issue-write.ts`) need.
6
+ *
7
+ * Split out so neither half carries the other's weight — the same table-of-
8
+ * contents discipline `commands/session-sync*.ts` and `commands/backfill*.ts`
9
+ * follow, applied before the file got large rather than after.
10
+ */
11
+ import { askAgentDoor } from "./agent-door.js";
12
+ import { isInteractiveStdin, readPipedText } from "./cli-io.js";
13
+ import { readNoteFile } from "./notes-file.js";
14
+ export const TAG = "[issue cli]";
15
+ export const READ_DEADLINE_MS = 30_000;
16
+ export const WRITE_DEADLINE_MS = 60_000;
17
+ export const BODY_MAX_CHARS = 200_000;
18
+ /** A project id from a project NAME or a uuid — exact after case-folding. */
19
+ export async function resolveProjectId(door, ref) {
20
+ const answer = await askAgentDoor(door, {
21
+ path: "/api/work/projects?include_archived=true",
22
+ method: "GET",
23
+ label: "issue project resolve",
24
+ timeoutMs: READ_DEADLINE_MS,
25
+ });
26
+ if (!answer.ok)
27
+ return { status: "list_failed", reason: answer.reason, detail: answer.detail };
28
+ const projects = answer.body.projects ?? [];
29
+ const wanted = ref.toLowerCase();
30
+ const match = projects.find((project) => project.id === ref || project.name.toLowerCase() === wanted);
31
+ return match ? { status: "ok", id: match.id } : { status: "not_found" };
32
+ }
33
+ /** A parent issue's uuid from any issue reference, via the door's own resolver. */
34
+ export async function resolveIssueUuid(door, ref) {
35
+ const answer = await askAgentDoor(door, {
36
+ path: `/api/work/issues/${encodeURIComponent(ref)}`,
37
+ method: "GET",
38
+ label: "issue parent resolve",
39
+ timeoutMs: READ_DEADLINE_MS,
40
+ });
41
+ if (!answer.ok)
42
+ return { status: "failed", reason: answer.reason, detail: answer.detail };
43
+ const issue = answer.body.issue;
44
+ if (!issue?.id) {
45
+ return {
46
+ status: "failed",
47
+ reason: "issue_not_found_or_unreadable",
48
+ detail: `That issue does not exist, or you cannot read it: ${ref}`,
49
+ };
50
+ }
51
+ return { status: "ok", id: issue.id };
52
+ }
53
+ /** Body on stdin, never argv — `--file` is the safest way on Windows. */
54
+ export async function readBody(command, io) {
55
+ if (command.filePath) {
56
+ const read = await readNoteFile(command.filePath);
57
+ if (!read.ok) {
58
+ return { ok: false, reason: read.refusal, detail: `Could not read ${command.filePath}: ${read.detail}` };
59
+ }
60
+ return { ok: true, text: read.bytes.toString("utf8") };
61
+ }
62
+ if (isInteractiveStdin(io))
63
+ return { ok: true, text: "" };
64
+ try {
65
+ const text = await readPipedText(io.stdin, {
66
+ maxChars: BODY_MAX_CHARS,
67
+ overflowMessage: `An issue description is limited to ${BODY_MAX_CHARS} characters.`,
68
+ });
69
+ return { ok: true, text };
70
+ }
71
+ catch (error) {
72
+ return { ok: false, reason: "description_too_long", detail: error instanceof Error ? error.message : String(error) };
73
+ }
74
+ }
75
+ /** The project/parent halves both `create` and `update` need, resolved once. */
76
+ export async function resolveRefs(command, door) {
77
+ let projectId;
78
+ let parentId;
79
+ if (command.project) {
80
+ const resolved = await resolveProjectId(door, command.project);
81
+ if (resolved.status === "list_failed")
82
+ return { ok: false, reason: resolved.reason, detail: resolved.detail };
83
+ if (resolved.status === "not_found") {
84
+ return {
85
+ ok: false,
86
+ reason: "project_not_found_or_unreadable",
87
+ detail: `No project here is called "${command.project}" — run \`cockpit project list\` for the names.`,
88
+ };
89
+ }
90
+ projectId = resolved.id;
91
+ }
92
+ if (command.parentRef) {
93
+ const resolved = await resolveIssueUuid(door, command.parentRef);
94
+ if (resolved.status === "failed")
95
+ return { ok: false, reason: resolved.reason, detail: resolved.detail };
96
+ parentId = resolved.id;
97
+ }
98
+ return { ok: true, ...(projectId ? { projectId } : {}), ...(parentId ? { parentId } : {}) };
99
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The verbs that CHANGE something: `cockpit issue create|update|move|comment`
3
+ * (BLI-3716). The reads and the router live in `issue.ts`, the shared shapes
4
+ * and resolvers in `issue-contracts.ts`.
5
+ *
6
+ * Every write prints a receipt line on stderr naming what it did and to
7
+ * which row, success included — `--json` owns stdout, so a receipt that went
8
+ * there would corrupt the one machine-readable object a caller parses.
9
+ */
10
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
11
+ import { writeLine } from "./cli-io.js";
12
+ import { TAG, WRITE_DEADLINE_MS, readBody, resolveRefs, } from "./issue-contracts.js";
13
+ export async function createIssue(command, door) {
14
+ if (!command.title)
15
+ return failAgentDoor(door, TAG, "invalid_body", "issue create needs --title.");
16
+ const refs = await resolveRefs(command, door);
17
+ if (!refs.ok)
18
+ return failAgentDoor(door, TAG, refs.reason, refs.detail);
19
+ const body = await readBody(command, door.io);
20
+ if (!body.ok)
21
+ return failAgentDoor(door, TAG, body.reason, body.detail);
22
+ const answer = await askAgentDoor(door, {
23
+ path: "/api/work/issues",
24
+ method: "POST",
25
+ label: "issue create",
26
+ timeoutMs: WRITE_DEADLINE_MS,
27
+ body: {
28
+ title: command.title,
29
+ ...(body.text ? { description: body.text } : {}),
30
+ ...(refs.projectId ? { project_id: refs.projectId } : {}),
31
+ ...(refs.parentId ? { parent_id: refs.parentId } : {}),
32
+ ...(command.priority === undefined ? {} : { priority: command.priority }),
33
+ ...(command.assignee ? { assignee_id: command.assignee } : {}),
34
+ },
35
+ });
36
+ if (!answer.ok)
37
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
38
+ const issue = answer.body.issue;
39
+ writeLine(door.io.stderr, `${TAG} created ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, description_bytes: Buffer.byteLength(body.text, "utf8") })}`);
40
+ if (door.json)
41
+ return emitAgentDoor(door, { ok: true, issue });
42
+ writeLine(door.io.stdout, `Created ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`);
43
+ return 0;
44
+ }
45
+ export async function updateIssue(command, door) {
46
+ const ref = command.issueRef ?? "";
47
+ const refs = await resolveRefs(command, door);
48
+ if (!refs.ok)
49
+ return failAgentDoor(door, TAG, refs.reason, refs.detail);
50
+ let description;
51
+ if (command.bodyStdin || command.filePath) {
52
+ const body = await readBody(command, door.io);
53
+ if (!body.ok)
54
+ return failAgentDoor(door, TAG, body.reason, body.detail);
55
+ description = body.text;
56
+ }
57
+ if (description === undefined
58
+ && command.title === undefined
59
+ && command.priority === undefined
60
+ && command.assignee === undefined
61
+ && refs.projectId === undefined
62
+ && refs.parentId === undefined) {
63
+ return failAgentDoor(door, TAG, "invalid_body", "issue update needs at least one of --title, --priority, --assignee, --project, --parent, or a description on --body-stdin/--file.");
64
+ }
65
+ const answer = await askAgentDoor(door, {
66
+ path: `/api/work/issues/${encodeURIComponent(ref)}`,
67
+ method: "PATCH",
68
+ label: "issue update",
69
+ timeoutMs: WRITE_DEADLINE_MS,
70
+ body: {
71
+ ...(command.title !== undefined ? { title: command.title } : {}),
72
+ ...(description !== undefined ? { description } : {}),
73
+ ...(command.priority === undefined ? {} : { priority: command.priority }),
74
+ ...(command.assignee ? { assignee_id: command.assignee } : {}),
75
+ ...(refs.projectId ? { project_id: refs.projectId } : {}),
76
+ ...(refs.parentId ? { parent_id: refs.parentId } : {}),
77
+ },
78
+ });
79
+ if (!answer.ok)
80
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
81
+ const issue = answer.body.issue;
82
+ writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null })}`);
83
+ if (door.json)
84
+ return emitAgentDoor(door, { ok: true, issue });
85
+ writeLine(door.io.stdout, `Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`);
86
+ return 0;
87
+ }
88
+ export async function moveIssue(command, door) {
89
+ const ref = command.issueRef ?? "";
90
+ const answer = await askAgentDoor(door, {
91
+ path: `/api/work/issues/${encodeURIComponent(ref)}/state`,
92
+ method: "POST",
93
+ label: "issue move",
94
+ timeoutMs: WRITE_DEADLINE_MS,
95
+ body: { state: command.moveState },
96
+ });
97
+ if (!answer.ok)
98
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
99
+ const issue = answer.body.issue;
100
+ writeLine(door.io.stderr, `${TAG} moved ${JSON.stringify({ issue_id: issue?.id ?? null, identifier: issue?.identifier ?? null, to_state: command.moveState })}`);
101
+ if (door.json)
102
+ return emitAgentDoor(door, { ok: true, issue });
103
+ writeLine(door.io.stdout, `${issue?.identifier ?? ref} is now ${issue?.state ?? command.moveState}.`);
104
+ return 0;
105
+ }
106
+ export async function commentIssue(command, door) {
107
+ const ref = command.issueRef ?? "";
108
+ const body = await readBody(command, door.io);
109
+ if (!body.ok)
110
+ return failAgentDoor(door, TAG, body.reason, body.detail);
111
+ if (body.text.trim() === "") {
112
+ return failAgentDoor(door, TAG, "invalid_body", 'A comment is never taken on the command line — pipe it in: `echo "..." | cockpit issue comment BLI-3654`.');
113
+ }
114
+ const answer = await askAgentDoor(door, {
115
+ path: `/api/work/issues/${encodeURIComponent(ref)}/comments`,
116
+ method: "POST",
117
+ label: "issue comment",
118
+ timeoutMs: WRITE_DEADLINE_MS,
119
+ body: { body_markdown: body.text },
120
+ });
121
+ if (!answer.ok)
122
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
123
+ const comment = answer.body.comment;
124
+ writeLine(door.io.stderr, `${TAG} commented ${JSON.stringify({ comment_id: comment?.id ?? null, byte_size: Buffer.byteLength(body.text, "utf8") })}`);
125
+ if (door.json)
126
+ return emitAgentDoor(door, { ok: true, comment });
127
+ writeLine(door.io.stdout, `Commented on ${ref} (${comment?.id ?? ""}).`);
128
+ return 0;
129
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `cockpit issue` — Tower's issue tracker, typed (BLI-3716).
3
+ *
4
+ * Seven verbs over `/api/work/**`, the same doors the browser uses. Every
5
+ * one of those routes already accepted the collector device token
6
+ * (`resolveCaller({ allowDeviceToken: true })`, BLI-3703), so this command
7
+ * is a terminal in front of an existing door, not a new one.
8
+ *
9
+ * Three references a person actually types, resolved here or by the door:
10
+ * - an ISSUE is `BLI-3654` or a uuid. The SERVER resolves it
11
+ * (`lib/work/issue-ref.ts`), so `show`/`move`/`comment`/`history` cost one
12
+ * request, not two.
13
+ * - a PROJECT is its name or a uuid, resolved by `issue-contracts.ts`
14
+ * against the same `GET /api/work/projects` list `cockpit project list`
15
+ * prints — exact after case-folding, never fuzzy.
16
+ * - a PARENT is another issue reference, resolved with one `GET` so the
17
+ * create/patch body carries the uuid its schema demands.
18
+ *
19
+ * This file is the router plus the READ verbs (`list`, `show`, `history`);
20
+ * `issue-write.ts` holds `create`, `update`, `move` and `comment`, and
21
+ * `issue-contracts.ts` the shapes and resolvers both halves share.
22
+ *
23
+ * Bodies never travel on argv: a description or a comment comes from stdin
24
+ * or `--file`. A refusal keeps the door's own `reason` label verbatim
25
+ * (`agent-door.ts`) — `issue_not_found_or_unreadable`, `invalid_state`,
26
+ * `needs_rls_client`, `comment_too_long`, and so on.
27
+ */
28
+ import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
29
+ import { writeLine } from "./cli-io.js";
30
+ import { READ_DEADLINE_MS, TAG, resolveProjectId, } from "./issue-contracts.js";
31
+ import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
32
+ export async function runIssue(command, io) {
33
+ const door = await openAgentDoor("issue", command, io);
34
+ switch (command.action) {
35
+ case "list":
36
+ return listIssues(command, door);
37
+ case "show":
38
+ return showIssue(command, door);
39
+ case "create":
40
+ return createIssue(command, door);
41
+ case "update":
42
+ return updateIssue(command, door);
43
+ case "move":
44
+ return moveIssue(command, door);
45
+ case "comment":
46
+ return commentIssue(command, door);
47
+ case "history":
48
+ return issueHistory(command, door);
49
+ }
50
+ }
51
+ function issueLine(issue) {
52
+ return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}`;
53
+ }
54
+ async function listIssues(command, door) {
55
+ const query = new URLSearchParams();
56
+ if (command.stateFilter)
57
+ query.set("state", command.stateFilter);
58
+ if (command.assignee)
59
+ query.set("assignee_id", command.assignee);
60
+ if (command.limit !== undefined)
61
+ query.set("limit", String(command.limit));
62
+ if (command.project) {
63
+ const resolved = await resolveProjectId(door, command.project);
64
+ if (resolved.status === "list_failed")
65
+ return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
66
+ if (resolved.status === "not_found") {
67
+ return failAgentDoor(door, TAG, "project_not_found_or_unreadable", `No project here is called "${command.project}" — run \`cockpit project list\` for the names.`);
68
+ }
69
+ query.set("project_id", resolved.id);
70
+ }
71
+ const suffix = query.toString();
72
+ const answer = await askAgentDoor(door, {
73
+ path: `/api/work/issues${suffix ? `?${suffix}` : ""}`,
74
+ method: "GET",
75
+ label: "issue list",
76
+ timeoutMs: READ_DEADLINE_MS,
77
+ });
78
+ if (!answer.ok)
79
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
80
+ const issues = answer.body.issues ?? [];
81
+ if (door.json)
82
+ return emitAgentDoor(door, { ok: true, issues });
83
+ if (issues.length === 0) {
84
+ writeLine(door.io.stdout, "No issues match that.");
85
+ return 0;
86
+ }
87
+ for (const issue of issues)
88
+ writeLine(door.io.stdout, issueLine(issue));
89
+ writeLine(door.io.stdout, "");
90
+ writeLine(door.io.stdout, `${issues.length} issue(s).`);
91
+ return 0;
92
+ }
93
+ async function showIssue(command, door) {
94
+ const ref = command.issueRef ?? "";
95
+ const answer = await askAgentDoor(door, {
96
+ path: `/api/work/issues/${encodeURIComponent(ref)}`,
97
+ method: "GET",
98
+ label: "issue show",
99
+ timeoutMs: READ_DEADLINE_MS,
100
+ });
101
+ if (!answer.ok)
102
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
103
+ const issue = answer.body.issue;
104
+ if (!issue) {
105
+ return failAgentDoor(door, TAG, "issue_not_found_or_unreadable", `That issue does not exist, or you cannot read it: ${ref}`);
106
+ }
107
+ // A comment read that fails does NOT fail the whole `show` — the issue is
108
+ // already in hand, and a named gap beats losing what was read.
109
+ const commentsAnswer = await askAgentDoor(door, {
110
+ path: `/api/work/issues/${encodeURIComponent(issue.id)}/comments`,
111
+ method: "GET",
112
+ label: "issue show comments",
113
+ timeoutMs: READ_DEADLINE_MS,
114
+ });
115
+ const comments = commentsAnswer.ok
116
+ ? (commentsAnswer.body.comments ?? [])
117
+ : [];
118
+ if (!commentsAnswer.ok) {
119
+ writeLine(door.io.stderr, `${TAG} comments unread ${JSON.stringify({ reason: commentsAnswer.reason, issue_id: issue.id })}`);
120
+ }
121
+ if (door.json) {
122
+ return emitAgentDoor(door, {
123
+ ok: true,
124
+ issue,
125
+ comments,
126
+ ...(commentsAnswer.ok ? {} : { comments_unread_reason: commentsAnswer.reason }),
127
+ });
128
+ }
129
+ writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
130
+ writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${issue.assignee_id ?? "(nobody)"} · updated ${issue.updated_at}`);
131
+ writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
132
+ writeLine(door.io.stdout, "");
133
+ writeLine(door.io.stdout, issue.description ?? "(no description)");
134
+ if (comments.length > 0) {
135
+ writeLine(door.io.stdout, "");
136
+ writeLine(door.io.stdout, `--- ${comments.length} comment(s) ---`);
137
+ for (const comment of comments) {
138
+ writeLine(door.io.stdout, "");
139
+ writeLine(door.io.stdout, `[${comment.created_at}] ${comment.author_name ?? comment.author_id ?? "unknown"}`);
140
+ writeLine(door.io.stdout, comment.body_markdown);
141
+ }
142
+ }
143
+ else if (commentsAnswer.ok) {
144
+ writeLine(door.io.stdout, "");
145
+ writeLine(door.io.stdout, "No comments.");
146
+ }
147
+ return 0;
148
+ }
149
+ /**
150
+ * What a history row actually says. A row imported from Linear's issue
151
+ * TIMESTAMPS (created/started/completed/canceled) carries no state names at
152
+ * all: the reader refuses to name a workflow state it cannot prove
153
+ * (`linear-reader.ts` `buildSyntheticTransitions` — "naming a stale workflow
154
+ * state would be a small invented fact"), and only the state CATEGORY it did
155
+ * know is dropped on the way into `work_issue_history`. Printing
156
+ * "(none) -> (none)" is honest and useless; this says which it is, so nobody
157
+ * reads a real gap as a broken command.
158
+ */
159
+ function historyChange(row) {
160
+ if (row.from_value === null && row.to_value === null) {
161
+ return `(no state names recorded — imported from ${row.source})`;
162
+ }
163
+ return `${row.from_value ?? "(none)"} -> ${row.to_value ?? "(none)"}`;
164
+ }
165
+ async function issueHistory(command, door) {
166
+ const ref = command.issueRef ?? "";
167
+ const query = command.limit === undefined ? "" : `?limit=${command.limit}`;
168
+ const answer = await askAgentDoor(door, {
169
+ path: `/api/work/issues/${encodeURIComponent(ref)}/history${query}`,
170
+ method: "GET",
171
+ label: "issue history",
172
+ timeoutMs: READ_DEADLINE_MS,
173
+ });
174
+ if (!answer.ok)
175
+ return failAgentDoor(door, TAG, answer.reason, answer.detail);
176
+ const history = answer.body.history ?? [];
177
+ if (door.json)
178
+ return emitAgentDoor(door, { ok: true, history });
179
+ if (history.length === 0) {
180
+ writeLine(door.io.stdout, `No recorded changes for ${ref}.`);
181
+ return 0;
182
+ }
183
+ for (const row of history) {
184
+ writeLine(door.io.stdout, `${row.occurred_at} ${row.change_type.padEnd(9)} ${historyChange(row)} ${row.actor_name ?? ""}`.trimEnd());
185
+ }
186
+ writeLine(door.io.stdout, "");
187
+ writeLine(door.io.stdout, `${history.length} change(s).`);
188
+ return 0;
189
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `cockpit issue` and `cockpit project` argument parsing (BLI-3716) — the
3
+ * Work surface's half of `local-args-tower.ts`, a sibling of
4
+ * `local-args-tower-docs-msg.ts` for the same reason that file is a sibling
5
+ * of the pages family: issues are their own noun with their own vocabulary
6
+ * (a state, an assignee, a project) and folding them into another family's
7
+ * parser would stretch that family's doc comment past the truth.
8
+ *
9
+ * Two commands, because a person types `cockpit project list`, not
10
+ * `cockpit issue projects` — the nouns are what a person says.
11
+ *
12
+ * Bodies never travel on argv: `create --title` names the issue, but the
13
+ * DESCRIPTION and every comment come from stdin or `--file`, the same
14
+ * discipline `cockpit docs` and `cockpit notes paste` keep (BLI-3706, and
15
+ * the sourcing-secrets verdict before it — argv is world-readable on a
16
+ * shared machine and lands in shell history).
17
+ */
18
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
19
+ const ISSUE_ACTIONS = new Set([
20
+ "list",
21
+ "show",
22
+ "create",
23
+ "update",
24
+ "move",
25
+ "comment",
26
+ "history",
27
+ ]);
28
+ /** Every verb except `list` and `create` names ONE issue first. */
29
+ const ISSUE_ACTIONS_NEEDING_AN_ISSUE = new Set([
30
+ "show",
31
+ "update",
32
+ "move",
33
+ "comment",
34
+ "history",
35
+ ]);
36
+ /**
37
+ * The closed state vocabulary, copied verbatim from
38
+ * `apps/dashboard/src/lib/work/api-doors.ts` `WORK_ISSUE_STATES` (which is
39
+ * itself the migration's CHECK constraint). Duplicated rather than imported
40
+ * because no dependency edge exists from the collector to the dashboard;
41
+ * `issue.ts`'s tests pin the list so a drift shows up here rather than as a
42
+ * 400 on a fleet machine.
43
+ */
44
+ export const ISSUE_STATES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
45
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+ export function parseIssueArgs(args) {
47
+ const values = parseNamedArgs(args, {
48
+ allowedFlags: [
49
+ "--home",
50
+ "--dashboard-url",
51
+ "--state",
52
+ "--assignee",
53
+ "--project",
54
+ "--limit",
55
+ "--title",
56
+ "--priority",
57
+ "--parent",
58
+ "--file",
59
+ "--body-stdin",
60
+ "--json",
61
+ ],
62
+ valueFlags: [
63
+ "--home",
64
+ "--dashboard-url",
65
+ "--state",
66
+ "--assignee",
67
+ "--project",
68
+ "--limit",
69
+ "--title",
70
+ "--priority",
71
+ "--parent",
72
+ "--file",
73
+ ],
74
+ });
75
+ const first = values.positionals[0];
76
+ const action = (first === undefined ? "list" : first);
77
+ if (!ISSUE_ACTIONS.has(action)) {
78
+ throw new Error(`Unknown issue command: ${first}. Try list, show, create, update, move, comment, or history.`);
79
+ }
80
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
81
+ let issueRef;
82
+ let moveState;
83
+ if (ISSUE_ACTIONS_NEEDING_AN_ISSUE.has(action)) {
84
+ issueRef = optionalNonEmpty(rest[0]);
85
+ if (!issueRef)
86
+ throw new Error(`issue ${action} needs an issue id or identifier, e.g. BLI-3654.`);
87
+ if (action === "move") {
88
+ moveState = optionalNonEmpty(rest[1]);
89
+ if (!moveState) {
90
+ throw new Error(`issue move needs a state: ${ISSUE_STATES.join(", ")}.`);
91
+ }
92
+ if (rest.length > 2)
93
+ throw new Error(`issue move takes an issue and one state, not ${rest.length} words.`);
94
+ }
95
+ else if (rest.length > 1) {
96
+ throw new Error(`issue ${action} takes one issue reference, not ${rest.length}.`);
97
+ }
98
+ }
99
+ else if (rest.length > 0) {
100
+ throw new Error(`issue ${action} does not take "${rest[0]}".`);
101
+ }
102
+ if (moveState && !ISSUE_STATES.includes(moveState)) {
103
+ throw new Error(`issue move state must be one of: ${ISSUE_STATES.join(", ")}.`);
104
+ }
105
+ const stateFilter = optionalNonEmpty(values.flags.get("--state"));
106
+ if (stateFilter !== undefined && !ISSUE_STATES.includes(stateFilter)) {
107
+ throw new Error(`--state must be one of: ${ISSUE_STATES.join(", ")}.`);
108
+ }
109
+ if (stateFilter !== undefined && action !== "list") {
110
+ throw new Error("--state filters `cockpit issue list`; to move an issue use `cockpit issue move <id> <state>`.");
111
+ }
112
+ const title = optionalNonEmpty(values.flags.get("--title"));
113
+ if (action === "create" && !title)
114
+ throw new Error("issue create needs --title.");
115
+ // Not `optionalPositiveInteger`: 0 is a legitimate priority ("none", the
116
+ // column default), and that helper refuses it as non-positive.
117
+ const priorityRaw = optionalNonEmpty(values.flags.get("--priority"));
118
+ let priority;
119
+ if (priorityRaw !== undefined) {
120
+ priority = Number(priorityRaw);
121
+ if (!Number.isInteger(priority) || priority < 0 || priority > 4) {
122
+ throw new Error("--priority must be 0-4 (0 none, 1 urgent, 2 high, 3 medium, 4 low).");
123
+ }
124
+ }
125
+ // An assignee is `me`, `unassigned`, or a person's uuid. A name or an
126
+ // email is refused HERE rather than travelling to Postgres as a malformed
127
+ // uuid, which would come back as a 500 that names nothing useful.
128
+ const assignee = optionalNonEmpty(values.flags.get("--assignee"));
129
+ if (assignee !== undefined && assignee !== "me" && assignee !== "unassigned" && !UUID_PATTERN.test(assignee)) {
130
+ throw new Error('--assignee must be "me", "unassigned", or a person\'s uuid (see `cockpit team`).');
131
+ }
132
+ if (assignee === "unassigned" && action !== "list") {
133
+ throw new Error('--assignee unassigned filters `cockpit issue list`; to clear an assignee pass --assignee "" is not supported yet.');
134
+ }
135
+ const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
136
+ if (limit !== undefined && action !== "list" && action !== "history") {
137
+ throw new Error("--limit belongs to `cockpit issue list` or `cockpit issue history`.");
138
+ }
139
+ return {
140
+ kind: "issue",
141
+ action,
142
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
143
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
144
+ ...(issueRef ? { issueRef } : {}),
145
+ ...(moveState ? { moveState } : {}),
146
+ ...(stateFilter ? { stateFilter } : {}),
147
+ ...(assignee ? { assignee } : {}),
148
+ project: optionalNonEmpty(values.flags.get("--project")),
149
+ ...(limit === undefined ? {} : { limit }),
150
+ title,
151
+ ...(priority === undefined ? {} : { priority }),
152
+ parentRef: optionalNonEmpty(values.flags.get("--parent")),
153
+ filePath: optionalNonEmpty(values.flags.get("--file")),
154
+ bodyStdin: values.booleans.has("--body-stdin"),
155
+ json: values.booleans.has("--json"),
156
+ };
157
+ }
158
+ export function parseProjectArgs(args) {
159
+ const values = parseNamedArgs(args, {
160
+ allowedFlags: ["--home", "--dashboard-url", "--archived", "--json"],
161
+ valueFlags: ["--home", "--dashboard-url"],
162
+ });
163
+ const first = values.positionals[0];
164
+ const action = (first === undefined ? "list" : first);
165
+ if (action !== "list")
166
+ throw new Error(`Unknown project command: ${first}. The only verb is list.`);
167
+ const rest = values.positionals.slice(first === undefined ? 0 : 1);
168
+ if (rest.length > 0)
169
+ throw new Error(`project list does not take "${rest[0]}".`);
170
+ return {
171
+ kind: "project",
172
+ action,
173
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
174
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
175
+ includeArchived: values.booleans.has("--archived"),
176
+ json: values.booleans.has("--json"),
177
+ };
178
+ }
@@ -18,10 +18,13 @@
18
18
  * model
19
19
  * local-args-tower-docs-msg.ts docs, msg — the document library and
20
20
  * channels/messages (BLI-3706)
21
+ * local-args-tower-work.ts issue, project — the issue tracker
22
+ * (BLI-3716)
21
23
  *
22
24
  * Every name this module has ever exported is still importable from here.
23
25
  */
24
26
  export { parseJarvisArgs, parseCorrectArgs } from "./local-args-tower-chat.js";
25
27
  export { parseBriefArgs, WORKBOOK_MIN_WIDTH, parseWorkbookArgs, parseNotesArgs, } from "./local-args-tower-pages.js";
26
28
  export { SCOUT_MIN_PREFIX_LENGTH, parseScoutArgs, parseOpsArgs, SLACK_WORKSPACE_KEYS, parseSlackArgs, parseSettingsArgs, parseTeamArgs, parseModelArgs, } from "./local-args-tower-admin.js";
27
- export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
29
+ export { parseDocsArgs, parseMsgArgs, } from "./local-args-tower-docs-msg.js";
30
+ export { ISSUE_STATES, parseIssueArgs, parseProjectArgs, } from "./local-args-tower-work.js";
@@ -1,11 +1,11 @@
1
1
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
2
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
2
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseScoutArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, } from "./local-args-tower.js";
3
3
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
4
4
  // `local-auth.ts` import it from here — so it stays exported from this address
5
5
  // even though it now lives next door. The same goes for the four names the
6
6
  // Tower parsers publish.
7
7
  export { normalizeUrl } from "./local-arg-values.js";
8
- export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, } from "./local-args-tower.js";
8
+ export { SCOUT_MIN_PREFIX_LENGTH, SLACK_WORKSPACE_KEYS, WORKBOOK_MIN_WIDTH, ISSUE_STATES, } from "./local-args-tower.js";
9
9
  // The six human "set my machine up" doors. They are one thing wearing six
10
10
  // hats, so they all run the convergence command — but they keep accepting the
11
11
  // flags they always accepted, because DMs, runbooks and AGENTS.md rules across
@@ -88,6 +88,10 @@ export function parseLocalArgs(argv) {
88
88
  return parseDocsArgs(argv.slice(1));
89
89
  case "msg":
90
90
  return parseMsgArgs(argv.slice(1));
91
+ case "issue":
92
+ return parseIssueArgs(argv.slice(1));
93
+ case "project":
94
+ return parseProjectArgs(argv.slice(1));
91
95
  case "release":
92
96
  return parseReleaseArgs(argv.slice(1));
93
97
  default: