@bli-cockpit/cli 0.2.137 → 0.2.140
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/docs-read.js +197 -0
- package/dist/commands/docs.js +26 -32
- package/dist/commands/issue-directory.js +63 -0
- package/dist/commands/issue-show.js +147 -0
- package/dist/commands/issue.js +54 -71
- package/dist/commands/local-args-tower-admin.js +25 -0
- package/dist/commands/local-args-tower-docs-msg.js +95 -12
- package/dist/commands/local-args-tower-work.js +17 -5
- package/dist/commands/local-help-commands-tower.js +20 -14
- package/dist/commands/local-help-commands.js +14 -2
- package/dist/commands/local-help.js +3 -2
- package/dist/commands/local.js +8 -0
- package/dist/commands/mail.js +5 -1
- package/dist/commands/memory-store.js +11 -1
- package/dist/commands/msg.js +14 -3
- package/dist/commands/notes-reads.js +29 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/slack.js +59 -15
- package/dist/tower-client.js +29 -7
- package/package.json +3 -3
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { writeLine } from "./cli-io.js";
|
|
3
|
+
import { docsListPath, resolveDocIds } from "./docs.js";
|
|
4
|
+
const TAG = "[docs cli]";
|
|
5
|
+
const READ_DEADLINE_MS = 30_000;
|
|
6
|
+
function headingsOf(markdown) {
|
|
7
|
+
const lines = markdown.split("\n");
|
|
8
|
+
const headings = [];
|
|
9
|
+
lines.forEach((line, lineIndex) => {
|
|
10
|
+
const match = /^(#{1,6})\s+(.+?)\s*$/.exec(line);
|
|
11
|
+
if (match?.[1] && match[2])
|
|
12
|
+
headings.push({ lineIndex, level: match[1].length, title: match[2] });
|
|
13
|
+
});
|
|
14
|
+
return headings;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The Markdown from a heading matching `wanted` (case-insensitively, exact)
|
|
18
|
+
* to the next heading at the same or shallower level. `null` means no
|
|
19
|
+
* heading matched — a wrong slice reads as the whole answer, so a miss is a
|
|
20
|
+
* refusal, never an empty string.
|
|
21
|
+
*/
|
|
22
|
+
export function sliceDocSection(markdown, wanted) {
|
|
23
|
+
const headings = headingsOf(markdown);
|
|
24
|
+
const target = headings.find((heading) => heading.title.toLowerCase() === wanted.trim().toLowerCase());
|
|
25
|
+
if (!target)
|
|
26
|
+
return null;
|
|
27
|
+
const lines = markdown.split("\n");
|
|
28
|
+
const next = headings.find((heading) => heading.lineIndex > target.lineIndex && heading.level <= target.level);
|
|
29
|
+
const endLine = next ? next.lineIndex : lines.length;
|
|
30
|
+
return { text: lines.slice(target.lineIndex, endLine).join("\n").trim(), headings: headings.map((h) => h.title) };
|
|
31
|
+
}
|
|
32
|
+
/** A character range of the body — `offset` defaults to 0, `limit` to "the rest". */
|
|
33
|
+
export function sliceDocRange(body, offset, limit) {
|
|
34
|
+
const totalChars = body.length;
|
|
35
|
+
if (offset === undefined && limit === undefined)
|
|
36
|
+
return { text: body, truncated: false, totalChars };
|
|
37
|
+
const start = Math.min(offset ?? 0, totalChars);
|
|
38
|
+
const end = limit === undefined ? totalChars : Math.min(start + limit, totalChars);
|
|
39
|
+
return { text: body.slice(start, end), truncated: end < totalChars, totalChars };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Applies `--section` then, only when `applyCharRange` is true,
|
|
43
|
+
* `--offset`/`--limit` (as a CHARACTER range) to one document's body, in
|
|
44
|
+
* place. Returns a refusal reason when `--section` named a heading this
|
|
45
|
+
* document does not have — the only way this can fail.
|
|
46
|
+
*
|
|
47
|
+
* `applyCharRange` is false in `--parent --bodies` mode: there, `--limit`
|
|
48
|
+
* already means the DOCUMENT COUNT the door capped the list at (matching
|
|
49
|
+
* `list`'s own meaning) — applying it a second time as a character count
|
|
50
|
+
* here silently truncated every body to a handful of characters, a real bug
|
|
51
|
+
* a live-fire probe against production caught before this shipped (every
|
|
52
|
+
* body under a folder came back `""` once `--limit 3` meant "3 documents").
|
|
53
|
+
*/
|
|
54
|
+
function narrowBody(command, document, applyCharRange) {
|
|
55
|
+
let body = typeof document.body_markdown === "string" ? document.body_markdown : "";
|
|
56
|
+
if (command.section) {
|
|
57
|
+
const sliced = sliceDocSection(body, command.section);
|
|
58
|
+
if (!sliced) {
|
|
59
|
+
const known = headingsOf(body).map((heading) => heading.title);
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: "document_section_not_found",
|
|
63
|
+
detail: `No section "${command.section}" in ${document.id} (${known.join(", ") || "none"}).`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
body = sliced.text;
|
|
67
|
+
}
|
|
68
|
+
if (applyCharRange && (command.offset !== undefined || command.limit !== undefined)) {
|
|
69
|
+
body = sliceDocRange(body, command.offset, command.limit).text;
|
|
70
|
+
}
|
|
71
|
+
document.body_markdown = body;
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
async function fetchOneDocument(door, ref, id) {
|
|
75
|
+
if (!id) {
|
|
76
|
+
return {
|
|
77
|
+
ref,
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "document_not_found_or_unreadable",
|
|
80
|
+
detail: `That document does not exist, or you cannot read it: ${ref}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const answer = await askAgentDoor(door, {
|
|
84
|
+
path: `/api/docs/documents/${encodeURIComponent(id)}`,
|
|
85
|
+
method: "GET",
|
|
86
|
+
label: "docs read",
|
|
87
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
88
|
+
});
|
|
89
|
+
if (!answer.ok)
|
|
90
|
+
return { ref, ok: false, reason: answer.reason, detail: answer.detail };
|
|
91
|
+
const document = (answer.body.document ?? { id });
|
|
92
|
+
return { ref, ok: true, document };
|
|
93
|
+
}
|
|
94
|
+
function writeDocumentBlock(io, document) {
|
|
95
|
+
writeLine(io.stdout, String(document["title"] ?? ""));
|
|
96
|
+
writeLine(io.stdout, `${document["slug"] ?? "(no slug)"} · ${document["visibility"] ?? ""} · ${document["source"] ?? ""}`);
|
|
97
|
+
writeLine(io.stdout, "");
|
|
98
|
+
writeLine(io.stdout, String(document["body_markdown"] ?? ""));
|
|
99
|
+
}
|
|
100
|
+
/** `--parent <id> --bodies`: every document under that parent, bodies included, in one call. */
|
|
101
|
+
async function readParentBodies(command, door) {
|
|
102
|
+
const answer = await askAgentDoor(door, {
|
|
103
|
+
path: docsListPath(command),
|
|
104
|
+
method: "GET",
|
|
105
|
+
label: "docs read (parent bodies)",
|
|
106
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
107
|
+
});
|
|
108
|
+
if (!answer.ok)
|
|
109
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
110
|
+
const documents = (answer.body.documents ?? []);
|
|
111
|
+
const failures = [];
|
|
112
|
+
for (const document of documents) {
|
|
113
|
+
// `--limit` here already picked the DOCUMENT COUNT (docsListPath, above);
|
|
114
|
+
// `false` keeps it from being reapplied as a character count.
|
|
115
|
+
const narrowed = narrowBody(command, document, false);
|
|
116
|
+
if (!narrowed.ok)
|
|
117
|
+
failures.push({ ref: document.id, reason: narrowed.reason, detail: narrowed.detail });
|
|
118
|
+
}
|
|
119
|
+
writeLine(door.io.stderr, `${TAG} read parent bodies ${JSON.stringify({ parent_id: command.parentId, count: documents.length, section_misses: failures.length })}`);
|
|
120
|
+
if (door.json) {
|
|
121
|
+
return emitAgentDoor(door, { ok: failures.length === 0, documents, ...(failures.length > 0 ? { section_misses: failures } : {}) }, failures.length === 0 ? 0 : 1);
|
|
122
|
+
}
|
|
123
|
+
if (documents.length === 0) {
|
|
124
|
+
writeLine(door.io.stdout, `No documents under ${command.parentId}.`);
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
documents.forEach((document, index) => {
|
|
128
|
+
if (index > 0) {
|
|
129
|
+
writeLine(door.io.stdout, "");
|
|
130
|
+
writeLine(door.io.stdout, "---");
|
|
131
|
+
writeLine(door.io.stdout, "");
|
|
132
|
+
}
|
|
133
|
+
writeDocumentBlock(door.io, document);
|
|
134
|
+
});
|
|
135
|
+
writeLine(door.io.stdout, "");
|
|
136
|
+
writeLine(door.io.stdout, `${documents.length} document(s).`);
|
|
137
|
+
return failures.length === 0 ? 0 : 1;
|
|
138
|
+
}
|
|
139
|
+
export async function readDoc(command, door) {
|
|
140
|
+
if (command.parentId && command.bodies)
|
|
141
|
+
return readParentBodies(command, door);
|
|
142
|
+
const refs = command.docRefs && command.docRefs.length > 0 ? command.docRefs : [command.docRef ?? ""];
|
|
143
|
+
// BLI-10450: ONE shared `docs list` resolves every ref — a bulk read used
|
|
144
|
+
// to pay a resolve call per ref, on top of a read call per ref.
|
|
145
|
+
const resolved = await resolveDocIds(door, refs);
|
|
146
|
+
if (resolved.status === "list_failed")
|
|
147
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
148
|
+
const results = await Promise.all(refs.map((ref) => fetchOneDocument(door, ref, resolved.ids.get(ref))));
|
|
149
|
+
for (const result of results) {
|
|
150
|
+
if (result.ok) {
|
|
151
|
+
// `--limit` here means a CHARACTER count (paired with --offset) —
|
|
152
|
+
// this is not `--parent --bodies` mode, so it is applied.
|
|
153
|
+
const narrowed = narrowBody(command, result.document, true);
|
|
154
|
+
if (!narrowed.ok)
|
|
155
|
+
Object.assign(result, { ok: false, reason: narrowed.reason, detail: narrowed.detail });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// A single ref keeps the exact pre-BLI-10450 shape and failure path.
|
|
159
|
+
if (refs.length === 1) {
|
|
160
|
+
const [result] = results;
|
|
161
|
+
if (!result || !result.ok) {
|
|
162
|
+
return failAgentDoor(door, TAG, result?.reason ?? "document_not_found_or_unreadable", result?.detail ?? "Not found.");
|
|
163
|
+
}
|
|
164
|
+
if (door.json)
|
|
165
|
+
return emitAgentDoor(door, { ok: true, document: result.document });
|
|
166
|
+
writeDocumentBlock(door.io, result.document);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
// Several refs: `{ ok, documents: [{ ref, ok, document|error }] }` — one bad
|
|
170
|
+
// ref never loses the rest of the batch.
|
|
171
|
+
const failed = results.filter((result) => !result.ok);
|
|
172
|
+
if (door.json) {
|
|
173
|
+
return emitAgentDoor(door, {
|
|
174
|
+
ok: failed.length === 0,
|
|
175
|
+
documents: results.map((result) => result.ok
|
|
176
|
+
? { ref: result.ref, ok: true, document: result.document }
|
|
177
|
+
: { ref: result.ref, ok: false, error: result.reason, detail: result.detail }),
|
|
178
|
+
}, failed.length === 0 ? 0 : 1);
|
|
179
|
+
}
|
|
180
|
+
results.forEach((result, index) => {
|
|
181
|
+
if (index > 0) {
|
|
182
|
+
writeLine(door.io.stdout, "");
|
|
183
|
+
writeLine(door.io.stdout, "---");
|
|
184
|
+
writeLine(door.io.stdout, "");
|
|
185
|
+
}
|
|
186
|
+
if (result.ok) {
|
|
187
|
+
writeDocumentBlock(door.io, result.document);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ ref: result.ref, reason: result.reason })}`);
|
|
191
|
+
writeLine(door.io.stdout, `${result.ref}: ${result.detail}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
writeLine(door.io.stdout, "");
|
|
195
|
+
writeLine(door.io.stdout, `${results.length - failed.length}/${results.length} document(s) read.`);
|
|
196
|
+
return failed.length === 0 ? 0 : 1;
|
|
197
|
+
}
|
package/dist/commands/docs.js
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
31
31
|
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
32
|
+
import { readDoc } from "./docs-read.js";
|
|
32
33
|
import { linkDoc, syncStatusDocs } from "./docs-notion.js";
|
|
33
34
|
import { readNoteFile } from "./notes-file.js";
|
|
34
35
|
const TAG = "[docs cli]";
|
|
@@ -54,15 +55,19 @@ export async function runDocs(command, io) {
|
|
|
54
55
|
return syncStatusDocs(command, door);
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
|
-
/** `--parent`, `--query`, `--limit` as the door's own query parameters (BLI-3737). */
|
|
58
|
+
/** `--parent`, `--query`, `--word`, `--limit`, `--bodies` (BLI-10450) as the door's own query parameters (BLI-3737). */
|
|
58
59
|
export function docsListPath(command) {
|
|
59
60
|
const params = new URLSearchParams();
|
|
60
61
|
if (command.parentId)
|
|
61
62
|
params.set("parent_id", command.parentId);
|
|
62
63
|
if (command.query)
|
|
63
64
|
params.set("query", command.query);
|
|
65
|
+
if (command.query && command.wholeWord)
|
|
66
|
+
params.set("word", "1");
|
|
64
67
|
if (command.limit !== undefined)
|
|
65
68
|
params.set("limit", String(command.limit));
|
|
69
|
+
if (command.bodies)
|
|
70
|
+
params.set("bodies", "true");
|
|
66
71
|
const search = params.toString();
|
|
67
72
|
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
68
73
|
}
|
|
@@ -118,6 +123,18 @@ async function treeDocs(door) {
|
|
|
118
123
|
}
|
|
119
124
|
/** Finds a document id by exact id or exact slug against the flat list. Never fuzzy. */
|
|
120
125
|
export async function resolveDocId(door, ref) {
|
|
126
|
+
const resolved = await resolveDocIds(door, [ref]);
|
|
127
|
+
if (resolved.status === "list_failed")
|
|
128
|
+
return resolved;
|
|
129
|
+
const id = resolved.ids.get(ref);
|
|
130
|
+
return id ? { status: "ok", id } : { status: "not_found" };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* BLI-10450: `docs read <ref1> <ref2> ...` resolves every ref against ONE
|
|
134
|
+
* `docs list` call, not one per ref — `resolveDocId` above is now this
|
|
135
|
+
* function's single-ref case, so a bulk read pays one list call total.
|
|
136
|
+
*/
|
|
137
|
+
export async function resolveDocIds(door, refs) {
|
|
121
138
|
const answer = await askAgentDoor(door, {
|
|
122
139
|
path: "/api/docs/documents",
|
|
123
140
|
method: "GET",
|
|
@@ -127,38 +144,15 @@ export async function resolveDocId(door, ref) {
|
|
|
127
144
|
if (!answer.ok)
|
|
128
145
|
return { status: "list_failed", reason: answer.reason, detail: answer.detail };
|
|
129
146
|
const documents = (answer.body.documents ?? []);
|
|
130
|
-
const byId = documents.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
async function readDoc(command, door) {
|
|
139
|
-
const ref = command.docRef ?? "";
|
|
140
|
-
const resolved = await resolveDocId(door, ref);
|
|
141
|
-
if (resolved.status === "list_failed")
|
|
142
|
-
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
143
|
-
if (resolved.status === "not_found") {
|
|
144
|
-
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${ref}`);
|
|
147
|
+
const byId = new Map(documents.map((doc) => [doc.id, doc.id]));
|
|
148
|
+
const bySlug = new Map(documents.filter((doc) => doc.slug).map((doc) => [doc.slug, doc.id]));
|
|
149
|
+
const ids = new Map();
|
|
150
|
+
for (const ref of refs) {
|
|
151
|
+
const id = byId.get(ref) ?? bySlug.get(ref);
|
|
152
|
+
if (id)
|
|
153
|
+
ids.set(ref, id);
|
|
145
154
|
}
|
|
146
|
-
|
|
147
|
-
path: `/api/docs/documents/${encodeURIComponent(resolved.id)}`,
|
|
148
|
-
method: "GET",
|
|
149
|
-
label: "docs read",
|
|
150
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
151
|
-
});
|
|
152
|
-
if (!answer.ok)
|
|
153
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
154
|
-
const document = answer.body.document ?? {};
|
|
155
|
-
if (door.json)
|
|
156
|
-
return emitAgentDoor(door, { ok: true, document });
|
|
157
|
-
writeLine(door.io.stdout, String(document["title"] ?? ""));
|
|
158
|
-
writeLine(door.io.stdout, `${document["slug"] ?? "(no slug)"} · ${document["visibility"] ?? ""} · ${document["source"] ?? ""}`);
|
|
159
|
-
writeLine(door.io.stdout, "");
|
|
160
|
-
writeLine(door.io.stdout, String(document["body_markdown"] ?? ""));
|
|
161
|
-
return 0;
|
|
155
|
+
return { status: "ok", ids };
|
|
162
156
|
}
|
|
163
157
|
/** Body on stdin, never argv (BLI-3706) — `--file` is the safest way on Windows, same as `cockpit notes paste`. */
|
|
164
158
|
async function readBody(command, io) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-10450: `issue list --json` carried only `assignee_id` (a uuid) and
|
|
3
|
+
* `issue show` printed the same uuid — answering "who owns what" cost a
|
|
4
|
+
* second call to `cockpit team members` and a join done by hand.
|
|
5
|
+
*
|
|
6
|
+
* `resolveAssigneeDirectory` is that join, done once per command (never once
|
|
7
|
+
* per row): the same `/api/team/members` door `cockpit team members` already
|
|
8
|
+
* calls, reused rather than a new door minted for this.
|
|
9
|
+
*/
|
|
10
|
+
import { askAgentDoor } from "./agent-door.js";
|
|
11
|
+
import { writeLine } from "./cli-io.js";
|
|
12
|
+
const TEAM_DIRECTORY_TIMEOUT_MS = 30_000;
|
|
13
|
+
/**
|
|
14
|
+
* One `/api/team/members` call however many issues are in hand. Best-effort:
|
|
15
|
+
* a directory that cannot be read still lets the issue(s) print their bare
|
|
16
|
+
* `assignee_id`, the pre-BLI-10450 behaviour, rather than failing the whole
|
|
17
|
+
* read over an enrichment nobody asked to gate on.
|
|
18
|
+
*/
|
|
19
|
+
export async function resolveAssigneeDirectory(door, assigneeIds) {
|
|
20
|
+
const wanted = new Set(assigneeIds.filter((id) => Boolean(id)));
|
|
21
|
+
const directory = new Map();
|
|
22
|
+
if (wanted.size === 0)
|
|
23
|
+
return directory;
|
|
24
|
+
const answer = await askAgentDoor(door, {
|
|
25
|
+
path: "/api/team/members",
|
|
26
|
+
method: "GET",
|
|
27
|
+
label: "issue assignee directory",
|
|
28
|
+
timeoutMs: TEAM_DIRECTORY_TIMEOUT_MS,
|
|
29
|
+
});
|
|
30
|
+
if (!answer.ok) {
|
|
31
|
+
writeLine(door.io.stderr, `[issue cli] assignee directory unread ${JSON.stringify({ reason: answer.reason, wanted: wanted.size })}`);
|
|
32
|
+
return directory;
|
|
33
|
+
}
|
|
34
|
+
const people = answer.body.people ?? [];
|
|
35
|
+
let resolved = 0;
|
|
36
|
+
for (const person of people) {
|
|
37
|
+
const userId = person.account?.userId;
|
|
38
|
+
if (!userId || !wanted.has(userId))
|
|
39
|
+
continue;
|
|
40
|
+
directory.set(userId, { name: person.name ?? userId, email: person.email ?? null });
|
|
41
|
+
resolved += 1;
|
|
42
|
+
}
|
|
43
|
+
writeLine(door.io.stderr, `[issue cli] assignee directory resolved ${JSON.stringify({ wanted: wanted.size, resolved })}`);
|
|
44
|
+
return directory;
|
|
45
|
+
}
|
|
46
|
+
/** `assignee_name`/`assignee_email` alongside the bare id, for JSON output. */
|
|
47
|
+
export function withAssigneeIdentity(row, directory) {
|
|
48
|
+
const identity = row.assignee_id ? directory.get(row.assignee_id) : undefined;
|
|
49
|
+
return {
|
|
50
|
+
...row,
|
|
51
|
+
assignee_name: identity?.name ?? null,
|
|
52
|
+
assignee_email: identity?.email ?? null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** The text-output assignee phrase: a name (and email) when resolved, the bare id otherwise. */
|
|
56
|
+
export function assigneeLabel(assigneeId, directory) {
|
|
57
|
+
if (!assigneeId)
|
|
58
|
+
return "(nobody)";
|
|
59
|
+
const identity = directory.get(assigneeId);
|
|
60
|
+
if (!identity)
|
|
61
|
+
return assigneeId;
|
|
62
|
+
return identity.email ? `${identity.name} <${identity.email}>` : identity.name;
|
|
63
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit issue show <id> [<id>...]` (BLI-10450) — split out of `issue.ts`
|
|
3
|
+
* when `show` grew a second shape: several references read in one call
|
|
4
|
+
* (still one HTTP round trip per issue plus its comments; there is no bulk
|
|
5
|
+
* `/api/work/issues` body door), and the assignee's name/email alongside the
|
|
6
|
+
* bare `assignee_id` a uuid always was.
|
|
7
|
+
*
|
|
8
|
+
* A single ref keeps the EXACT pre-BLI-10450 JSON shape (`{ ok, issue,
|
|
9
|
+
* comments, ... }`) — nothing that already parses `docs_read`/`work_get_issue`
|
|
10
|
+
* output breaks. Several refs answer `{ ok, issues: [...] }`, one entry per
|
|
11
|
+
* ref, each carrying its own `ref` and either the issue or a `failed: true`
|
|
12
|
+
* reason — one bad id in a batch never loses the rest.
|
|
13
|
+
*/
|
|
14
|
+
import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
|
|
15
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
|
|
16
|
+
import { writeLine } from "./cli-io.js";
|
|
17
|
+
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
18
|
+
import { READ_DEADLINE_MS, TAG } from "./issue-contracts.js";
|
|
19
|
+
async function fetchOneIssue(door, ref) {
|
|
20
|
+
const answer = await askAgentDoor(door, {
|
|
21
|
+
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
22
|
+
method: "GET",
|
|
23
|
+
label: "issue show",
|
|
24
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
25
|
+
});
|
|
26
|
+
if (!answer.ok)
|
|
27
|
+
return { ref, ok: false, reason: answer.reason, detail: answer.detail };
|
|
28
|
+
const issue = answer.body.issue;
|
|
29
|
+
if (!issue) {
|
|
30
|
+
return {
|
|
31
|
+
ref,
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: "issue_not_found_or_unreadable",
|
|
34
|
+
detail: `That issue does not exist, or you cannot read it: ${ref}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
// A comment read that fails does NOT fail the whole `show` — the issue is
|
|
38
|
+
// already in hand, and a named gap beats losing what was read.
|
|
39
|
+
const commentsAnswer = await askAgentDoor(door, {
|
|
40
|
+
path: `/api/work/issues/${encodeURIComponent(issue.id)}/comments`,
|
|
41
|
+
method: "GET",
|
|
42
|
+
label: "issue show comments",
|
|
43
|
+
timeoutMs: READ_DEADLINE_MS,
|
|
44
|
+
});
|
|
45
|
+
const comments = commentsAnswer.ok ? (commentsAnswer.body.comments ?? []) : [];
|
|
46
|
+
if (!commentsAnswer.ok) {
|
|
47
|
+
writeLine(door.io.stderr, `${TAG} comments unread ${JSON.stringify({ reason: commentsAnswer.reason, issue_id: issue.id })}`);
|
|
48
|
+
}
|
|
49
|
+
// BLI-3779: during the dual-run a Tower-native row stands for a Linear
|
|
50
|
+
// ticket, and says so in its own title (`… [BLI-3779]`).
|
|
51
|
+
const linearTicketId = mirroredTicketIdInTitle(issue.title ?? "");
|
|
52
|
+
return {
|
|
53
|
+
ref,
|
|
54
|
+
ok: true,
|
|
55
|
+
issue,
|
|
56
|
+
comments,
|
|
57
|
+
...(linearTicketId ? { linearTicketId } : {}),
|
|
58
|
+
...(commentsAnswer.ok ? {} : { commentsUnreadReason: commentsAnswer.reason }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function renderOneIssue(door, result, directory) {
|
|
62
|
+
const { issue, comments, linearTicketId } = result;
|
|
63
|
+
writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
|
|
64
|
+
writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${assigneeLabel(issue.assignee_id, directory)} · updated ${issue.updated_at}`);
|
|
65
|
+
writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
|
|
66
|
+
if (linearTicketId)
|
|
67
|
+
writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
|
|
68
|
+
writeLine(door.io.stdout, "");
|
|
69
|
+
writeLine(door.io.stdout, issue.description ?? "(no description)");
|
|
70
|
+
if (comments.length > 0) {
|
|
71
|
+
writeLine(door.io.stdout, "");
|
|
72
|
+
writeLine(door.io.stdout, `--- ${comments.length} comment(s) ---`);
|
|
73
|
+
for (const comment of comments) {
|
|
74
|
+
writeLine(door.io.stdout, "");
|
|
75
|
+
writeLine(door.io.stdout, `[${comment.created_at}] ${comment.author_name ?? comment.author_id ?? "unknown"}`);
|
|
76
|
+
writeLine(door.io.stdout, comment.body_markdown);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (result.commentsUnreadReason === undefined) {
|
|
80
|
+
writeLine(door.io.stdout, "");
|
|
81
|
+
writeLine(door.io.stdout, "No comments.");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function showIssue(command, door) {
|
|
85
|
+
const refs = command.issueRefs && command.issueRefs.length > 0
|
|
86
|
+
? command.issueRefs
|
|
87
|
+
: [command.issueRef ?? ""];
|
|
88
|
+
const results = [];
|
|
89
|
+
for (const ref of refs)
|
|
90
|
+
results.push(await fetchOneIssue(door, ref));
|
|
91
|
+
// BLI-10450: one directory call for the whole batch, never one per issue.
|
|
92
|
+
const directory = await resolveAssigneeDirectory(door, results.filter((result) => result.ok).map((result) => result.issue.assignee_id));
|
|
93
|
+
// A single ref keeps the pre-BLI-10450 shape and failure path exactly.
|
|
94
|
+
if (refs.length === 1) {
|
|
95
|
+
const [result] = results;
|
|
96
|
+
if (!result || !result.ok) {
|
|
97
|
+
return failAgentDoor(door, TAG, result?.reason ?? "issue_not_found_or_unreadable", result?.detail ?? "That issue does not exist, or you cannot read it.");
|
|
98
|
+
}
|
|
99
|
+
if (door.json) {
|
|
100
|
+
return emitAgentDoor(door, {
|
|
101
|
+
ok: true,
|
|
102
|
+
issue: withAssigneeIdentity(result.issue, directory),
|
|
103
|
+
comments: result.comments,
|
|
104
|
+
...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
|
|
105
|
+
...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
renderOneIssue(door, result, directory);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
// Several refs: `{ ok, issues: [...] }`, one entry per ref in the order
|
|
112
|
+
// given, each carrying `ref` so a caller can match an answer back to what
|
|
113
|
+
// it asked for. One bad id never loses the rest of the batch.
|
|
114
|
+
const failed = results.filter((result) => !result.ok);
|
|
115
|
+
if (door.json) {
|
|
116
|
+
return emitAgentDoor(door, {
|
|
117
|
+
ok: failed.length === 0,
|
|
118
|
+
issues: results.map((result) => result.ok
|
|
119
|
+
? {
|
|
120
|
+
ref: result.ref,
|
|
121
|
+
ok: true,
|
|
122
|
+
issue: withAssigneeIdentity(result.issue, directory),
|
|
123
|
+
comments: result.comments,
|
|
124
|
+
...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
|
|
125
|
+
...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
|
|
126
|
+
}
|
|
127
|
+
: { ref: result.ref, ok: false, error: result.reason, detail: result.detail }),
|
|
128
|
+
}, failed.length === 0 ? 0 : 1);
|
|
129
|
+
}
|
|
130
|
+
results.forEach((result, index) => {
|
|
131
|
+
if (index > 0) {
|
|
132
|
+
writeLine(door.io.stdout, "");
|
|
133
|
+
writeLine(door.io.stdout, "---");
|
|
134
|
+
writeLine(door.io.stdout, "");
|
|
135
|
+
}
|
|
136
|
+
if (result.ok) {
|
|
137
|
+
renderOneIssue(door, result, directory);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
writeLine(door.io.stderr, `${TAG} refused ${JSON.stringify({ ref: result.ref, reason: result.reason })}`);
|
|
141
|
+
writeLine(door.io.stdout, `${result.ref}: ${result.detail}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
writeLine(door.io.stdout, "");
|
|
145
|
+
writeLine(door.io.stdout, `${results.length - failed.length}/${results.length} issue(s) read.`);
|
|
146
|
+
return failed.length === 0 ? 0 : 1;
|
|
147
|
+
}
|
package/dist/commands/issue.js
CHANGED
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
* (`agent-door.ts`) — `issue_not_found_or_unreadable`, `invalid_state`,
|
|
26
26
|
* `needs_rls_client`, `comment_too_long`, and so on.
|
|
27
27
|
*/
|
|
28
|
-
import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
|
|
29
28
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor, } from "./agent-door.js";
|
|
30
29
|
import { writeLine } from "./cli-io.js";
|
|
30
|
+
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
31
|
+
import { showIssue } from "./issue-show.js";
|
|
31
32
|
import { READ_DEADLINE_MS, TAG, resolveProjectId, } from "./issue-contracts.js";
|
|
32
33
|
import { commentIssue, createIssue, moveIssue, updateIssue } from "./issue-write.js";
|
|
33
34
|
export async function runIssue(command, io) {
|
|
@@ -49,8 +50,40 @@ export async function runIssue(command, io) {
|
|
|
49
50
|
return issueHistory(command, door);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
function issueLine(issue) {
|
|
53
|
-
|
|
53
|
+
function issueLine(issue, directory) {
|
|
54
|
+
const assignee = issue.assignee_id ? ` [${assigneeLabel(issue.assignee_id, directory)}]` : "";
|
|
55
|
+
return `${issue.identifier.padEnd(9)} ${issue.state.padEnd(11)} ${issue.title}${assignee}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* What the list did not show (BLI-10447): a capped window of a larger set,
|
|
59
|
+
* and sub-issues of the listed issues that live outside the named project.
|
|
60
|
+
* A door too old to say anything is judged by whether the read filled its limit.
|
|
61
|
+
*/
|
|
62
|
+
export function listGapLines(body, requestedLimit) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
const returned = body.issues?.length ?? 0;
|
|
65
|
+
const completeness = body.completeness;
|
|
66
|
+
if (completeness?.status === "partial") {
|
|
67
|
+
lines.push(`${TAG} partial list: ${completeness.returned} of ${completeness.total} matching issues; narrow the filter or pass --limit (at most 1000).`);
|
|
68
|
+
}
|
|
69
|
+
else if (completeness?.status === "unknown") {
|
|
70
|
+
lines.push(`${TAG} list may be partial: ${completeness.returned} issues filled the limit and Tower could not count the rest.`);
|
|
71
|
+
}
|
|
72
|
+
else if (!completeness && returned >= requestedLimit) {
|
|
73
|
+
lines.push(`${TAG} list may be partial: ${returned} issues filled the limit and this Tower did not say whether more exist.`);
|
|
74
|
+
}
|
|
75
|
+
const outside = body.outside_project;
|
|
76
|
+
if (outside && outside.issues.length > 0) {
|
|
77
|
+
lines.push(`${TAG} ${outside.issues.length} sub-issue(s) of these issues sit outside the project:`);
|
|
78
|
+
for (const issue of outside.issues) {
|
|
79
|
+
lines.push(` ${issue.identifier} (under ${issue.parent_identifier ?? "?"}, ${issue.project_id ? `project ${issue.project_id}` : "no project"}) ${issue.title}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (outside?.status === "capped")
|
|
83
|
+
lines.push(`${TAG} the sub-issue check was capped; more may sit outside the project.`);
|
|
84
|
+
if (outside?.status === "failed")
|
|
85
|
+
lines.push(`${TAG} could not check for sub-issues outside the project (read_failed).`);
|
|
86
|
+
return lines;
|
|
54
87
|
}
|
|
55
88
|
async function listIssues(command, door) {
|
|
56
89
|
const query = new URLSearchParams();
|
|
@@ -78,83 +111,33 @@ async function listIssues(command, door) {
|
|
|
78
111
|
});
|
|
79
112
|
if (!answer.ok)
|
|
80
113
|
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
114
|
+
const body = answer.body;
|
|
115
|
+
const issues = body.issues ?? [];
|
|
116
|
+
// BLI-10447: the gaps go to stderr in both modes, so `--json` stdout stays one object.
|
|
117
|
+
for (const line of listGapLines(body, command.limit ?? 1000))
|
|
118
|
+
writeLine(door.io.stderr, line);
|
|
119
|
+
// BLI-10450: one extra call for the whole list, never one per row — the
|
|
120
|
+
// uuid `assignee_id` alone forced a second `cockpit team members` call and
|
|
121
|
+
// a join done by hand to answer "who owns what".
|
|
122
|
+
const directory = await resolveAssigneeDirectory(door, issues.map((issue) => issue.assignee_id));
|
|
123
|
+
if (door.json) {
|
|
124
|
+
return emitAgentDoor(door, {
|
|
125
|
+
ok: true,
|
|
126
|
+
issues: issues.map((issue) => withAssigneeIdentity(issue, directory)),
|
|
127
|
+
...(body.completeness ? { completeness: body.completeness } : {}),
|
|
128
|
+
...(body.outside_project ? { outside_project: body.outside_project } : {}),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
84
131
|
if (issues.length === 0) {
|
|
85
132
|
writeLine(door.io.stdout, "No issues match that.");
|
|
86
133
|
return 0;
|
|
87
134
|
}
|
|
88
135
|
for (const issue of issues)
|
|
89
|
-
writeLine(door.io.stdout, issueLine(issue));
|
|
136
|
+
writeLine(door.io.stdout, issueLine(issue, directory));
|
|
90
137
|
writeLine(door.io.stdout, "");
|
|
91
138
|
writeLine(door.io.stdout, `${issues.length} issue(s).`);
|
|
92
139
|
return 0;
|
|
93
140
|
}
|
|
94
|
-
async function showIssue(command, door) {
|
|
95
|
-
const ref = command.issueRef ?? "";
|
|
96
|
-
const answer = await askAgentDoor(door, {
|
|
97
|
-
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
98
|
-
method: "GET",
|
|
99
|
-
label: "issue show",
|
|
100
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
101
|
-
});
|
|
102
|
-
if (!answer.ok)
|
|
103
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
104
|
-
const issue = answer.body.issue;
|
|
105
|
-
if (!issue) {
|
|
106
|
-
return failAgentDoor(door, TAG, "issue_not_found_or_unreadable", `That issue does not exist, or you cannot read it: ${ref}`);
|
|
107
|
-
}
|
|
108
|
-
// A comment read that fails does NOT fail the whole `show` — the issue is
|
|
109
|
-
// already in hand, and a named gap beats losing what was read.
|
|
110
|
-
const commentsAnswer = await askAgentDoor(door, {
|
|
111
|
-
path: `/api/work/issues/${encodeURIComponent(issue.id)}/comments`,
|
|
112
|
-
method: "GET",
|
|
113
|
-
label: "issue show comments",
|
|
114
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
115
|
-
});
|
|
116
|
-
const comments = commentsAnswer.ok
|
|
117
|
-
? (commentsAnswer.body.comments ?? [])
|
|
118
|
-
: [];
|
|
119
|
-
if (!commentsAnswer.ok) {
|
|
120
|
-
writeLine(door.io.stderr, `${TAG} comments unread ${JSON.stringify({ reason: commentsAnswer.reason, issue_id: issue.id })}`);
|
|
121
|
-
}
|
|
122
|
-
// BLI-3779: during the dual-run a Tower-native row stands for a Linear
|
|
123
|
-
// ticket, and says so in its own title (`… [BLI-3779]`). Read it out rather
|
|
124
|
-
// than making every caller parse the title — `cockpit start --ticket` binds
|
|
125
|
-
// a session to both ids, and this is where a person checks which they are.
|
|
126
|
-
const linearTicketId = mirroredTicketIdInTitle(issue.title ?? "");
|
|
127
|
-
if (door.json) {
|
|
128
|
-
return emitAgentDoor(door, {
|
|
129
|
-
ok: true,
|
|
130
|
-
issue,
|
|
131
|
-
comments,
|
|
132
|
-
...(linearTicketId ? { linear_ticket_id: linearTicketId } : {}),
|
|
133
|
-
...(commentsAnswer.ok ? {} : { comments_unread_reason: commentsAnswer.reason }),
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
writeLine(door.io.stdout, `${issue.identifier} ${issue.title}`);
|
|
137
|
-
writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0} · assignee ${issue.assignee_id ?? "(nobody)"} · updated ${issue.updated_at}`);
|
|
138
|
-
writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
|
|
139
|
-
if (linearTicketId)
|
|
140
|
-
writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
|
|
141
|
-
writeLine(door.io.stdout, "");
|
|
142
|
-
writeLine(door.io.stdout, issue.description ?? "(no description)");
|
|
143
|
-
if (comments.length > 0) {
|
|
144
|
-
writeLine(door.io.stdout, "");
|
|
145
|
-
writeLine(door.io.stdout, `--- ${comments.length} comment(s) ---`);
|
|
146
|
-
for (const comment of comments) {
|
|
147
|
-
writeLine(door.io.stdout, "");
|
|
148
|
-
writeLine(door.io.stdout, `[${comment.created_at}] ${comment.author_name ?? comment.author_id ?? "unknown"}`);
|
|
149
|
-
writeLine(door.io.stdout, comment.body_markdown);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
else if (commentsAnswer.ok) {
|
|
153
|
-
writeLine(door.io.stdout, "");
|
|
154
|
-
writeLine(door.io.stdout, "No comments.");
|
|
155
|
-
}
|
|
156
|
-
return 0;
|
|
157
|
-
}
|
|
158
141
|
/**
|
|
159
142
|
* What a history row actually says. A row imported from Linear's issue
|
|
160
143
|
* TIMESTAMPS (created/started/completed/canceled) carries no state names at
|