@bli-cockpit/cli 0.2.135 → 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-notion.js +90 -0
- package/dist/commands/docs-read.js +197 -0
- package/dist/commands/docs.js +32 -33
- 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 +130 -13
- package/dist/commands/local-args-tower-work.js +17 -5
- package/dist/commands/local-help-commands-tower.js +22 -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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit docs link` and `cockpit docs sync-status` — the Notion half of the
|
|
3
|
+
* document library (BLI-10430). A sibling of `docs.ts` rather than more of it:
|
|
4
|
+
* that file is the five library verbs, and this one is the two-way sync's.
|
|
5
|
+
*
|
|
6
|
+
* `link <id|slug> <notion-url|page-id>` → `PUT /api/docs/documents/[id]/notion-link`
|
|
7
|
+
* `link <id|slug> --clear` → `DELETE` on the same door
|
|
8
|
+
* `sync-status [<id|slug>]` → `GET /api/docs/notion-sync`
|
|
9
|
+
*
|
|
10
|
+
* The door folds any Notion URL or id to the page id the importer stores, and
|
|
11
|
+
* refuses by name: `invalid_notion_page`, `notion_page_already_linked`,
|
|
12
|
+
* `document_not_writable`. A link means the page is the truth: the next sync
|
|
13
|
+
* pulls it over the Tower copy and keeps the copy as a version
|
|
14
|
+
* (`docs/architecture/docs.md`, "Two-way sync with Notion").
|
|
15
|
+
*/
|
|
16
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
17
|
+
import { writeLine } from "./cli-io.js";
|
|
18
|
+
import { resolveDocId } from "./docs.js";
|
|
19
|
+
const TAG = "[docs cli]";
|
|
20
|
+
const READ_DEADLINE_MS = 30_000;
|
|
21
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
22
|
+
/**
|
|
23
|
+
* `sync-status [<id|slug>]` → `GET /api/docs/notion-sync[?document_id=]`. Tower's
|
|
24
|
+
* half of the answer only (synced / tower_changed / never_synced); the door
|
|
25
|
+
* never claims anything about Notion it has not read.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncStatusDocs(command, door) {
|
|
28
|
+
let path = "/api/docs/notion-sync";
|
|
29
|
+
if (command.docRef) {
|
|
30
|
+
const resolved = await resolveDocId(door, command.docRef);
|
|
31
|
+
if (resolved.status === "list_failed")
|
|
32
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
33
|
+
if (resolved.status === "not_found") {
|
|
34
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot read it: ${command.docRef}`);
|
|
35
|
+
}
|
|
36
|
+
path = `${path}?document_id=${encodeURIComponent(resolved.id)}`;
|
|
37
|
+
}
|
|
38
|
+
const answer = await askAgentDoor(door, { path, method: "GET", label: "docs sync-status", timeoutMs: READ_DEADLINE_MS });
|
|
39
|
+
if (!answer.ok)
|
|
40
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
41
|
+
const body = answer.body;
|
|
42
|
+
if (door.json)
|
|
43
|
+
return emitAgentDoor(door, { ok: true, counts: body.counts, documents: body.documents ?? [] });
|
|
44
|
+
const documents = body.documents ?? [];
|
|
45
|
+
for (const doc of documents) {
|
|
46
|
+
writeLine(door.io.stdout, `${doc.id} ${doc.state.padEnd(13)} ${(doc.notion_synced_at ?? "never").padEnd(25)} ${doc.slug ?? "(no slug)"} ${doc.title}`);
|
|
47
|
+
}
|
|
48
|
+
const byState = body.counts?.by_state ?? {};
|
|
49
|
+
writeLine(door.io.stdout, "");
|
|
50
|
+
writeLine(door.io.stdout, `${body.counts?.linked ?? documents.length} linked: ${Object.entries(byState)
|
|
51
|
+
.map(([state, count]) => `${count} ${state}`)
|
|
52
|
+
.join(", ")}. A Notion edit shows up at the next pull, not here.`);
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
export async function linkDoc(command, door) {
|
|
56
|
+
const ref = command.docRef ?? "";
|
|
57
|
+
const resolved = await resolveDocId(door, ref);
|
|
58
|
+
if (resolved.status === "list_failed")
|
|
59
|
+
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
60
|
+
if (resolved.status === "not_found") {
|
|
61
|
+
return failAgentDoor(door, TAG, "document_not_found_or_unreadable", `That document does not exist, or you cannot write to it: ${ref}`);
|
|
62
|
+
}
|
|
63
|
+
const path = `/api/docs/documents/${encodeURIComponent(resolved.id)}/notion-link`;
|
|
64
|
+
const answer = command.clearLink
|
|
65
|
+
? await askAgentDoor(door, { path, method: "DELETE", label: "docs link", timeoutMs: WRITE_DEADLINE_MS })
|
|
66
|
+
: await askAgentDoor(door, {
|
|
67
|
+
path,
|
|
68
|
+
method: "PUT",
|
|
69
|
+
label: "docs link",
|
|
70
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
71
|
+
body: { notion_page: command.notionPage ?? "" },
|
|
72
|
+
});
|
|
73
|
+
if (!answer.ok)
|
|
74
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
75
|
+
const result = answer.body;
|
|
76
|
+
const document = result.document ?? {};
|
|
77
|
+
writeLine(door.io.stderr, `${TAG} link ${JSON.stringify({ document_id: document["id"] ?? resolved.id, outcome: result.outcome ?? null })}`);
|
|
78
|
+
if (door.json)
|
|
79
|
+
return emitAgentDoor(door, { ok: true, outcome: result.outcome, document });
|
|
80
|
+
const title = String(document["title"] ?? "");
|
|
81
|
+
const page = document["notion_page_id"];
|
|
82
|
+
const sentence = {
|
|
83
|
+
linked: `Linked ${title} to Notion page ${String(page)}. The next sync pulls the page over this copy and keeps the copy as a version.`,
|
|
84
|
+
already_linked: `${title} was already linked to Notion page ${String(page)}. Nothing changed.`,
|
|
85
|
+
unlinked: `Unlinked ${title} from Notion. It is a Tower-only document now.`,
|
|
86
|
+
already_unlinked: `${title} was not linked to Notion. Nothing changed.`,
|
|
87
|
+
};
|
|
88
|
+
writeLine(door.io.stdout, sentence[result.outcome ?? ""] ?? `Done: ${title}.`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
@@ -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,8 @@
|
|
|
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";
|
|
33
|
+
import { linkDoc, syncStatusDocs } from "./docs-notion.js";
|
|
32
34
|
import { readNoteFile } from "./notes-file.js";
|
|
33
35
|
const TAG = "[docs cli]";
|
|
34
36
|
const READ_DEADLINE_MS = 30_000;
|
|
@@ -47,17 +49,25 @@ export async function runDocs(command, io) {
|
|
|
47
49
|
return createDoc(command, door);
|
|
48
50
|
case "update":
|
|
49
51
|
return updateDoc(command, door);
|
|
52
|
+
case "link":
|
|
53
|
+
return linkDoc(command, door);
|
|
54
|
+
case "sync-status":
|
|
55
|
+
return syncStatusDocs(command, door);
|
|
50
56
|
}
|
|
51
57
|
}
|
|
52
|
-
/** `--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). */
|
|
53
59
|
export function docsListPath(command) {
|
|
54
60
|
const params = new URLSearchParams();
|
|
55
61
|
if (command.parentId)
|
|
56
62
|
params.set("parent_id", command.parentId);
|
|
57
63
|
if (command.query)
|
|
58
64
|
params.set("query", command.query);
|
|
65
|
+
if (command.query && command.wholeWord)
|
|
66
|
+
params.set("word", "1");
|
|
59
67
|
if (command.limit !== undefined)
|
|
60
68
|
params.set("limit", String(command.limit));
|
|
69
|
+
if (command.bodies)
|
|
70
|
+
params.set("bodies", "true");
|
|
61
71
|
const search = params.toString();
|
|
62
72
|
return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
|
|
63
73
|
}
|
|
@@ -112,7 +122,19 @@ async function treeDocs(door) {
|
|
|
112
122
|
return 0;
|
|
113
123
|
}
|
|
114
124
|
/** Finds a document id by exact id or exact slug against the flat list. Never fuzzy. */
|
|
115
|
-
async function resolveDocId(door, ref) {
|
|
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) {
|
|
116
138
|
const answer = await askAgentDoor(door, {
|
|
117
139
|
path: "/api/docs/documents",
|
|
118
140
|
method: "GET",
|
|
@@ -122,38 +144,15 @@ async function resolveDocId(door, ref) {
|
|
|
122
144
|
if (!answer.ok)
|
|
123
145
|
return { status: "list_failed", reason: answer.reason, detail: answer.detail };
|
|
124
146
|
const documents = (answer.body.documents ?? []);
|
|
125
|
-
const byId = documents.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
async function readDoc(command, door) {
|
|
134
|
-
const ref = command.docRef ?? "";
|
|
135
|
-
const resolved = await resolveDocId(door, ref);
|
|
136
|
-
if (resolved.status === "list_failed")
|
|
137
|
-
return failAgentDoor(door, TAG, resolved.reason, resolved.detail);
|
|
138
|
-
if (resolved.status === "not_found") {
|
|
139
|
-
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);
|
|
140
154
|
}
|
|
141
|
-
|
|
142
|
-
path: `/api/docs/documents/${encodeURIComponent(resolved.id)}`,
|
|
143
|
-
method: "GET",
|
|
144
|
-
label: "docs read",
|
|
145
|
-
timeoutMs: READ_DEADLINE_MS,
|
|
146
|
-
});
|
|
147
|
-
if (!answer.ok)
|
|
148
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
149
|
-
const document = answer.body.document ?? {};
|
|
150
|
-
if (door.json)
|
|
151
|
-
return emitAgentDoor(door, { ok: true, document });
|
|
152
|
-
writeLine(door.io.stdout, String(document["title"] ?? ""));
|
|
153
|
-
writeLine(door.io.stdout, `${document["slug"] ?? "(no slug)"} · ${document["visibility"] ?? ""} · ${document["source"] ?? ""}`);
|
|
154
|
-
writeLine(door.io.stdout, "");
|
|
155
|
-
writeLine(door.io.stdout, String(document["body_markdown"] ?? ""));
|
|
156
|
-
return 0;
|
|
155
|
+
return { status: "ok", ids };
|
|
157
156
|
}
|
|
158
157
|
/** Body on stdin, never argv (BLI-3706) — `--file` is the safest way on Windows, same as `cockpit notes paste`. */
|
|
159
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
|
+
}
|