@uic-coe-connect/cli 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/reports.d.ts +1 -0
- package/dist/commands/reports.js +278 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function registerReportCommands(): void;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { CliError } from "../client.js";
|
|
4
|
+
import { details, emit, info, table } from "../output.js";
|
|
5
|
+
import { register } from "../registry.js";
|
|
6
|
+
/**
|
|
7
|
+
* Bug reports and feature requests from the terminal.
|
|
8
|
+
*
|
|
9
|
+
* The point of this surface: a maintainer (or an AI agent working on their
|
|
10
|
+
* behalf) fixing a bug is already in a terminal, and the report is the thing
|
|
11
|
+
* that says what to fix and who is waiting on it. Closing the loop shouldn't
|
|
12
|
+
* mean going to find a browser tab.
|
|
13
|
+
*
|
|
14
|
+
* Permissions are the API's, unchanged: you see reports you filed and reports
|
|
15
|
+
* on apps you help run, and only the latter lets you set a status.
|
|
16
|
+
*/
|
|
17
|
+
const STATUSES = ["open", "planned", "in-progress", "done", "declined"];
|
|
18
|
+
const CLOSED = ["done", "declined"];
|
|
19
|
+
function humanSize(bytes) {
|
|
20
|
+
if (bytes < 1024)
|
|
21
|
+
return `${bytes} B`;
|
|
22
|
+
if (bytes < 1024 * 1024)
|
|
23
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
24
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
25
|
+
}
|
|
26
|
+
/** Every file on the thread, wherever it hangs — the whole list is what you search. */
|
|
27
|
+
function allAttachments(report) {
|
|
28
|
+
return [...(report.attachments ?? []), ...(report.comments ?? []).flatMap((c) => c.attachments ?? [])];
|
|
29
|
+
}
|
|
30
|
+
/** Match an attachment by id or by filename, the way `files download` matches. */
|
|
31
|
+
function findAttachment(report, ref) {
|
|
32
|
+
const all = allAttachments(report);
|
|
33
|
+
const byId = all.find((a) => a.id === ref);
|
|
34
|
+
if (byId)
|
|
35
|
+
return byId;
|
|
36
|
+
const matches = all.filter((a) => a.name.toLowerCase().includes(ref.toLowerCase()));
|
|
37
|
+
if (matches.length === 1)
|
|
38
|
+
return matches[0];
|
|
39
|
+
if (matches.length > 1) {
|
|
40
|
+
throw new CliError(`"${ref}" matches ${matches.length} attachments: ${matches.map((a) => a.name).join(", ")}`, 4, "Use the exact attachment id.");
|
|
41
|
+
}
|
|
42
|
+
throw new CliError(`No attachment "${ref}" on that report.`, 4, all.length === 0 ? "It has no attachments." : `It has: ${all.map((a) => a.name).join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
function assertStatus(value) {
|
|
45
|
+
if (!STATUSES.includes(value)) {
|
|
46
|
+
throw new CliError(`Unknown status "${value}".`, 6, `Use one of: ${STATUSES.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function day(iso) {
|
|
51
|
+
return iso.slice(0, 10);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve an app reference for filing.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately not apps.ts's resolveApp: that one reads /registered-apps,
|
|
57
|
+
* which lists only apps you can *manage*. Anyone may file a report about an
|
|
58
|
+
* app they merely use, so a shared resolver would refuse exactly the people
|
|
59
|
+
* this command exists for. /reports/targets is the filing-side list, and it's
|
|
60
|
+
* the same list the server checks the POST against.
|
|
61
|
+
*/
|
|
62
|
+
async function resolveTarget(client, ref) {
|
|
63
|
+
// The portal itself has no registry entry; its reports carry no appId.
|
|
64
|
+
if (ref === "coeconnect")
|
|
65
|
+
return null;
|
|
66
|
+
const { targets, attributes } = await client.request("GET", "/reports/targets");
|
|
67
|
+
const exact = targets.find((t) => t.id === ref);
|
|
68
|
+
if (exact)
|
|
69
|
+
return exact.id;
|
|
70
|
+
const needle = ref.toLowerCase();
|
|
71
|
+
const matches = targets.filter((t) => t.name.toLowerCase().includes(needle));
|
|
72
|
+
if (matches.length === 1)
|
|
73
|
+
return matches[0].id;
|
|
74
|
+
if (matches.length > 1) {
|
|
75
|
+
throw new CliError(`"${ref}" matches ${matches.length} apps: ${matches.map((t) => t.id).join(", ")}`, 4, "Use the exact app id.");
|
|
76
|
+
}
|
|
77
|
+
// A terminal session authenticates by NetID and carries no SAML attributes,
|
|
78
|
+
// so apps that grant access by attribute rule can't appear in this list.
|
|
79
|
+
// Saying so beats a flat "no such app" the reporter can't act on.
|
|
80
|
+
throw new CliError(`No app "${ref}" that you have access to.`, 4, attributes
|
|
81
|
+
? "Run `coe reports file coeconnect …` to report a problem with the portal itself."
|
|
82
|
+
: "This session can't evaluate attribute-based access, so apps granted that way " +
|
|
83
|
+
"aren't listed — file those from the COEConnect web UI.");
|
|
84
|
+
}
|
|
85
|
+
export function registerReportCommands() {
|
|
86
|
+
register({
|
|
87
|
+
name: "reports list",
|
|
88
|
+
summary: "List bug reports and feature requests",
|
|
89
|
+
usage: "reports list [app] [--all] [--mine]",
|
|
90
|
+
details: [
|
|
91
|
+
"Shows reports on apps you help run, plus any you filed yourself.",
|
|
92
|
+
"",
|
|
93
|
+
" [app] Limit to one app (id, name or URL)",
|
|
94
|
+
" --all Include closed reports (done / won't do)",
|
|
95
|
+
" --mine Only reports you filed",
|
|
96
|
+
],
|
|
97
|
+
async run({ args, client }) {
|
|
98
|
+
const { reports } = await client().request("GET", "/reports");
|
|
99
|
+
// Matched against what came back rather than resolved through
|
|
100
|
+
// /registered-apps: that endpoint is manage-scoped, and someone who
|
|
101
|
+
// only files reports manages nothing.
|
|
102
|
+
const ref = args.positional[0]?.toLowerCase();
|
|
103
|
+
const shown = reports.filter((r) => {
|
|
104
|
+
if (ref &&
|
|
105
|
+
r.appId !== args.positional[0] &&
|
|
106
|
+
!(r.appName ?? "").toLowerCase().includes(ref)) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (args.bool("mine") && r.reason !== "reporter")
|
|
110
|
+
return false;
|
|
111
|
+
if (!args.bool("all") && CLOSED.includes(r.status))
|
|
112
|
+
return false;
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
emit({ reports: shown }, () => {
|
|
116
|
+
table(shown.map((r) => ({
|
|
117
|
+
id: r.id,
|
|
118
|
+
status: r.status,
|
|
119
|
+
kind: r.kind === "bug" ? "bug" : "request",
|
|
120
|
+
app: r.appName ?? r.appId ?? "COEConnect",
|
|
121
|
+
title: r.title.length > 60 ? `${r.title.slice(0, 57)}…` : r.title,
|
|
122
|
+
from: r.reporterNetid,
|
|
123
|
+
filed: day(r.createdAt),
|
|
124
|
+
})), ["id", "status", "kind", "app", "title", "from", "filed"]);
|
|
125
|
+
if (shown.length > 0 && !args.bool("all")) {
|
|
126
|
+
info("");
|
|
127
|
+
info("Closed reports are hidden — pass --all to include them.");
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
}, {
|
|
132
|
+
name: "reports show",
|
|
133
|
+
summary: "Show one report and its whole thread",
|
|
134
|
+
usage: "reports show <id>",
|
|
135
|
+
async run({ args, client }) {
|
|
136
|
+
const id = args.arg(0, "id");
|
|
137
|
+
const data = await client().request("GET", `/reports/${id}`);
|
|
138
|
+
emit(data, () => {
|
|
139
|
+
const r = data.report;
|
|
140
|
+
details([
|
|
141
|
+
["Title", r.title],
|
|
142
|
+
["App", data.appName],
|
|
143
|
+
["Kind", r.kind === "bug" ? "Bug report" : "Feature request"],
|
|
144
|
+
["Status", r.status],
|
|
145
|
+
["From", `${r.reporterName ?? r.reporterNetid} (${r.reporterNetid})`],
|
|
146
|
+
["Filed", r.createdAt],
|
|
147
|
+
...(r.pageUrl ? [["Page", r.pageUrl]] : []),
|
|
148
|
+
]);
|
|
149
|
+
process.stdout.write(`\n${r.body}\n`);
|
|
150
|
+
for (const a of r.attachments ?? []) {
|
|
151
|
+
process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
|
|
152
|
+
}
|
|
153
|
+
for (const comment of r.comments ?? []) {
|
|
154
|
+
const change = comment.statusTo ? ` [→ ${comment.statusTo}]` : "";
|
|
155
|
+
process.stdout.write(`\n--- ${comment.authorName ?? comment.authorNetid} · ${comment.createdAt}${change}\n` +
|
|
156
|
+
(comment.body ? `${comment.body}\n` : ""));
|
|
157
|
+
for (const a of comment.attachments ?? []) {
|
|
158
|
+
process.stdout.write(` [attachment] ${a.name} (${humanSize(a.size)}) ${a.id}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const files = allAttachments(r);
|
|
162
|
+
if (files.length > 0) {
|
|
163
|
+
info("");
|
|
164
|
+
info(`${files.length} attachment(s) — fetch one with: coe reports download ${r.id} <name>`);
|
|
165
|
+
}
|
|
166
|
+
if (!data.canRespond) {
|
|
167
|
+
info("");
|
|
168
|
+
info("You can reply, but only the people who run this app can set its status.");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
}, {
|
|
173
|
+
name: "reports reply",
|
|
174
|
+
summary: "Reply on a report, and optionally set its status",
|
|
175
|
+
usage: "reports reply <id> <message> [--status <status>]",
|
|
176
|
+
details: [
|
|
177
|
+
` --status One of: ${STATUSES.join(", ")}`,
|
|
178
|
+
"",
|
|
179
|
+
"The reply and the status change land as one entry, so the explanation",
|
|
180
|
+
"and the change can't be read out of order. The other side is emailed.",
|
|
181
|
+
],
|
|
182
|
+
async run({ args, client }) {
|
|
183
|
+
const id = args.arg(0, "id");
|
|
184
|
+
const message = args.arg(1, "message");
|
|
185
|
+
const status = args.flag("status") ? assertStatus(args.flag("status")) : undefined;
|
|
186
|
+
const { report } = await client().request("POST", `/reports/${id}/comments`, { body: message, status });
|
|
187
|
+
emit({ report }, () => info(`✓ Replied on ${report.id}${status ? ` — now ${report.status}` : ""}`));
|
|
188
|
+
},
|
|
189
|
+
}, {
|
|
190
|
+
name: "reports status",
|
|
191
|
+
summary: "Set a report's status",
|
|
192
|
+
usage: "reports status <id> <status> [--note <message>]",
|
|
193
|
+
details: [
|
|
194
|
+
` <status> One of: ${STATUSES.join(", ")}`,
|
|
195
|
+
" --note A message to send with the change (recommended for done / declined)",
|
|
196
|
+
],
|
|
197
|
+
async run({ args, client }) {
|
|
198
|
+
const id = args.arg(0, "id");
|
|
199
|
+
const status = assertStatus(args.arg(1, "status"));
|
|
200
|
+
const { report } = await client().request("POST", `/reports/${id}/comments`, { body: args.flag("note") ?? "", status });
|
|
201
|
+
emit({ report }, () => info(`✓ ${report.id} is now ${report.status}`));
|
|
202
|
+
},
|
|
203
|
+
}, {
|
|
204
|
+
name: "reports attach",
|
|
205
|
+
summary: "Attach a file to a report",
|
|
206
|
+
usage: "reports attach <id> <path>",
|
|
207
|
+
details: [
|
|
208
|
+
"Screenshots, logs, spreadsheets — anything that shows the problem.",
|
|
209
|
+
"Up to 10 MB each, 20 per report. It lands on the report itself; use the",
|
|
210
|
+
"web UI to attach a file to one particular reply.",
|
|
211
|
+
],
|
|
212
|
+
async run({ args, client }) {
|
|
213
|
+
const id = args.arg(0, "id");
|
|
214
|
+
const filePath = args.arg(1, "path");
|
|
215
|
+
let bytes;
|
|
216
|
+
try {
|
|
217
|
+
bytes = readFileSync(filePath);
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
throw new CliError(`Can't read ${filePath}: ${error instanceof Error ? error.message : String(error)}`, 4);
|
|
221
|
+
}
|
|
222
|
+
const file = await client().upload(`/reports/${id}/attachments`, basename(filePath), bytes);
|
|
223
|
+
emit({ attachment: file }, () => info(`✓ Attached ${file.name} (${humanSize(file.size)}) to ${id}`));
|
|
224
|
+
},
|
|
225
|
+
}, {
|
|
226
|
+
name: "reports download",
|
|
227
|
+
summary: "Download an attachment from a report",
|
|
228
|
+
usage: "reports download <id> <name|attachment-id> [--out <path>]",
|
|
229
|
+
async run({ args, client }) {
|
|
230
|
+
const c = client();
|
|
231
|
+
const id = args.arg(0, "id");
|
|
232
|
+
const { report } = await c.request("GET", `/reports/${id}`);
|
|
233
|
+
const file = findAttachment(report, args.arg(1, "name"));
|
|
234
|
+
const bytes = await c.download(`/reports/${id}/attachments/${file.id}`);
|
|
235
|
+
const out = args.flag("out") ?? file.name;
|
|
236
|
+
writeFileSync(out, bytes);
|
|
237
|
+
emit({ reportId: id, attachment: file.name, out, bytes: bytes.length }, () => info(`✓ Wrote ${out} (${humanSize(bytes.length)})`));
|
|
238
|
+
},
|
|
239
|
+
}, {
|
|
240
|
+
name: "reports file",
|
|
241
|
+
summary: "File a bug report or feature request",
|
|
242
|
+
usage: "reports file <app> <title> [--details <text>] [--request] [--attach <path>]",
|
|
243
|
+
details: [
|
|
244
|
+
" <app> The app it's about, or `coeconnect` for the portal itself",
|
|
245
|
+
" --details The body of the report (defaults to the title)",
|
|
246
|
+
" --request File it as a feature request rather than a bug",
|
|
247
|
+
" --attach A file to attach — a screenshot, a log excerpt",
|
|
248
|
+
],
|
|
249
|
+
async run({ args, client }) {
|
|
250
|
+
const c = client();
|
|
251
|
+
const target = args.arg(0, "app");
|
|
252
|
+
const title = args.arg(1, "title");
|
|
253
|
+
const appId = await resolveTarget(c, target);
|
|
254
|
+
const { report } = await c.request("POST", "/reports", {
|
|
255
|
+
appId,
|
|
256
|
+
kind: args.bool("request") ? "idea" : "bug",
|
|
257
|
+
title,
|
|
258
|
+
body: args.flag("details") ?? title,
|
|
259
|
+
});
|
|
260
|
+
// Attached after the report exists, because that is what it hangs off.
|
|
261
|
+
// A failure here must not read as "the report wasn't filed" — it was.
|
|
262
|
+
const attach = args.flag("attach");
|
|
263
|
+
let attachment = null;
|
|
264
|
+
if (attach) {
|
|
265
|
+
try {
|
|
266
|
+
attachment = await c.upload(`/reports/${report.id}/attachments`, basename(attach), readFileSync(attach));
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
info(`✓ Filed ${report.id} — ${report.title}`);
|
|
270
|
+
throw new CliError(`The report was filed, but ${attach} did not attach: ` +
|
|
271
|
+
(error instanceof Error ? error.message : String(error)), 1, `Try again with: coe reports attach ${report.id} ${attach}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
emit({ report, attachment }, () => info(`✓ Filed ${report.id} — ${report.title}` +
|
|
275
|
+
(attachment ? ` (attached ${attachment.name})` : "")));
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { registerDeployCommands } from "./commands/deploy.js";
|
|
|
7
7
|
import { registerEnvCommands } from "./commands/env.js";
|
|
8
8
|
import { registerLogCommands } from "./commands/logs.js";
|
|
9
9
|
import { registerPipelineCommands } from "./commands/pipelines.js";
|
|
10
|
+
import { registerReportCommands } from "./commands/reports.js";
|
|
10
11
|
import { registerResourceCommands } from "./commands/resources.js";
|
|
11
12
|
import { registerVmCommands } from "./commands/vms.js";
|
|
12
13
|
import { info, setJsonMode } from "./output.js";
|
|
@@ -20,6 +21,7 @@ registerEnvCommands();
|
|
|
20
21
|
registerResourceCommands();
|
|
21
22
|
registerLogCommands();
|
|
22
23
|
registerVmCommands();
|
|
24
|
+
registerReportCommands();
|
|
23
25
|
function parseArgs(argv) {
|
|
24
26
|
const positional = [];
|
|
25
27
|
const flags = {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uic-coe-connect/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The coe CLI — manage COEConnect apps (pipelines, deploys, access, env) from a terminal, with browser-approved auth. Designed to be driven by an AI agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|