@allocator-one/infraone 0.0.0-stage → 1.17.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/LICENSE +21 -0
- package/README.md +77 -2
- package/dist/chunk-5THYIKLW.js +95 -0
- package/dist/chunk-A4WZAC7L.js +563 -0
- package/dist/chunk-BHBEIT5I.js +1348 -0
- package/dist/chunk-CEEQW6ZV.js +3793 -0
- package/dist/chunk-W7BVOQQG.js +1448 -0
- package/dist/chunk-WSDFLMO6.js +717 -0
- package/dist/cli.js +3329 -0
- package/dist/infraone-A3KDADRM.js +249 -0
- package/dist/infraone-V3IKKVBG.js +593 -0
- package/dist/infraone.js +372 -0
- package/dist/login-page-MFHNVTUV.js +299 -0
- package/dist/server-TRXBTJSA.js +1080 -0
- package/package.json +27 -4
|
@@ -0,0 +1,3793 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
Client,
|
|
4
|
+
CommandError,
|
|
5
|
+
EXPERT_ASSIGNMENTS_PATH,
|
|
6
|
+
HttpError,
|
|
7
|
+
LocalError,
|
|
8
|
+
MalformedResponseError,
|
|
9
|
+
TransportError,
|
|
10
|
+
UpgradeRequiredError,
|
|
11
|
+
UsageError,
|
|
12
|
+
activityEntries,
|
|
13
|
+
clean,
|
|
14
|
+
cleanMultiline,
|
|
15
|
+
conditionLine,
|
|
16
|
+
errorDetails,
|
|
17
|
+
expertAssignments,
|
|
18
|
+
expertSummary,
|
|
19
|
+
explainExpertRefusal,
|
|
20
|
+
gateName,
|
|
21
|
+
graphemeLength,
|
|
22
|
+
isAgentStep,
|
|
23
|
+
isExpertToken,
|
|
24
|
+
issueDetail,
|
|
25
|
+
issuesTable,
|
|
26
|
+
ownerText,
|
|
27
|
+
planDetail,
|
|
28
|
+
plansTable,
|
|
29
|
+
stats,
|
|
30
|
+
table,
|
|
31
|
+
truncate
|
|
32
|
+
} from "./chunk-BHBEIT5I.js";
|
|
33
|
+
import {
|
|
34
|
+
CLI_VERSION,
|
|
35
|
+
invokedName,
|
|
36
|
+
named
|
|
37
|
+
} from "./chunk-5THYIKLW.js";
|
|
38
|
+
|
|
39
|
+
// src/commands/issues.ts
|
|
40
|
+
import { openAsBlob, openSync, closeSync, readFileSync, statSync as statSync2 } from "fs";
|
|
41
|
+
import { basename, extname } from "path";
|
|
42
|
+
|
|
43
|
+
// src/core/attachments.ts
|
|
44
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
45
|
+
import { dirname, join } from "path";
|
|
46
|
+
var REF = /\/(?:uploads\/harness|api\/v1)\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/([A-Za-z0-9._-]*\.(?:png|jpe?g|webp|gif))(?![A-Za-z0-9._-])/gi;
|
|
47
|
+
var SOURCES = [
|
|
48
|
+
["description", "description"],
|
|
49
|
+
["design", "design"],
|
|
50
|
+
["acceptance_criteria", "acceptance criteria"],
|
|
51
|
+
["notes", "notes"]
|
|
52
|
+
];
|
|
53
|
+
var MAX_IMAGES_PER_SHOW = 20;
|
|
54
|
+
var MAX_ATTACHMENT_BYTES = 25e6;
|
|
55
|
+
function refs(issue) {
|
|
56
|
+
const texts = SOURCES.map(([field3, source]) => [issue[field3], source]);
|
|
57
|
+
const comments2 = Array.isArray(issue["comments"]) ? issue["comments"] : [];
|
|
58
|
+
for (const comment2 of comments2) texts.push([comment2["body"], "comment"]);
|
|
59
|
+
const found = [];
|
|
60
|
+
const seen = /* @__PURE__ */ new Set();
|
|
61
|
+
for (const [text, source] of texts) {
|
|
62
|
+
if (typeof text !== "string" || text === "") continue;
|
|
63
|
+
for (const match of text.matchAll(REF)) {
|
|
64
|
+
const ref = {
|
|
65
|
+
attachmentId: match[1].toLowerCase(),
|
|
66
|
+
file: match[2],
|
|
67
|
+
source
|
|
68
|
+
};
|
|
69
|
+
const key = `${ref.attachmentId}/${ref.file}`;
|
|
70
|
+
if (!seen.has(key)) {
|
|
71
|
+
seen.add(key);
|
|
72
|
+
found.push(ref);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
async function fetchImages(client, issue, cacheDir) {
|
|
79
|
+
const allRefs = refs(issue);
|
|
80
|
+
const wanted = allRefs.slice(0, MAX_IMAGES_PER_SHOW);
|
|
81
|
+
const dropped = allRefs.slice(MAX_IMAGES_PER_SHOW);
|
|
82
|
+
const images = [];
|
|
83
|
+
const failures = [];
|
|
84
|
+
for (const ref of wanted) {
|
|
85
|
+
const result = await download(client, ref, cacheDir);
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
images.push({
|
|
88
|
+
attachment_id: ref.attachmentId,
|
|
89
|
+
file: ref.file,
|
|
90
|
+
path: result.path,
|
|
91
|
+
source: ref.source
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
failures.push(`${ref.file}: ${result.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (dropped.length > 0) {
|
|
98
|
+
failures.push(
|
|
99
|
+
`${dropped.length} further reference(s) skipped \u2014 the cap is ${MAX_IMAGES_PER_SHOW} images per show`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return { images, failures };
|
|
103
|
+
}
|
|
104
|
+
async function download(client, ref, cacheDir) {
|
|
105
|
+
const path = join(cacheDir, hostSegment(client.baseUrl), ref.attachmentId, ref.file);
|
|
106
|
+
try {
|
|
107
|
+
if (statSync(path).size > 0) return { ok: true, path };
|
|
108
|
+
} catch {
|
|
109
|
+
}
|
|
110
|
+
const bytes = await fetchBytes(client, ref);
|
|
111
|
+
if (!bytes.ok) return bytes;
|
|
112
|
+
return write(path, bytes.bytes);
|
|
113
|
+
}
|
|
114
|
+
function hostSegment(baseUrl) {
|
|
115
|
+
let host = "unknown-host";
|
|
116
|
+
let port = null;
|
|
117
|
+
try {
|
|
118
|
+
const parsed = new URL(baseUrl);
|
|
119
|
+
host = parsed.hostname.toLowerCase().replace(/[^a-z0-9.-]/g, "_");
|
|
120
|
+
port = parsed.port === "" ? null : Number(parsed.port);
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
return port === null || port === 80 || port === 443 ? host : `${host}_${port}`;
|
|
124
|
+
}
|
|
125
|
+
async function fetchBytes(client, ref) {
|
|
126
|
+
let bytes;
|
|
127
|
+
try {
|
|
128
|
+
bytes = await client.getBytes(`/api/v1/attachments/${ref.attachmentId}/${ref.file}`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error instanceof HttpError) return { ok: false, message: `${error.message} (HTTP ${error.status})` };
|
|
131
|
+
if (error instanceof TransportError) return { ok: false, message: error.message };
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
135
|
+
return { ok: false, message: `response is larger than the ${MAX_ATTACHMENT_BYTES}-byte attachment cap` };
|
|
136
|
+
}
|
|
137
|
+
if (!isImage(bytes)) return { ok: false, message: "response is not an image" };
|
|
138
|
+
return { ok: true, bytes };
|
|
139
|
+
}
|
|
140
|
+
function isImage(bytes) {
|
|
141
|
+
const startsWith = (prefix) => prefix.every((byte, index) => bytes[index] === byte);
|
|
142
|
+
if (startsWith([137, 80, 78, 71, 13, 10, 26, 10])) return true;
|
|
143
|
+
if (startsWith([255, 216, 255])) return true;
|
|
144
|
+
if (startsWith([71, 73, 70, 56, 55, 97])) return true;
|
|
145
|
+
if (startsWith([71, 73, 70, 56, 57, 97])) return true;
|
|
146
|
+
if (startsWith([82, 73, 70, 70]) && bytes.length >= 12) {
|
|
147
|
+
return bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
var partialCounter = 0;
|
|
152
|
+
function write(path, bytes) {
|
|
153
|
+
const partial = `${path}.partial.${process.pid}.${++partialCounter}`;
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
156
|
+
chmodSync(dirname(path), 448);
|
|
157
|
+
writeFileSync(partial, bytes);
|
|
158
|
+
chmodSync(partial, 384);
|
|
159
|
+
renameSync(partial, path);
|
|
160
|
+
return { ok: true, path };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
rmSync(partial, { force: true });
|
|
163
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
164
|
+
return { ok: false, message: `cannot write ${path}: ${detail}` };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/core/pagination.ts
|
|
169
|
+
function notices(meta, requestedPageSize, records4) {
|
|
170
|
+
return capNotice(meta, requestedPageSize) + truncationNotice(meta, records4);
|
|
171
|
+
}
|
|
172
|
+
function capNotice(meta, requestedPageSize) {
|
|
173
|
+
const served = meta?.page_size;
|
|
174
|
+
if (typeof served === "number" && typeof requestedPageSize === "number" && requestedPageSize > served) {
|
|
175
|
+
return `harness: --limit ${requestedPageSize} was capped at ${served} by the server.
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
function truncationNotice(meta, records4) {
|
|
181
|
+
const total = meta?.total;
|
|
182
|
+
const page = meta?.page;
|
|
183
|
+
const pages = meta?.total_pages;
|
|
184
|
+
if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
|
|
185
|
+
const shown = records4.length;
|
|
186
|
+
if (shown < total) {
|
|
187
|
+
return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
|
|
188
|
+
`;
|
|
189
|
+
}
|
|
190
|
+
return "";
|
|
191
|
+
}
|
|
192
|
+
async function fetchEveryPage(fetchPage) {
|
|
193
|
+
const first = await fetchPage(1);
|
|
194
|
+
const totalPages = pageCount(first.meta);
|
|
195
|
+
if (totalPages === null) return first.data;
|
|
196
|
+
let records4 = first.data;
|
|
197
|
+
for (let page = 2; page <= totalPages; page++) {
|
|
198
|
+
const body = await fetchPage(page);
|
|
199
|
+
records4 = dedupe([...records4, ...body.data]);
|
|
200
|
+
}
|
|
201
|
+
return records4;
|
|
202
|
+
}
|
|
203
|
+
function pageCount(meta) {
|
|
204
|
+
const total = meta?.total_pages;
|
|
205
|
+
return typeof total === "number" && total > 0 ? total : null;
|
|
206
|
+
}
|
|
207
|
+
function dedupe(records4) {
|
|
208
|
+
const seen = /* @__PURE__ */ new Set();
|
|
209
|
+
return records4.filter((record) => {
|
|
210
|
+
const id = record["id"];
|
|
211
|
+
const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
|
|
212
|
+
if (seen.has(key)) return false;
|
|
213
|
+
seen.add(key);
|
|
214
|
+
return true;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function stableKey(value) {
|
|
218
|
+
return JSON.stringify(
|
|
219
|
+
value,
|
|
220
|
+
(_field, nested) => nested !== null && typeof nested === "object" && !Array.isArray(nested) ? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a < b ? -1 : 1)) : nested
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// src/commands/issues.ts
|
|
225
|
+
var PRIORITY_NAMES = { urgent: 1, high: 2, medium: 3, low: 4, none: 0 };
|
|
226
|
+
var PRIORITY_RANK = { 1: 0, 2: 1, 3: 2, 4: 3, 0: 4 };
|
|
227
|
+
var STATUS_STATE_TYPES = {
|
|
228
|
+
open: "backlog,unstarted",
|
|
229
|
+
in_progress: "started",
|
|
230
|
+
closed: "completed,canceled"
|
|
231
|
+
};
|
|
232
|
+
var MAX_IDEMPOTENCY_KEY_BYTES = 200;
|
|
233
|
+
var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
|
|
234
|
+
var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
235
|
+
var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
236
|
+
function isUuid(value) {
|
|
237
|
+
return UUID_FORMAT.test(value);
|
|
238
|
+
}
|
|
239
|
+
function isIdentifier(value) {
|
|
240
|
+
return IDENTIFIER_FORMAT.test(value);
|
|
241
|
+
}
|
|
242
|
+
function requireIdentifiers(identifiers) {
|
|
243
|
+
const bad = identifiers.filter((identifier) => !IDENTIFIER_FORMAT.test(identifier));
|
|
244
|
+
if (bad.length > 0) {
|
|
245
|
+
throw new UsageError(`not an issue identifier: ${bad.join(", ")} (expected e.g. ALL-4223)`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function ready(ctx, opts) {
|
|
249
|
+
if (opts.claim === true) return claimNext(ctx, opts);
|
|
250
|
+
const params = { ...listParams(ctx, opts), ready: true };
|
|
251
|
+
const { records: records4, notice } = await fetchIssues(ctx, params, opts);
|
|
252
|
+
const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
|
|
253
|
+
const sorted = markAgentSteps([...records4].sort((a, b) => rank(a) - rank(b)));
|
|
254
|
+
return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
|
|
255
|
+
}
|
|
256
|
+
function markAgentSteps(issues) {
|
|
257
|
+
return issues.map((issue) => ({ ...issue, agent_step: isAgentStep(issue) }));
|
|
258
|
+
}
|
|
259
|
+
function requireReadyClaimArgs(opts) {
|
|
260
|
+
const paging = [
|
|
261
|
+
opts.all === true ? "--all" : null,
|
|
262
|
+
opts.limit !== void 0 ? "--limit" : null,
|
|
263
|
+
opts.page !== void 0 ? "--page" : null
|
|
264
|
+
].filter((flag) => flag !== null);
|
|
265
|
+
if (paging.length === 0) return;
|
|
266
|
+
const last = paging.pop();
|
|
267
|
+
const flags = paging.length === 0 ? last : `${paging.join(", ")} or ${last}`;
|
|
268
|
+
throw new UsageError(
|
|
269
|
+
`--claim cannot be combined with ${flags}: the server picks from the team's whole ready queue, so there are no pages to choose from`
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
async function claimNext(ctx, opts) {
|
|
273
|
+
requireReadyClaimArgs(opts);
|
|
274
|
+
const team = requiredTeam(ctx, opts.team);
|
|
275
|
+
const check = `check \`${invokedName()} list --team ${team} --assignee me --status in_progress\``;
|
|
276
|
+
const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
|
|
277
|
+
let body;
|
|
278
|
+
try {
|
|
279
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/issues/claim-next`);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
if (error instanceof MalformedResponseError) throw unreadable(`could not be read (${error.message})`);
|
|
282
|
+
if (error instanceof TransportError) {
|
|
283
|
+
throw new TransportError(
|
|
284
|
+
`${error.message} \u2014 if the request reached the server, an issue may have been claimed; ${check} before claiming again`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
if (body.data === null) {
|
|
290
|
+
return { json: null, human: `Nothing to claim: team ${team} has no unassigned ready issues.
|
|
291
|
+
` };
|
|
292
|
+
}
|
|
293
|
+
const claimed = body.data;
|
|
294
|
+
if (!isIssueRecord(claimed)) throw unreadable("carried neither an issue nor null");
|
|
295
|
+
return {
|
|
296
|
+
json: claimed,
|
|
297
|
+
human: `Claimed ${str(claimed["identifier"])}: ${clean(claimed["title"])}
|
|
298
|
+
`
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function isIssueRecord(value) {
|
|
302
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
303
|
+
const identifier = value["identifier"];
|
|
304
|
+
return typeof identifier === "string" && identifier !== "";
|
|
305
|
+
}
|
|
306
|
+
async function blocked(ctx, opts) {
|
|
307
|
+
const params = { ...listParams(ctx, opts), blocked: true };
|
|
308
|
+
const { records: records4, notice } = await fetchIssues(ctx, params, opts);
|
|
309
|
+
return { json: records4, human: issuesTable(records4), notice };
|
|
310
|
+
}
|
|
311
|
+
async function list(ctx, opts) {
|
|
312
|
+
const params = listParams(ctx, opts);
|
|
313
|
+
const { records: records4, notice } = await fetchIssues(ctx, params, opts);
|
|
314
|
+
return { json: records4, human: issuesTable(records4), notice };
|
|
315
|
+
}
|
|
316
|
+
async function search(ctx, queryWords, opts) {
|
|
317
|
+
if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
|
|
318
|
+
const deprecation = opts.page !== void 0 || opts.all === true ? "--page/--all are ignored: search returns one ranked list, capped at 20" : void 0;
|
|
319
|
+
let params = { q: queryWords.join(" ") };
|
|
320
|
+
if (opts.limit !== void 0) params = { ...params, limit: opts.limit };
|
|
321
|
+
const body = await ctx.client.get("/api/v1/issues/search", params);
|
|
322
|
+
const meta = body.meta ?? {};
|
|
323
|
+
const degraded = meta["mode"] === "lexical" ? "semantic search unavailable \u2014 showing lexical matches only" : void 0;
|
|
324
|
+
const notice = [deprecation, degraded].filter(Boolean).join("; ") || void 0;
|
|
325
|
+
return { json: body.data, human: issuesTable(body.data), notice };
|
|
326
|
+
}
|
|
327
|
+
async function show(ctx, identifier, opts) {
|
|
328
|
+
requireIdentifiers([identifier]);
|
|
329
|
+
const body = await ctx.client.get(`/api/v1/issues/${identifier}`, {
|
|
330
|
+
include: "relations,children,comments"
|
|
331
|
+
});
|
|
332
|
+
const issue = body.data;
|
|
333
|
+
let images = [];
|
|
334
|
+
let failures = [];
|
|
335
|
+
if (opts.images !== false) {
|
|
336
|
+
({ images, failures } = await fetchImages(ctx.client, issue, ctx.config.cacheDir));
|
|
337
|
+
}
|
|
338
|
+
const json = images.length === 0 ? issue : { ...issue, local_images: images };
|
|
339
|
+
const human = issueDetail(issue, images);
|
|
340
|
+
const notice = failures.map((failure) => `harness: image not downloaded: ${clean(failure)}
|
|
341
|
+
`).join("");
|
|
342
|
+
return notice === "" ? { json, human } : { json, human, notice };
|
|
343
|
+
}
|
|
344
|
+
var ACTIVITY_MAX_LIMIT = 100;
|
|
345
|
+
var ACTIVITY_INCLUDE = "condition_events,field_events";
|
|
346
|
+
var ACTIVITY_INCLUDE_WITHOUT_FIELDS = "condition_events";
|
|
347
|
+
var ACTIVITY_WITHOUT_FIELDS_NOTICE = "harness: this server doesn't record field changes yet; showing activity without them.\n";
|
|
348
|
+
async function activity(ctx, identifier, opts) {
|
|
349
|
+
requireIdentifiers([identifier]);
|
|
350
|
+
const first = await firstActivityPage(ctx, identifier, opts.limit);
|
|
351
|
+
const include = first.include;
|
|
352
|
+
let { page, notice } = first;
|
|
353
|
+
let entries = page.entries;
|
|
354
|
+
if (opts.all === true) {
|
|
355
|
+
const pages = [page.entries];
|
|
356
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
357
|
+
while (page.hasMore) {
|
|
358
|
+
const cursor = page.nextCursor;
|
|
359
|
+
if (seenCursors.has(cursor)) {
|
|
360
|
+
throw new CommandError("Activity response repeated a cursor.");
|
|
361
|
+
}
|
|
362
|
+
seenCursors.add(cursor);
|
|
363
|
+
page = await activityPage(ctx, identifier, include, opts.limit, cursor);
|
|
364
|
+
pages.push(page.entries);
|
|
365
|
+
}
|
|
366
|
+
entries = pages.reverse().flat();
|
|
367
|
+
}
|
|
368
|
+
if (opts.all !== true && page.hasMore) {
|
|
369
|
+
notice += `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
return { json: entries, human: activityEntries(entries), notice: notice === "" ? void 0 : notice };
|
|
373
|
+
}
|
|
374
|
+
async function firstActivityPage(ctx, identifier, limit) {
|
|
375
|
+
try {
|
|
376
|
+
const page2 = await activityPage(ctx, identifier, ACTIVITY_INCLUDE, limit);
|
|
377
|
+
return { page: page2, include: ACTIVITY_INCLUDE, notice: "" };
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (!refusesInclude(error)) throw error;
|
|
380
|
+
}
|
|
381
|
+
const page = await activityPage(ctx, identifier, ACTIVITY_INCLUDE_WITHOUT_FIELDS, limit);
|
|
382
|
+
return { page, include: ACTIVITY_INCLUDE_WITHOUT_FIELDS, notice: ACTIVITY_WITHOUT_FIELDS_NOTICE };
|
|
383
|
+
}
|
|
384
|
+
function refusesInclude(error) {
|
|
385
|
+
if (!(error instanceof HttpError) || error.status !== 400) return false;
|
|
386
|
+
const message = error.body["message"];
|
|
387
|
+
return typeof message === "string" && /\binclude\b/.test(message) && /\b(condition_events|field_events)\b|\baccepts only\b/.test(message);
|
|
388
|
+
}
|
|
389
|
+
async function activityPage(ctx, identifier, include, limit, before) {
|
|
390
|
+
const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, {
|
|
391
|
+
limit,
|
|
392
|
+
before,
|
|
393
|
+
include
|
|
394
|
+
});
|
|
395
|
+
if (!Array.isArray(body.data)) throw new CommandError("Activity response data is not an array.");
|
|
396
|
+
if (body.data.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry))) {
|
|
397
|
+
throw new CommandError("Activity response contains a non-object entry.");
|
|
398
|
+
}
|
|
399
|
+
const meta = body.meta;
|
|
400
|
+
if (typeof meta !== "object" || meta === null) {
|
|
401
|
+
throw new CommandError("Activity response has no pagination metadata.");
|
|
402
|
+
}
|
|
403
|
+
const hasMore = meta.has_more;
|
|
404
|
+
const nextCursor = meta.next_cursor;
|
|
405
|
+
if (typeof hasMore !== "boolean") {
|
|
406
|
+
throw new CommandError("Activity response has an invalid has_more value.");
|
|
407
|
+
}
|
|
408
|
+
if (nextCursor !== null && typeof nextCursor !== "string") {
|
|
409
|
+
throw new CommandError("Activity response has an invalid next_cursor value.");
|
|
410
|
+
}
|
|
411
|
+
if (hasMore) {
|
|
412
|
+
if (nextCursor === null || nextCursor === "") {
|
|
413
|
+
throw new CommandError("Activity response says older entries exist but provides no cursor.");
|
|
414
|
+
}
|
|
415
|
+
if (body.data.length === 0) {
|
|
416
|
+
throw new CommandError("Activity response says older entries exist but contains no entries.");
|
|
417
|
+
}
|
|
418
|
+
return { entries: body.data, hasMore: true, nextCursor };
|
|
419
|
+
}
|
|
420
|
+
if (nextCursor !== null) {
|
|
421
|
+
throw new CommandError("Activity response provides a cursor when no older entries exist.");
|
|
422
|
+
}
|
|
423
|
+
return { entries: body.data, hasMore: false, nextCursor: null };
|
|
424
|
+
}
|
|
425
|
+
async function stats2(ctx) {
|
|
426
|
+
const body = await ctx.client.get("/api/v1/stats");
|
|
427
|
+
return { json: body.data, human: stats(body.data) };
|
|
428
|
+
}
|
|
429
|
+
async function prime(ctx) {
|
|
430
|
+
const statsBody = await ctx.client.get("/api/v1/stats");
|
|
431
|
+
const ready2 = markAgentSteps(await mostImportantReady(ctx));
|
|
432
|
+
const json = { stats: statsBody.data, ready: ready2 };
|
|
433
|
+
const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + named(`
|
|
434
|
+
Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
|
|
435
|
+
harness close <id> --reason "Implemented in <commit-hash>" (response
|
|
436
|
+
lists newly unblocked issues \u2014 surface them).
|
|
437
|
+
Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
|
|
438
|
+
Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
|
|
439
|
+
Before reporting done: close the issues you finished.
|
|
440
|
+
`);
|
|
441
|
+
return { json, human };
|
|
442
|
+
}
|
|
443
|
+
async function mostImportantReady(ctx) {
|
|
444
|
+
try {
|
|
445
|
+
return (await ctx.client.get("/api/v1/issues/next", { limit: 10 })).data;
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
|
448
|
+
return (await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 })).data;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
async function version(config) {
|
|
452
|
+
let server;
|
|
453
|
+
if (config === null || config.baseUrl === "") {
|
|
454
|
+
server = "unconfigured";
|
|
455
|
+
} else {
|
|
456
|
+
server = await new Client(config).serverVersion().catch(() => "unreachable");
|
|
457
|
+
}
|
|
458
|
+
return { json: { cli: CLI_VERSION, server }, human: `${invokedName()} ${CLI_VERSION} (server API ${server})
|
|
459
|
+
` };
|
|
460
|
+
}
|
|
461
|
+
async function create(ctx, opts) {
|
|
462
|
+
if (opts.title === void 0) {
|
|
463
|
+
throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
|
|
464
|
+
}
|
|
465
|
+
validateType(opts.type);
|
|
466
|
+
const team = requiredTeam(ctx, opts.team);
|
|
467
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
468
|
+
const priority = optionalPriority(opts.priority);
|
|
469
|
+
const dueDate = optionalDueDate(opts.dueDate);
|
|
470
|
+
const labels = requestedLabelNames(wantedLabels(opts));
|
|
471
|
+
const labelIds = await prepareCreateLabelIds(ctx, team, labels);
|
|
472
|
+
let body = { title: opts.title };
|
|
473
|
+
body = putOpt(body, "description", opts.description);
|
|
474
|
+
body = putOpt(body, "design", opts.design);
|
|
475
|
+
body = putOpt(body, "notes", opts.notes);
|
|
476
|
+
body = putOpt(body, "acceptance_criteria", opts.acceptance);
|
|
477
|
+
body = putOpt(body, "parent", opts.parent);
|
|
478
|
+
body = putPresent(body, "priority", priority);
|
|
479
|
+
body = putDueDate(body, dueDate);
|
|
480
|
+
body = putAssignee(body, opts.assignee);
|
|
481
|
+
if (labelIds.length > 0) body["label_ids"] = labelIds;
|
|
482
|
+
const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body, {
|
|
483
|
+
idempotencyKey: opts.idempotencyKey
|
|
484
|
+
});
|
|
485
|
+
const issue = created.data;
|
|
486
|
+
return {
|
|
487
|
+
json: issue,
|
|
488
|
+
human: `Created ${str(issue["identifier"])}: ${clean(issue["title"])}
|
|
489
|
+
`
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function validateIdempotencyKey(key) {
|
|
493
|
+
if (key === void 0) return;
|
|
494
|
+
if (key.trim() === "") throw new UsageError("idempotency key must not be blank");
|
|
495
|
+
if (Buffer.byteLength(key, "utf8") > MAX_IDEMPOTENCY_KEY_BYTES) {
|
|
496
|
+
throw new UsageError(`idempotency key must not exceed ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
|
|
497
|
+
}
|
|
498
|
+
if (!IDEMPOTENCY_KEY_FORMAT.test(key)) {
|
|
499
|
+
throw new UsageError("idempotency key must use printable ASCII and cannot start or end with a space");
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function update(ctx, identifier, opts) {
|
|
503
|
+
requireIdentifiers([identifier]);
|
|
504
|
+
if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
|
|
505
|
+
throw new UsageError("--notes replaces and --append-notes appends \u2014 pass one, not both");
|
|
506
|
+
}
|
|
507
|
+
if (opts.claim === true && opts.assignee !== void 0) {
|
|
508
|
+
throw new UsageError(
|
|
509
|
+
"--claim takes the issue and starts it; --assignee only sets the field \u2014 pass one, not both (--claim to take it yourself, --assignee to hand it to someone)"
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const priority = optionalPriority(opts.priority);
|
|
513
|
+
const dueDate = optionalDueDate(opts.dueDate);
|
|
514
|
+
const addLabels = requestedLabelInputs(opts.addLabel ?? []);
|
|
515
|
+
const removeLabels = requestedLabelInputs(opts.removeLabel ?? []);
|
|
516
|
+
const labelPlan = addLabels.length > 0 || removeLabels.length > 0 ? await prepareLabelUpdate(ctx, identifier, addLabels, removeLabels) : null;
|
|
517
|
+
let issue = labelPlan?.issue ?? null;
|
|
518
|
+
let wrote = false;
|
|
519
|
+
if (opts.claim === true) {
|
|
520
|
+
try {
|
|
521
|
+
const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
|
|
522
|
+
issue = body.data;
|
|
523
|
+
} catch (error) {
|
|
524
|
+
throw explainClaimRefusal(error, identifier);
|
|
525
|
+
}
|
|
526
|
+
wrote = true;
|
|
527
|
+
}
|
|
528
|
+
let patch = {};
|
|
529
|
+
patch = putOpt(patch, "title", opts.title);
|
|
530
|
+
patch = putOpt(patch, "description", opts.description);
|
|
531
|
+
patch = putOpt(patch, "design", opts.design);
|
|
532
|
+
patch = putOpt(patch, "notes", opts.notes);
|
|
533
|
+
patch = putOpt(patch, "acceptance_criteria", opts.acceptance);
|
|
534
|
+
patch = putOpt(patch, "parent", opts.parent);
|
|
535
|
+
patch = putPresent(patch, "priority", priority);
|
|
536
|
+
patch = putDueDate(patch, dueDate);
|
|
537
|
+
patch = putAssignee(patch, opts.assignee);
|
|
538
|
+
if (opts.appendNotes !== void 0) {
|
|
539
|
+
const current = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
540
|
+
const existing = current.data["notes"];
|
|
541
|
+
const combined = existing === null || existing === void 0 || existing === "" ? opts.appendNotes : `${str(existing)}
|
|
542
|
+
|
|
543
|
+
${opts.appendNotes}`;
|
|
544
|
+
patch["notes"] = combined;
|
|
545
|
+
}
|
|
546
|
+
if (Object.keys(patch).length > 0) {
|
|
547
|
+
const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
|
|
548
|
+
issue = body.data;
|
|
549
|
+
wrote = true;
|
|
550
|
+
}
|
|
551
|
+
if (opts.state !== void 0) {
|
|
552
|
+
issue = await setState(ctx, identifier, opts.state);
|
|
553
|
+
wrote = true;
|
|
554
|
+
}
|
|
555
|
+
if (labelPlan?.changed === true) {
|
|
556
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/labels`, {
|
|
557
|
+
label_ids: labelPlan.ids
|
|
558
|
+
});
|
|
559
|
+
issue = body.data;
|
|
560
|
+
wrote = true;
|
|
561
|
+
}
|
|
562
|
+
if (issue === null) {
|
|
563
|
+
throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
|
|
564
|
+
}
|
|
565
|
+
const human = wrote ? `Updated ${str(issue["identifier"])}
|
|
566
|
+
` : `Labels already up to date for ${str(issue["identifier"])}
|
|
567
|
+
`;
|
|
568
|
+
return { json: issue, human };
|
|
569
|
+
}
|
|
570
|
+
var CLAIM_REFUSAL_HINTS = {
|
|
571
|
+
// A person never claims an Operator step, so releasing the plan is not the
|
|
572
|
+
// fix: handing the step to a person is, and a handed-over Operator step
|
|
573
|
+
// stays frozen until the next release.
|
|
574
|
+
agent_step: (_identifier, plan) => {
|
|
575
|
+
const runs = plan.kind === "sole" ? `plan ${plan.root}` : plan.kind === "nearest" ? `its plans (several govern it; the nearest is ${plan.root})` : "its plan";
|
|
576
|
+
const target = plan.kind === "sole" ? plan.root : "<plan>";
|
|
577
|
+
return `a person can't claim an Operator step: Operator runs it for ${runs}. To do it yourself, set its executor to none (${invokedName()} plan edit ${target}) and release the plan again`;
|
|
578
|
+
},
|
|
579
|
+
plan_not_released: (_identifier, plan) => planReleaseHint(plan),
|
|
580
|
+
not_in_plan_revision: (_identifier, plan) => planReleaseHint(plan),
|
|
581
|
+
edited_since_release: (_identifier, plan) => planReleaseHint(plan),
|
|
582
|
+
waiting_on_condition: (identifier) => `${invokedName()} show ${identifier} lists what it waits on; tick a gate with ${invokedName()} gate satisfy ${identifier} <gate>`
|
|
583
|
+
};
|
|
584
|
+
function planReleaseHint(plan) {
|
|
585
|
+
if (plan.kind === "sole") return plan.release ? `run ${invokedName()} plan release ${plan.root}` : void 0;
|
|
586
|
+
if (plan.kind === "nearest") {
|
|
587
|
+
return `several plans govern it; the nearest is ${plan.root} (${invokedName()} plan show ${plan.root})`;
|
|
588
|
+
}
|
|
589
|
+
return void 0;
|
|
590
|
+
}
|
|
591
|
+
function explainClaimRefusal(error, identifier) {
|
|
592
|
+
if (!(error instanceof HttpError) || error.status !== 409) return error;
|
|
593
|
+
const reason = error.body["reason"];
|
|
594
|
+
const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier, refusingPlan(error.body)) : void 0;
|
|
595
|
+
if (hint === void 0) return error;
|
|
596
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
597
|
+
}
|
|
598
|
+
function refusingPlan(body) {
|
|
599
|
+
const plan = body["plan"];
|
|
600
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) return { kind: "none" };
|
|
601
|
+
const root = plan["root"];
|
|
602
|
+
if (typeof root !== "string" || !isIdentifier(root)) return { kind: "none" };
|
|
603
|
+
const message = typeof body["message"] === "string" ? body["message"] : "";
|
|
604
|
+
if (!new RegExp(`\\bplan ${root}\\b`).test(message)) return { kind: "nearest", root };
|
|
605
|
+
return { kind: "sole", root, release: new RegExp(`\\brelease plan ${root}\\b`).test(message) };
|
|
606
|
+
}
|
|
607
|
+
function requireCloseArgs(identifiers, opts) {
|
|
608
|
+
if (identifiers.length === 0) {
|
|
609
|
+
throw new UsageError(
|
|
610
|
+
"usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel | --duplicate-of <id>]"
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
requireIdentifiers(identifiers);
|
|
614
|
+
if (opts.duplicateOf === void 0) return;
|
|
615
|
+
requireIdentifiers([opts.duplicateOf]);
|
|
616
|
+
if (opts.cancel === true) {
|
|
617
|
+
throw new UsageError("--duplicate-of and --cancel are mutually exclusive");
|
|
618
|
+
}
|
|
619
|
+
if (identifiers.length !== 1) {
|
|
620
|
+
throw new UsageError("--duplicate-of accepts exactly one source issue");
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
async function close(ctx, identifiers, opts) {
|
|
624
|
+
requireCloseArgs(identifiers, opts);
|
|
625
|
+
let body = {};
|
|
626
|
+
body = putPresent(body, "reason", opts.reason);
|
|
627
|
+
body = putPresent(body, "cancel", opts.cancel);
|
|
628
|
+
body = putPresent(body, "duplicate_of", opts.duplicateOf);
|
|
629
|
+
const results = [];
|
|
630
|
+
for (const identifier of identifiers) {
|
|
631
|
+
try {
|
|
632
|
+
const response = await ctx.client.post(`/api/v1/issues/${identifier}/close`, body);
|
|
633
|
+
results.push({ identifier, ok: true, response });
|
|
634
|
+
} catch (error) {
|
|
635
|
+
if (error instanceof HttpError || error instanceof TransportError) {
|
|
636
|
+
results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
|
|
637
|
+
} else {
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
|
|
643
|
+
const json = results.map(
|
|
644
|
+
(result) => result.ok ? {
|
|
645
|
+
identifier: result.identifier,
|
|
646
|
+
closed: true,
|
|
647
|
+
...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
|
|
648
|
+
unblocked: result.response["unblocked"] ?? null,
|
|
649
|
+
// Passed through as sent: older servers omit it, and so does this.
|
|
650
|
+
..."still_blocking" in result.response ? { still_blocking: result.response["still_blocking"] } : {}
|
|
651
|
+
} : { identifier: result.identifier, closed: false, error: errorText(result.error) }
|
|
652
|
+
);
|
|
653
|
+
const partial = results.some((result) => !result.ok);
|
|
654
|
+
return partial ? { json, human, partial: true } : { json, human };
|
|
655
|
+
}
|
|
656
|
+
function closeLine(result, duplicateOf) {
|
|
657
|
+
if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
|
|
658
|
+
`;
|
|
659
|
+
const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
|
|
660
|
+
const unblocked = result.response["unblocked"];
|
|
661
|
+
const line = Array.isArray(unblocked) && unblocked.length > 0 ? `${closure} \u2014 unblocked: ${unblocked.map((identifier) => clean(identifier)).join(", ")}
|
|
662
|
+
` : `${closure}
|
|
663
|
+
`;
|
|
664
|
+
return line + stillBlockingLine(result.response["still_blocking"]);
|
|
665
|
+
}
|
|
666
|
+
function stillBlockingLine(stillBlocking) {
|
|
667
|
+
if (!Array.isArray(stillBlocking) || stillBlocking.length === 0) return "";
|
|
668
|
+
const identifiers = stillBlocking.map((identifier) => clean(identifier)).join(", ");
|
|
669
|
+
return ` Still blocking: ${identifiers} (a cancelled issue still blocks its dependents)
|
|
670
|
+
`;
|
|
671
|
+
}
|
|
672
|
+
async function archive(ctx, identifiers, opts) {
|
|
673
|
+
if (identifiers.length === 0) {
|
|
674
|
+
throw new UsageError("usage: harness archive <id> [<id>\u2026] [--restore]");
|
|
675
|
+
}
|
|
676
|
+
requireIdentifiers(identifiers);
|
|
677
|
+
const archived = opts.restore !== true;
|
|
678
|
+
const verb = archived ? "Archived" : "Restored";
|
|
679
|
+
const results = [];
|
|
680
|
+
for (const identifier of identifiers) {
|
|
681
|
+
try {
|
|
682
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/archive_state`, { archived });
|
|
683
|
+
results.push({ identifier, ok: true, issue: body.data });
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (error instanceof HttpError || error instanceof TransportError) {
|
|
686
|
+
results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
|
|
687
|
+
} else {
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const human = results.map(
|
|
693
|
+
(result) => result.ok ? `${verb} ${result.identifier}
|
|
694
|
+
` : `FAILED ${result.identifier}: ${errorText(result.error)}
|
|
695
|
+
`
|
|
696
|
+
).join("");
|
|
697
|
+
const json = results.map(
|
|
698
|
+
(result) => result.ok ? { identifier: result.identifier, archived } : { identifier: result.identifier, error: errorText(result.error) }
|
|
699
|
+
);
|
|
700
|
+
const partial = results.some((result) => !result.ok);
|
|
701
|
+
return partial ? { json, human, partial: true } : { json, human };
|
|
702
|
+
}
|
|
703
|
+
async function comments(ctx, identifier) {
|
|
704
|
+
requireIdentifiers([identifier]);
|
|
705
|
+
const records4 = await completeCommentThread(ctx, identifier);
|
|
706
|
+
if (records4.length === 0) return { json: records4, human: "No comments.\n" };
|
|
707
|
+
const human = records4.map((comment2) => {
|
|
708
|
+
const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
|
|
709
|
+
const id = clean(comment2["id"] ?? "unknown id");
|
|
710
|
+
const text = cleanMultiline(str(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
|
|
711
|
+
return `[${str(comment2["inserted_at"])}] ${author} (${id}):
|
|
712
|
+
${text}
|
|
713
|
+
`;
|
|
714
|
+
}).join("\n");
|
|
715
|
+
return { json: records4, human };
|
|
716
|
+
}
|
|
717
|
+
async function completeCommentThread(ctx, identifier) {
|
|
718
|
+
const pages = [];
|
|
719
|
+
const seen = /* @__PURE__ */ new Set();
|
|
720
|
+
let before;
|
|
721
|
+
for (; ; ) {
|
|
722
|
+
const page = await ctx.client.get(`/api/v1/issues/${identifier}/comments`, {
|
|
723
|
+
limit: 200,
|
|
724
|
+
before
|
|
725
|
+
});
|
|
726
|
+
if (!Array.isArray(page.data) || typeof page.meta?.has_more !== "boolean") {
|
|
727
|
+
throw new CommandError("Invalid comment pagination: expected a comment array and boolean meta.has_more; completeness unknown.");
|
|
728
|
+
}
|
|
729
|
+
const records4 = [];
|
|
730
|
+
for (const record of page.data) {
|
|
731
|
+
if (record === null || typeof record !== "object" || Array.isArray(record) || typeof record.id !== "string" || record.id.trim() === "" || seen.has(record.id)) {
|
|
732
|
+
throw new CommandError("Invalid comment pagination: missing or repeated comment id; completeness unknown.");
|
|
733
|
+
}
|
|
734
|
+
seen.add(record.id);
|
|
735
|
+
records4.push(record);
|
|
736
|
+
}
|
|
737
|
+
pages.push(records4);
|
|
738
|
+
if (!page.meta.has_more) return pages.reverse().flat();
|
|
739
|
+
if (records4.length === 0) {
|
|
740
|
+
throw new CommandError("Invalid comment pagination: has_more on an empty page; completeness unknown.");
|
|
741
|
+
}
|
|
742
|
+
before = records4[0]["id"];
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async function dep(ctx, verb, issue, blocker) {
|
|
746
|
+
requireIdentifiers([issue, blocker]);
|
|
747
|
+
if (verb === "add") {
|
|
748
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
|
|
749
|
+
type: "blocked_by",
|
|
750
|
+
related_issue: blocker
|
|
751
|
+
});
|
|
752
|
+
return { json: response.data, human: `${blocker} now blocks ${issue}
|
|
753
|
+
` };
|
|
754
|
+
}
|
|
755
|
+
if (verb === "rm") {
|
|
756
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
757
|
+
const relation = (body.data ?? []).find(
|
|
758
|
+
(candidate) => candidate["direction"] === "blocked_by" && candidate["other_issue"] === blocker
|
|
759
|
+
);
|
|
760
|
+
if (relation === void 0) throw new CommandError(`${blocker} does not block this issue.`);
|
|
761
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/relations/${str(relation["id"])}`);
|
|
762
|
+
return { json: { deleted: relation["id"] }, human: `${blocker} no longer blocks ${issue}
|
|
763
|
+
` };
|
|
764
|
+
}
|
|
765
|
+
throw new UsageError("usage: harness dep add|rm <issue> <blocker> (the blocker blocks the issue)");
|
|
766
|
+
}
|
|
767
|
+
var RELATION_CREATE_TYPES = [
|
|
768
|
+
"blocks",
|
|
769
|
+
"blocked_by",
|
|
770
|
+
"related",
|
|
771
|
+
"duplicate",
|
|
772
|
+
"duplicate_of",
|
|
773
|
+
"duplicated_by"
|
|
774
|
+
];
|
|
775
|
+
var RELATION_UPDATE_TYPES = ["blocks", "blocked_by", "related", "duplicate_of"];
|
|
776
|
+
function requireRelationType(type, allowed, operation) {
|
|
777
|
+
if (!allowed.includes(type)) {
|
|
778
|
+
throw new UsageError(`relation ${operation} type must be one of: ${allowed.join(", ")}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
async function findRelation(ctx, issue, idOrIssue) {
|
|
782
|
+
const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
783
|
+
const idMatch = response.data.find((candidate) => candidate["id"] === idOrIssue);
|
|
784
|
+
if (idMatch !== void 0) return idMatch;
|
|
785
|
+
const issueMatches = response.data.filter((candidate) => candidate["other_issue"] === idOrIssue);
|
|
786
|
+
if (issueMatches.length > 1) {
|
|
787
|
+
throw new CommandError(`multiple relations on ${issue} match ${idOrIssue}; use a relation id`);
|
|
788
|
+
}
|
|
789
|
+
if (issueMatches.length === 0) {
|
|
790
|
+
throw new CommandError(`no relation on ${issue} matches ${idOrIssue} (by id or related issue)`);
|
|
791
|
+
}
|
|
792
|
+
return issueMatches[0];
|
|
793
|
+
}
|
|
794
|
+
async function resolveRelationId(ctx, issue, idOrIssue) {
|
|
795
|
+
if (isUuid(idOrIssue)) return idOrIssue.toLowerCase();
|
|
796
|
+
const relation = await findRelation(ctx, issue, idOrIssue);
|
|
797
|
+
return String(relation["id"]);
|
|
798
|
+
}
|
|
799
|
+
async function relationList(ctx, issue) {
|
|
800
|
+
requireIdentifiers([issue]);
|
|
801
|
+
const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
802
|
+
const human = response.data.length === 0 ? "No relations.\n" : table(
|
|
803
|
+
["ID", "DIRECTION", "ISSUE"],
|
|
804
|
+
response.data.map((relation) => [
|
|
805
|
+
String(relation["id"] ?? ""),
|
|
806
|
+
String(relation["direction"] ?? ""),
|
|
807
|
+
String(relation["other_issue"] ?? "")
|
|
808
|
+
])
|
|
809
|
+
);
|
|
810
|
+
return { json: response.data, human };
|
|
811
|
+
}
|
|
812
|
+
async function relationAdd(ctx, issue, relatedIssue, type) {
|
|
813
|
+
requireIdentifiers([issue, relatedIssue]);
|
|
814
|
+
requireRelationType(type, RELATION_CREATE_TYPES, "add");
|
|
815
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
|
|
816
|
+
type,
|
|
817
|
+
related_issue: relatedIssue
|
|
818
|
+
});
|
|
819
|
+
return { json: response.data, human: `Related ${issue} to ${relatedIssue} as ${type}
|
|
820
|
+
` };
|
|
821
|
+
}
|
|
822
|
+
async function relationUpdate(ctx, issue, idOrIssue, type) {
|
|
823
|
+
requireIdentifiers([issue]);
|
|
824
|
+
requireRelationType(type, RELATION_UPDATE_TYPES, "update");
|
|
825
|
+
const id = await resolveRelationId(ctx, issue, idOrIssue);
|
|
826
|
+
const response = await ctx.client.patch(`/api/v1/issues/${issue}/relations/${id}`, { type });
|
|
827
|
+
return { json: response.data, human: `Updated relation ${id} on ${issue} to ${type}
|
|
828
|
+
` };
|
|
829
|
+
}
|
|
830
|
+
async function relationRm(ctx, issue, idOrIssue) {
|
|
831
|
+
requireIdentifiers([issue]);
|
|
832
|
+
const id = await resolveRelationId(ctx, issue, idOrIssue);
|
|
833
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/relations/${id}`);
|
|
834
|
+
return { json: { deleted: id }, human: `Removed relation ${id} from ${issue}
|
|
835
|
+
` };
|
|
836
|
+
}
|
|
837
|
+
async function comment(ctx, identifier, textWords, mentions) {
|
|
838
|
+
requireIdentifiers([identifier]);
|
|
839
|
+
if (textWords.length === 0) {
|
|
840
|
+
throw new UsageError("usage: harness comment <id> <text> [--mention NAME]\u2026");
|
|
841
|
+
}
|
|
842
|
+
const body = { body: textWords.join(" ") };
|
|
843
|
+
if (mentions.length > 0) body["mentions"] = mentions;
|
|
844
|
+
const response = await ctx.client.post(`/api/v1/issues/${identifier}/comments`, body);
|
|
845
|
+
return { json: response.data, human: `Commented on ${identifier}
|
|
846
|
+
` };
|
|
847
|
+
}
|
|
848
|
+
async function editComment(ctx, identifier, commentId, textWords) {
|
|
849
|
+
requireIdentifiers([identifier]);
|
|
850
|
+
requireCommentId(commentId);
|
|
851
|
+
const body = textWords.join(" ");
|
|
852
|
+
if (body.trim() === "") {
|
|
853
|
+
throw new UsageError("the comment text is empty \u2014 nothing to edit");
|
|
854
|
+
}
|
|
855
|
+
const response = await ctx.client.patch(
|
|
856
|
+
`/api/v1/issues/${identifier}/comments/${commentId}`,
|
|
857
|
+
{ body }
|
|
858
|
+
);
|
|
859
|
+
return {
|
|
860
|
+
json: response.data,
|
|
861
|
+
human: `Edited comment ${commentId} on ${identifier}
|
|
862
|
+
`
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
async function deleteComment(ctx, identifier, commentId) {
|
|
866
|
+
requireIdentifiers([identifier]);
|
|
867
|
+
requireCommentId(commentId);
|
|
868
|
+
const response = await ctx.client.delete(
|
|
869
|
+
`/api/v1/issues/${identifier}/comments/${commentId}`
|
|
870
|
+
);
|
|
871
|
+
return {
|
|
872
|
+
json: response.data,
|
|
873
|
+
human: `Deleted comment ${commentId} from ${identifier}
|
|
874
|
+
`
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
function requireCommentId(commentId) {
|
|
878
|
+
if (!UUID_FORMAT.test(commentId)) {
|
|
879
|
+
throw new UsageError(`not a comment id: ${commentId} (expected a UUID)`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
var MAX_IMAGE_BYTES = 25e6;
|
|
883
|
+
var MAX_VIDEO_BYTES = 1e8;
|
|
884
|
+
async function attachImage(ctx, path) {
|
|
885
|
+
return uploadAttachment(ctx, path, MAX_IMAGE_BYTES);
|
|
886
|
+
}
|
|
887
|
+
async function attachFile(ctx, path) {
|
|
888
|
+
const maxBytes = extname(path).toLowerCase() === ".mp4" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
889
|
+
return uploadAttachment(ctx, path, maxBytes);
|
|
890
|
+
}
|
|
891
|
+
async function uploadAttachment(ctx, path, maxBytes) {
|
|
892
|
+
attachableFile(path, maxBytes);
|
|
893
|
+
const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path)], basename(path)) : new File([readFileSync(path)], basename(path));
|
|
894
|
+
requireAttachmentSize(path, file.size, maxBytes);
|
|
895
|
+
const form = new FormData();
|
|
896
|
+
form.set("file", file);
|
|
897
|
+
const body = await ctx.client.postMultipart("/api/v1/uploads", form, file.size);
|
|
898
|
+
const attachment = body.data;
|
|
899
|
+
const markdown = attachment["markdown"];
|
|
900
|
+
if (typeof markdown !== "string") throw new CommandError("the upload response carried no markdown reference");
|
|
901
|
+
return { json: attachment, human: clean(markdown) + "\n" };
|
|
902
|
+
}
|
|
903
|
+
function attachableFile(path, maxBytes) {
|
|
904
|
+
let fileStats;
|
|
905
|
+
try {
|
|
906
|
+
fileStats = statSync2(path);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
throw new LocalError(`cannot read ${path}: ${posixDetail(error)}`);
|
|
909
|
+
}
|
|
910
|
+
if (!fileStats.isFile()) {
|
|
911
|
+
const kind = fileStats.isDirectory() ? "directory" : "special file";
|
|
912
|
+
throw new LocalError(`${path} is a ${kind}, not a file.`);
|
|
913
|
+
}
|
|
914
|
+
requireAttachmentSize(path, fileStats.size, maxBytes);
|
|
915
|
+
try {
|
|
916
|
+
closeSync(openSync(path, "r"));
|
|
917
|
+
} catch (error) {
|
|
918
|
+
throw new LocalError(`cannot read ${path}: ${posixDetail(error)}`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function requireAttachmentSize(path, size, maxBytes) {
|
|
922
|
+
if (size > maxBytes) {
|
|
923
|
+
throw new LocalError(`${path} is too large: ${size} bytes, and attachments are capped at ${maxBytes}.`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function posixDetail(error) {
|
|
927
|
+
const code = error.code;
|
|
928
|
+
if (code === "ENOENT") return "no such file or directory";
|
|
929
|
+
if (code === "EACCES") return "permission denied";
|
|
930
|
+
return error instanceof Error ? error.message : String(error);
|
|
931
|
+
}
|
|
932
|
+
async function setState(ctx, identifier, stateName) {
|
|
933
|
+
const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
934
|
+
const teamKey = str((showBody.data["team"] ?? {})["key"]);
|
|
935
|
+
const statesBody = await ctx.client.get(
|
|
936
|
+
`/api/v1/teams/${teamKey}/workflow_states`
|
|
937
|
+
);
|
|
938
|
+
const wanted = stateName.toLowerCase();
|
|
939
|
+
const state = statesBody.data.find(
|
|
940
|
+
(candidate) => str(candidate["name"]).toLowerCase() === wanted || str(candidate["type"]) === wanted
|
|
941
|
+
);
|
|
942
|
+
if (state === void 0) {
|
|
943
|
+
const names = statesBody.data.map((candidate) => str(candidate["name"])).join(", ");
|
|
944
|
+
throw new CommandError(`No workflow state matches "${stateName}". States: ${names}`);
|
|
945
|
+
}
|
|
946
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/state`, { state_id: state["id"] });
|
|
947
|
+
return body.data;
|
|
948
|
+
}
|
|
949
|
+
var ISSUE_TYPES = ["task", "bug", "feature", "epic"];
|
|
950
|
+
function wantedLabels(opts) {
|
|
951
|
+
const typeLabels = opts.type === void 0 ? [] : [`type/${opts.type}`];
|
|
952
|
+
return [...typeLabels, ...opts.label ?? []];
|
|
953
|
+
}
|
|
954
|
+
function validateType(type) {
|
|
955
|
+
if (type !== void 0 && !ISSUE_TYPES.includes(type)) {
|
|
956
|
+
throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
function requestedLabelNames(names) {
|
|
960
|
+
const unique = /* @__PURE__ */ new Map();
|
|
961
|
+
for (const name of requestedLabelInputs(names)) {
|
|
962
|
+
const normalized = normalizeLabelName(name);
|
|
963
|
+
if (!unique.has(normalized)) unique.set(normalized, name);
|
|
964
|
+
}
|
|
965
|
+
return [...unique.values()];
|
|
966
|
+
}
|
|
967
|
+
function requestedLabelInputs(values) {
|
|
968
|
+
return values.map((value) => {
|
|
969
|
+
const trimmed = value.trim();
|
|
970
|
+
if (trimmed === "") throw new UsageError("label names and ids cannot be empty");
|
|
971
|
+
return trimmed;
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function normalizeLabelName(value) {
|
|
975
|
+
return str(value).trim().toLowerCase();
|
|
976
|
+
}
|
|
977
|
+
async function prepareCreateLabelIds(ctx, teamKey, names) {
|
|
978
|
+
if (names.length === 0) return [];
|
|
979
|
+
const teams = await ctx.client.get("/api/v1/teams");
|
|
980
|
+
const target = teams.data.find(
|
|
981
|
+
(team) => str(team["key"]).toLowerCase() === teamKey.toLowerCase()
|
|
982
|
+
);
|
|
983
|
+
if (target === void 0) throw new CommandError(`No team matches key "${teamKey}".`);
|
|
984
|
+
const teamId = str(target["id"]);
|
|
985
|
+
if (teamId === "") throw new CommandError(`team ${teamKey} carried no id`);
|
|
986
|
+
return prepareApplicableLabelIds(ctx, teamId, names);
|
|
987
|
+
}
|
|
988
|
+
async function prepareApplicableLabelIds(ctx, teamId, names) {
|
|
989
|
+
let labels = (await ctx.client.get("/api/v1/labels")).data;
|
|
990
|
+
const ids = [];
|
|
991
|
+
for (const name of names) {
|
|
992
|
+
const resolved = await resolveOrCreateApplicableLabel(ctx, labels, name, teamId);
|
|
993
|
+
labels = resolved.catalog;
|
|
994
|
+
ids.push(labelId(resolved.label));
|
|
995
|
+
}
|
|
996
|
+
return [...new Set(ids)];
|
|
997
|
+
}
|
|
998
|
+
function applicableLabel(labels, input, teamId) {
|
|
999
|
+
const normalized = normalizeLabelName(input);
|
|
1000
|
+
const named2 = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
|
|
1001
|
+
const teamMatches = named2.filter((label) => label["team_id"] === teamId);
|
|
1002
|
+
if (teamMatches.length > 1) {
|
|
1003
|
+
throw new CommandError(`more than one team label matches "${input}"; use a label id`);
|
|
1004
|
+
}
|
|
1005
|
+
if (teamMatches.length === 1) return teamMatches[0];
|
|
1006
|
+
const workspaceMatches = named2.filter(
|
|
1007
|
+
(label) => label["team_id"] === null || label["team_id"] === void 0
|
|
1008
|
+
);
|
|
1009
|
+
if (workspaceMatches.length > 1) {
|
|
1010
|
+
throw new CommandError(`more than one workspace label matches "${input}"; use a label id`);
|
|
1011
|
+
}
|
|
1012
|
+
return workspaceMatches[0];
|
|
1013
|
+
}
|
|
1014
|
+
async function resolveOrCreateApplicableLabel(ctx, catalog, name, teamId) {
|
|
1015
|
+
const existing = applicableLabel(catalog, name, teamId);
|
|
1016
|
+
if (existing !== void 0) return { label: existing, catalog };
|
|
1017
|
+
try {
|
|
1018
|
+
const created = await ctx.client.post("/api/v1/labels", {
|
|
1019
|
+
name,
|
|
1020
|
+
color: "gray"
|
|
1021
|
+
});
|
|
1022
|
+
return { label: created.data, catalog: [...catalog, created.data] };
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
if (!(error instanceof HttpError) || error.status !== 422) throw error;
|
|
1025
|
+
const refreshed = (await ctx.client.get("/api/v1/labels")).data;
|
|
1026
|
+
const winner = applicableLabel(refreshed, name, teamId);
|
|
1027
|
+
if (winner === void 0) throw error;
|
|
1028
|
+
return { label: winner, catalog: refreshed };
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
function labelId(label) {
|
|
1032
|
+
const id = str(label["id"]);
|
|
1033
|
+
if (id === "") throw new CommandError("the label response carried no id");
|
|
1034
|
+
return id;
|
|
1035
|
+
}
|
|
1036
|
+
async function prepareLabelUpdate(ctx, identifier, addInputs, removeInputs) {
|
|
1037
|
+
const issue = (await ctx.client.get(`/api/v1/issues/${identifier}`)).data;
|
|
1038
|
+
const attached = Array.isArray(issue["labels"]) ? issue["labels"] : [];
|
|
1039
|
+
const originalIds = attached.map(labelId);
|
|
1040
|
+
const removed = /* @__PURE__ */ new Set();
|
|
1041
|
+
for (const input of removeInputs) {
|
|
1042
|
+
const idInput = UUID_FORMAT.test(input);
|
|
1043
|
+
const exact = attached.find((label) => {
|
|
1044
|
+
const id = labelId(label);
|
|
1045
|
+
return idInput ? id.toLowerCase() === input.toLowerCase() : id === input;
|
|
1046
|
+
});
|
|
1047
|
+
if (exact !== void 0) {
|
|
1048
|
+
removed.add(labelId(exact));
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const normalized = normalizeLabelName(input);
|
|
1052
|
+
const matches = attached.filter((label) => normalizeLabelName(label["name"]) === normalized);
|
|
1053
|
+
if (matches.length > 1) {
|
|
1054
|
+
throw new CommandError(`more than one attached label matches "${input}"; remove by label id`);
|
|
1055
|
+
}
|
|
1056
|
+
if (matches.length === 1) removed.add(labelId(matches[0]));
|
|
1057
|
+
}
|
|
1058
|
+
const ids = originalIds.filter((id) => !removed.has(id));
|
|
1059
|
+
if (addInputs.length > 0) {
|
|
1060
|
+
const team = issue["team"] ?? {};
|
|
1061
|
+
const teamId = str(team["id"]);
|
|
1062
|
+
if (teamId === "") throw new CommandError(`issue ${identifier} carried no team id`);
|
|
1063
|
+
let catalog = (await ctx.client.get("/api/v1/labels")).data;
|
|
1064
|
+
for (const input of addInputs) {
|
|
1065
|
+
const idInput = UUID_FORMAT.test(input);
|
|
1066
|
+
let chosen = idInput ? catalog.find((label) => labelId(label).toLowerCase() === input.toLowerCase()) : void 0;
|
|
1067
|
+
if (chosen !== void 0) {
|
|
1068
|
+
const owner = chosen["team_id"];
|
|
1069
|
+
if (owner !== null && owner !== void 0 && owner !== teamId) {
|
|
1070
|
+
throw new CommandError(
|
|
1071
|
+
`label ${input} belongs to another team and cannot be added to ${identifier}`
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
} else {
|
|
1075
|
+
if (idInput) throw new CommandError(`No label matches id "${input}".`);
|
|
1076
|
+
const resolved = await resolveOrCreateApplicableLabel(ctx, catalog, input, teamId);
|
|
1077
|
+
chosen = resolved.label;
|
|
1078
|
+
catalog = resolved.catalog;
|
|
1079
|
+
}
|
|
1080
|
+
const id = labelId(chosen);
|
|
1081
|
+
if (!ids.includes(id)) ids.push(id);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
issue,
|
|
1086
|
+
ids,
|
|
1087
|
+
changed: ids.length !== originalIds.length || ids.some((id, index) => id !== originalIds[index])
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
async function fetchIssues(ctx, params, opts) {
|
|
1091
|
+
if (opts.all === true && opts.page !== void 0) {
|
|
1092
|
+
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1093
|
+
}
|
|
1094
|
+
if (opts.all === true) {
|
|
1095
|
+
const records5 = await fetchEveryPage(
|
|
1096
|
+
(page) => ctx.client.get("/api/v1/issues", { ...params, page })
|
|
1097
|
+
);
|
|
1098
|
+
return { records: records5, notice: "" };
|
|
1099
|
+
}
|
|
1100
|
+
const body = await ctx.client.get("/api/v1/issues", params);
|
|
1101
|
+
const records4 = body.data;
|
|
1102
|
+
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
1103
|
+
return { records: records4, notice: notices(body.meta, requested, records4) };
|
|
1104
|
+
}
|
|
1105
|
+
function listParams(ctx, opts) {
|
|
1106
|
+
if (opts.type !== void 0 && opts.label !== void 0) {
|
|
1107
|
+
throw new UsageError("--type is a label filter itself \u2014 pass either --type or --label, not both");
|
|
1108
|
+
}
|
|
1109
|
+
validateType(opts.type);
|
|
1110
|
+
let params = withTeam({ page_size: opts.limit ?? 50, page: opts.page ?? 1 }, ctx, opts);
|
|
1111
|
+
params = putParam(params, "search", opts.search);
|
|
1112
|
+
params = putParam(params, "parent", opts.parent);
|
|
1113
|
+
params = putParam(params, "label", opts.label);
|
|
1114
|
+
params = putParam(params, "scope", opts.scope);
|
|
1115
|
+
params = putParam(params, "assignee", opts.assignee);
|
|
1116
|
+
params = putParam(params, "created_by", opts.createdBy);
|
|
1117
|
+
params = putParam(params, "involved", opts.involved);
|
|
1118
|
+
if (opts.type !== void 0) params = putParam(params, "label", `type/${opts.type}`);
|
|
1119
|
+
if (opts.status !== void 0) {
|
|
1120
|
+
const stateTypes = Object.hasOwn(STATUS_STATE_TYPES, opts.status) ? STATUS_STATE_TYPES[opts.status] : void 0;
|
|
1121
|
+
if (stateTypes === void 0) {
|
|
1122
|
+
throw new UsageError(`status must be one of: ${Object.keys(STATUS_STATE_TYPES).join(", ")}`);
|
|
1123
|
+
}
|
|
1124
|
+
params = putParam(params, "state_type", stateTypes);
|
|
1125
|
+
}
|
|
1126
|
+
const priority = optionalPriority(opts.priority);
|
|
1127
|
+
if (priority !== null) params = putParam(params, "priority", priority);
|
|
1128
|
+
return params;
|
|
1129
|
+
}
|
|
1130
|
+
function optionalPriority(value) {
|
|
1131
|
+
if (value === void 0) return null;
|
|
1132
|
+
const named2 = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
|
|
1133
|
+
if (named2 !== void 0) return named2;
|
|
1134
|
+
if (/^\d+$/.test(value)) {
|
|
1135
|
+
throw new UsageError(
|
|
1136
|
+
`Bare numeric priorities are refused: bd and harness disagree on what
|
|
1137
|
+
the numbers mean, so a habit-typed number would silently file the
|
|
1138
|
+
wrong priority.
|
|
1139
|
+
|
|
1140
|
+
bd: 0=critical 1=high 2=medium 3=normal 4=backlog
|
|
1141
|
+
harness: 0=none 1=urgent 2=high 3=medium 4=low
|
|
1142
|
+
|
|
1143
|
+
Use a name instead: --priority urgent|high|medium|low|none
|
|
1144
|
+
`
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
throw new UsageError("priority must be one of: urgent, high, medium, low, none");
|
|
1148
|
+
}
|
|
1149
|
+
function optionalDueDate(value) {
|
|
1150
|
+
if (value === void 0) return null;
|
|
1151
|
+
const trimmed = value.trim();
|
|
1152
|
+
if (trimmed === "") return "clear";
|
|
1153
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
1154
|
+
if (match === null) throw new UsageError(`not a date: ${trimmed} (expected YYYY-MM-DD)`);
|
|
1155
|
+
const [, year, month, day] = match.map(Number);
|
|
1156
|
+
const candidate = /* @__PURE__ */ new Date(0);
|
|
1157
|
+
candidate.setUTCFullYear(year, month - 1, day);
|
|
1158
|
+
const real = candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day;
|
|
1159
|
+
if (!real) throw new UsageError(`not a date: ${trimmed} (no such day on the calendar)`);
|
|
1160
|
+
return trimmed;
|
|
1161
|
+
}
|
|
1162
|
+
var TEAM_KEY_FORMAT = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
1163
|
+
function requiredTeam(ctx, team) {
|
|
1164
|
+
const resolved = team ?? ctx.config.team ?? void 0;
|
|
1165
|
+
if (resolved === void 0) throw new UsageError("No team given. Pass --team <key> or set HARNESS_TEAM.");
|
|
1166
|
+
if (!TEAM_KEY_FORMAT.test(resolved)) throw new UsageError(`not a team key: ${resolved} (expected e.g. ALL)`);
|
|
1167
|
+
return resolved;
|
|
1168
|
+
}
|
|
1169
|
+
function withTeam(params, ctx, opts) {
|
|
1170
|
+
const team = opts.team ?? ctx.config.team ?? void 0;
|
|
1171
|
+
return team === void 0 ? params : { ...params, team };
|
|
1172
|
+
}
|
|
1173
|
+
function putParam(params, key, value) {
|
|
1174
|
+
return value === void 0 ? params : { ...params, [key]: value };
|
|
1175
|
+
}
|
|
1176
|
+
function putOpt(body, field3, value) {
|
|
1177
|
+
return value === void 0 ? body : { ...body, [field3]: value };
|
|
1178
|
+
}
|
|
1179
|
+
function putPresent(body, key, value) {
|
|
1180
|
+
if (value === null || value === void 0 || value === false) return body;
|
|
1181
|
+
return { ...body, [key]: value };
|
|
1182
|
+
}
|
|
1183
|
+
function putDueDate(body, dueDate) {
|
|
1184
|
+
if (dueDate === null) return body;
|
|
1185
|
+
if (dueDate === "clear") return { ...body, due_date: null };
|
|
1186
|
+
return { ...body, due_date: dueDate };
|
|
1187
|
+
}
|
|
1188
|
+
function putAssignee(body, assignee) {
|
|
1189
|
+
if (assignee === void 0) return body;
|
|
1190
|
+
return { ...body, assignee: assignee.trim() === "" ? null : assignee };
|
|
1191
|
+
}
|
|
1192
|
+
function errorText(error) {
|
|
1193
|
+
if (error instanceof HttpError) return `${clean(error.message)} (HTTP ${error.status})`;
|
|
1194
|
+
return `cannot reach the API: ${clean(error.message)}`;
|
|
1195
|
+
}
|
|
1196
|
+
function str(value) {
|
|
1197
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// src/commands/extended.ts
|
|
1201
|
+
var SAFE_ID = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
|
|
1202
|
+
function requireSafeId(kind, id) {
|
|
1203
|
+
if (!SAFE_ID.test(id)) throw new UsageError(`not a ${kind} id: ${id}`);
|
|
1204
|
+
}
|
|
1205
|
+
function str2(value) {
|
|
1206
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
1207
|
+
}
|
|
1208
|
+
function field(row, ...keys) {
|
|
1209
|
+
let current = row;
|
|
1210
|
+
for (const key of keys) {
|
|
1211
|
+
if (typeof current !== "object" || current === null) return null;
|
|
1212
|
+
current = current[key];
|
|
1213
|
+
}
|
|
1214
|
+
return current ?? null;
|
|
1215
|
+
}
|
|
1216
|
+
function putOpt2(body, key, value) {
|
|
1217
|
+
return value === void 0 ? body : { ...body, [key]: value };
|
|
1218
|
+
}
|
|
1219
|
+
function putDate(body, key, raw) {
|
|
1220
|
+
const parsed = optionalDueDate(raw);
|
|
1221
|
+
if (parsed === null) return body;
|
|
1222
|
+
return { ...body, [key]: parsed === "clear" ? null : parsed };
|
|
1223
|
+
}
|
|
1224
|
+
async function paginatedRead(ctx, path, params, opts) {
|
|
1225
|
+
if (opts.all === true && opts.page !== void 0) {
|
|
1226
|
+
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1227
|
+
}
|
|
1228
|
+
if (opts.all === true) {
|
|
1229
|
+
const records4 = await fetchEveryPage((page) => ctx.client.get(path, { ...params, page }));
|
|
1230
|
+
return { records: records4, notice: "" };
|
|
1231
|
+
}
|
|
1232
|
+
const body = await ctx.client.get(path, params);
|
|
1233
|
+
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
1234
|
+
return { records: body.data, notice: notices(body.meta, requested, body.data) };
|
|
1235
|
+
}
|
|
1236
|
+
async function projectList(ctx, opts) {
|
|
1237
|
+
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1238
|
+
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1239
|
+
if (opts.state !== void 0) params = { ...params, state: opts.state };
|
|
1240
|
+
const { records: records4, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
|
|
1241
|
+
const human = records4.length === 0 ? "No projects.\n" : table(
|
|
1242
|
+
["ID", "STATE", "LEAD", "TARGET", "NAME"],
|
|
1243
|
+
records4.map((project) => [
|
|
1244
|
+
str2(project["id"]),
|
|
1245
|
+
str2(project["state"]) || "-",
|
|
1246
|
+
str2(field(project, "lead", "name")) || "-",
|
|
1247
|
+
str2(project["target_date"]) || "-",
|
|
1248
|
+
str2(project["name"])
|
|
1249
|
+
])
|
|
1250
|
+
);
|
|
1251
|
+
return { json: records4, human, notice };
|
|
1252
|
+
}
|
|
1253
|
+
async function projectShow(ctx, id) {
|
|
1254
|
+
requireSafeId("project", id);
|
|
1255
|
+
const body = await ctx.client.get(`/api/v1/projects/${id}`);
|
|
1256
|
+
const project = body.data;
|
|
1257
|
+
const fields = [
|
|
1258
|
+
["State", project["state"]],
|
|
1259
|
+
["Lead", field(project, "lead", "name")],
|
|
1260
|
+
["Start", project["start_date"]],
|
|
1261
|
+
["Target", project["target_date"]],
|
|
1262
|
+
["Updated", project["updated_at"]]
|
|
1263
|
+
];
|
|
1264
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
1265
|
+
`).join("");
|
|
1266
|
+
const sections = [
|
|
1267
|
+
["Summary", project["summary"]],
|
|
1268
|
+
["Description", project["description"]]
|
|
1269
|
+
];
|
|
1270
|
+
const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
|
|
1271
|
+
${title}:
|
|
1272
|
+
${indent(String(text))}
|
|
1273
|
+
`).join("");
|
|
1274
|
+
const milestones = Array.isArray(project["milestones"]) ? project["milestones"] : [];
|
|
1275
|
+
const milestoneText = milestones.length === 0 ? "" : "\nMilestones:\n" + milestones.map((milestone) => ` ${(str2(milestone["target_date"]) || "-").padEnd(12)}${clean(milestone["name"])}
|
|
1276
|
+
`).join("");
|
|
1277
|
+
const human = `${str2(project["id"])} \xB7 ${clean(project["name"])}
|
|
1278
|
+
` + fieldLines + sectionText + milestoneText;
|
|
1279
|
+
return { json: project, human };
|
|
1280
|
+
}
|
|
1281
|
+
function projectBody(opts) {
|
|
1282
|
+
let body = {};
|
|
1283
|
+
body = putOpt2(body, "name", opts.name);
|
|
1284
|
+
body = putOpt2(body, "description", opts.description);
|
|
1285
|
+
body = putOpt2(body, "summary", opts.summary);
|
|
1286
|
+
body = putOpt2(body, "state", opts.state);
|
|
1287
|
+
body = putOpt2(body, "lead_id", opts.lead);
|
|
1288
|
+
body = putDate(body, "start_date", opts.startDate);
|
|
1289
|
+
body = putDate(body, "target_date", opts.targetDate);
|
|
1290
|
+
return body;
|
|
1291
|
+
}
|
|
1292
|
+
async function projectCreate(ctx, opts) {
|
|
1293
|
+
if (opts.name === void 0) throw new UsageError("usage: harness project create --name <name> \u2026");
|
|
1294
|
+
const body = await ctx.client.post("/api/v1/projects", projectBody(opts));
|
|
1295
|
+
const project = body.data;
|
|
1296
|
+
return { json: project, human: `Created project ${clean(project["name"])} (${str2(project["id"])})
|
|
1297
|
+
` };
|
|
1298
|
+
}
|
|
1299
|
+
async function projectUpdate(ctx, id, opts) {
|
|
1300
|
+
requireSafeId("project", id);
|
|
1301
|
+
const patch = projectBody(opts);
|
|
1302
|
+
if (Object.keys(patch).length === 0) {
|
|
1303
|
+
throw new UsageError("usage: harness project update <id> --name \u2026 | --state \u2026 | \u2026");
|
|
1304
|
+
}
|
|
1305
|
+
const body = await ctx.client.patch(`/api/v1/projects/${id}`, patch);
|
|
1306
|
+
return { json: body.data, human: `Updated project ${str2(body.data["id"])}
|
|
1307
|
+
` };
|
|
1308
|
+
}
|
|
1309
|
+
async function milestoneList(ctx, project) {
|
|
1310
|
+
if (project === void 0) throw new UsageError("usage: harness milestone list --project <id>");
|
|
1311
|
+
requireSafeId("project", project);
|
|
1312
|
+
const body = await ctx.client.get(`/api/v1/projects/${project}/milestones`);
|
|
1313
|
+
const human = body.data.length === 0 ? "No milestones.\n" : table(
|
|
1314
|
+
["ID", "TARGET", "NAME"],
|
|
1315
|
+
body.data.map((milestone) => [
|
|
1316
|
+
str2(milestone["id"]),
|
|
1317
|
+
str2(milestone["target_date"]) || "-",
|
|
1318
|
+
str2(milestone["name"])
|
|
1319
|
+
])
|
|
1320
|
+
);
|
|
1321
|
+
return { json: body.data, human };
|
|
1322
|
+
}
|
|
1323
|
+
async function milestoneCreate(ctx, opts) {
|
|
1324
|
+
if (opts.project === void 0 || opts.name === void 0) {
|
|
1325
|
+
throw new UsageError("usage: harness milestone create --project <id> --name <name> [--description TEXT] [--target-date YYYY-MM-DD]");
|
|
1326
|
+
}
|
|
1327
|
+
requireSafeId("project", opts.project);
|
|
1328
|
+
let body = { name: opts.name };
|
|
1329
|
+
body = putOpt2(body, "description", opts.description);
|
|
1330
|
+
body = putDate(body, "target_date", opts.targetDate);
|
|
1331
|
+
const response = await ctx.client.post(`/api/v1/projects/${opts.project}/milestones`, body);
|
|
1332
|
+
const milestone = response.data;
|
|
1333
|
+
return {
|
|
1334
|
+
json: milestone,
|
|
1335
|
+
human: `Created milestone ${clean(milestone["name"])} (${str2(milestone["id"])})
|
|
1336
|
+
`
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
async function docList(ctx, opts) {
|
|
1340
|
+
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1341
|
+
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1342
|
+
const { records: records4, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
|
|
1343
|
+
const human = records4.length === 0 ? "No documents.\n" : table(
|
|
1344
|
+
["ID", "UPDATED", "TITLE"],
|
|
1345
|
+
records4.map((doc) => [str2(doc["id"]), str2(doc["updated_at"]) || "-", str2(doc["title"])])
|
|
1346
|
+
);
|
|
1347
|
+
return { json: records4, human, notice };
|
|
1348
|
+
}
|
|
1349
|
+
async function docShow(ctx, id) {
|
|
1350
|
+
requireSafeId("document", id);
|
|
1351
|
+
const body = await ctx.client.get(`/api/v1/documents/${id}`);
|
|
1352
|
+
const doc = body.data;
|
|
1353
|
+
const content = doc["content"];
|
|
1354
|
+
const human = `${str2(doc["id"])} \xB7 ${clean(doc["title"])}
|
|
1355
|
+
Updated: ${clean(doc["updated_at"])}
|
|
1356
|
+
` + (content === null || content === void 0 || content === "" ? "" : "\n" + cleanMultiline(String(content)) + "\n");
|
|
1357
|
+
return { json: doc, human };
|
|
1358
|
+
}
|
|
1359
|
+
async function docCreate(ctx, opts) {
|
|
1360
|
+
if (opts.title === void 0) throw new UsageError("usage: harness doc create --title <title> [--content TEXT] [--project ID]");
|
|
1361
|
+
let body = { title: opts.title };
|
|
1362
|
+
body = putOpt2(body, "content", opts.content);
|
|
1363
|
+
body = putOpt2(body, "project_id", opts.project);
|
|
1364
|
+
const response = await ctx.client.post("/api/v1/documents", body);
|
|
1365
|
+
const doc = response.data;
|
|
1366
|
+
return { json: doc, human: `Created document ${clean(doc["title"])} (${str2(doc["id"])})
|
|
1367
|
+
` };
|
|
1368
|
+
}
|
|
1369
|
+
async function docUpdate(ctx, id, opts) {
|
|
1370
|
+
requireSafeId("document", id);
|
|
1371
|
+
let patch = {};
|
|
1372
|
+
patch = putOpt2(patch, "title", opts.title);
|
|
1373
|
+
patch = putOpt2(patch, "content", opts.content);
|
|
1374
|
+
patch = putOpt2(patch, "project_id", opts.project);
|
|
1375
|
+
if (Object.keys(patch).length === 0) {
|
|
1376
|
+
throw new UsageError("usage: harness doc update <id> --title \u2026 | --content \u2026 | --project \u2026");
|
|
1377
|
+
}
|
|
1378
|
+
const response = await ctx.client.patch(`/api/v1/documents/${id}`, patch);
|
|
1379
|
+
return { json: response.data, human: `Updated document ${str2(response.data["id"])}
|
|
1380
|
+
` };
|
|
1381
|
+
}
|
|
1382
|
+
async function linkList(ctx, issue) {
|
|
1383
|
+
requireIdentifiers([issue]);
|
|
1384
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
|
|
1385
|
+
const human = body.data.length === 0 ? "No links.\n" : table(
|
|
1386
|
+
["ID", "URL", "TITLE"],
|
|
1387
|
+
body.data.map((link) => [str2(link["id"]), str2(link["url"]), str2(link["title"]) || "-"])
|
|
1388
|
+
);
|
|
1389
|
+
return { json: body.data, human };
|
|
1390
|
+
}
|
|
1391
|
+
async function linkAdd(ctx, issue, url, title) {
|
|
1392
|
+
requireIdentifiers([issue]);
|
|
1393
|
+
let body = { url };
|
|
1394
|
+
body = putOpt2(body, "title", title);
|
|
1395
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/links`, body);
|
|
1396
|
+
return { json: response.data, human: `Linked ${clean(url)} to ${issue}
|
|
1397
|
+
` };
|
|
1398
|
+
}
|
|
1399
|
+
async function findLink(ctx, issue, idOrUrl, allowDuplicateUrl = false) {
|
|
1400
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
|
|
1401
|
+
const idMatch = body.data.find((candidate) => candidate["id"] === idOrUrl);
|
|
1402
|
+
if (idMatch !== void 0) return idMatch;
|
|
1403
|
+
const urlMatches = body.data.filter((candidate) => candidate["url"] === idOrUrl);
|
|
1404
|
+
if (urlMatches.length > 1 && !allowDuplicateUrl) {
|
|
1405
|
+
throw new CommandError(`multiple links on ${issue} match ${idOrUrl}; use a link id`);
|
|
1406
|
+
}
|
|
1407
|
+
if (urlMatches.length === 0) {
|
|
1408
|
+
throw new CommandError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
|
|
1409
|
+
}
|
|
1410
|
+
return urlMatches[0];
|
|
1411
|
+
}
|
|
1412
|
+
async function linkUpdate(ctx, issue, idOrUrl, opts) {
|
|
1413
|
+
requireIdentifiers([issue]);
|
|
1414
|
+
let patch = {};
|
|
1415
|
+
patch = putOpt2(patch, "url", opts.url);
|
|
1416
|
+
patch = putOpt2(patch, "title", opts.title);
|
|
1417
|
+
if (Object.keys(patch).length === 0) {
|
|
1418
|
+
throw new UsageError("usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>");
|
|
1419
|
+
}
|
|
1420
|
+
const id = isUuid(idOrUrl) ? idOrUrl.toLowerCase() : str2((await findLink(ctx, issue, idOrUrl))["id"]);
|
|
1421
|
+
if (!isUuid(idOrUrl)) patch["expected_url"] = idOrUrl;
|
|
1422
|
+
const response = await ctx.client.patch(`/api/v1/issues/${issue}/links/${id}`, patch);
|
|
1423
|
+
return { json: response.data, human: `Updated link ${clean(response.data["url"])} on ${issue}
|
|
1424
|
+
` };
|
|
1425
|
+
}
|
|
1426
|
+
async function linkRm(ctx, issue, idOrUrl) {
|
|
1427
|
+
requireIdentifiers([issue]);
|
|
1428
|
+
const link = await findLink(ctx, issue, idOrUrl, true);
|
|
1429
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/links/${str2(link["id"])}`);
|
|
1430
|
+
return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
|
|
1431
|
+
` };
|
|
1432
|
+
}
|
|
1433
|
+
async function labelList(ctx) {
|
|
1434
|
+
const body = await ctx.client.get("/api/v1/labels");
|
|
1435
|
+
const human = body.data.length === 0 ? "No labels.\n" : table(
|
|
1436
|
+
["NAME", "COLOR", "ISSUES", "ID"],
|
|
1437
|
+
body.data.map((label) => [
|
|
1438
|
+
str2(label["name"]),
|
|
1439
|
+
str2(label["color"]) || "-",
|
|
1440
|
+
str2(label["issue_count"] ?? 0),
|
|
1441
|
+
str2(label["id"])
|
|
1442
|
+
])
|
|
1443
|
+
);
|
|
1444
|
+
return { json: body.data, human };
|
|
1445
|
+
}
|
|
1446
|
+
async function labelCreate(ctx, name, color) {
|
|
1447
|
+
if (name === void 0) throw new UsageError("usage: harness label create <name> [--color <color>]");
|
|
1448
|
+
let body = { name };
|
|
1449
|
+
body = putOpt2(body, "color", color);
|
|
1450
|
+
const response = await ctx.client.post("/api/v1/labels", body);
|
|
1451
|
+
return { json: response.data, human: `Created label ${clean(response.data["name"])}
|
|
1452
|
+
` };
|
|
1453
|
+
}
|
|
1454
|
+
var TEAM_CREATE_KEY = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
1455
|
+
async function teamCreate(ctx, opts) {
|
|
1456
|
+
if (opts.key === void 0 || opts.name === void 0) {
|
|
1457
|
+
throw new UsageError("usage: harness team create --key <key> --name <name>");
|
|
1458
|
+
}
|
|
1459
|
+
if (!TEAM_CREATE_KEY.test(opts.key)) {
|
|
1460
|
+
throw new UsageError(`not a team key: ${opts.key} (expected e.g. ALL \u2014 letters and digits, letter first)`);
|
|
1461
|
+
}
|
|
1462
|
+
const created = await ctx.client.post("/api/v1/teams", { key: opts.key, name: opts.name });
|
|
1463
|
+
const team = created.data;
|
|
1464
|
+
const states = await ctx.client.get(`/api/v1/teams/${str2(team["key"])}/workflow_states`);
|
|
1465
|
+
const human = `Created team ${str2(team["key"])} \u2014 ${clean(team["name"])}
|
|
1466
|
+
` + table(
|
|
1467
|
+
["NAME", "TYPE"],
|
|
1468
|
+
states.data.map((state) => [str2(state["name"]), str2(state["type"])])
|
|
1469
|
+
);
|
|
1470
|
+
return { json: { ...team, workflow_states: states.data }, human };
|
|
1471
|
+
}
|
|
1472
|
+
async function teamList(ctx) {
|
|
1473
|
+
const body = await ctx.client.get("/api/v1/teams");
|
|
1474
|
+
const human = body.data.length === 0 ? "No teams.\n" : table(
|
|
1475
|
+
["KEY", "NAME", "ISSUES", "DEFAULT"],
|
|
1476
|
+
body.data.map((team) => [
|
|
1477
|
+
str2(team["key"]),
|
|
1478
|
+
str2(team["name"]),
|
|
1479
|
+
str2(team["issue_count"] ?? 0),
|
|
1480
|
+
team["is_default"] === true ? "yes" : ""
|
|
1481
|
+
])
|
|
1482
|
+
);
|
|
1483
|
+
return { json: body.data, human };
|
|
1484
|
+
}
|
|
1485
|
+
async function workflowStates(ctx, team) {
|
|
1486
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, team);
|
|
1487
|
+
const body = await ctx.client.get(`/api/v1/teams/${key}/workflow_states`);
|
|
1488
|
+
const human = body.data.length === 0 ? "No workflow states.\n" : table(
|
|
1489
|
+
["NAME", "TYPE", "COLOR", "ID"],
|
|
1490
|
+
body.data.map((state) => [
|
|
1491
|
+
str2(state["name"]),
|
|
1492
|
+
str2(state["type"]),
|
|
1493
|
+
str2(state["color"]) || "-",
|
|
1494
|
+
str2(state["id"])
|
|
1495
|
+
])
|
|
1496
|
+
);
|
|
1497
|
+
return { json: body.data, human };
|
|
1498
|
+
}
|
|
1499
|
+
async function userList(ctx) {
|
|
1500
|
+
const body = await ctx.client.get("/api/v1/users");
|
|
1501
|
+
const human = body.data.length === 0 ? "No users.\n" : table(
|
|
1502
|
+
["NAME", "EMAIL", "ID"],
|
|
1503
|
+
body.data.map((user) => [str2(user["name"]), str2(user["email"]), str2(user["id"])])
|
|
1504
|
+
);
|
|
1505
|
+
return { json: body.data, human };
|
|
1506
|
+
}
|
|
1507
|
+
async function teamRename(ctx, key, name) {
|
|
1508
|
+
if (key === void 0 || name === void 0) {
|
|
1509
|
+
throw new UsageError("usage: harness team rename <key> --name <name>");
|
|
1510
|
+
}
|
|
1511
|
+
const body = await ctx.client.patch(`/api/v1/teams/${encodeURIComponent(key)}`, { name });
|
|
1512
|
+
return { json: body.data, human: `Renamed ${str2(body.data["key"])} to ${clean(body.data["name"])}
|
|
1513
|
+
` };
|
|
1514
|
+
}
|
|
1515
|
+
async function teamSetDefault(ctx, key) {
|
|
1516
|
+
if (key === void 0) throw new UsageError("usage: harness team set-default <key>");
|
|
1517
|
+
const body = await ctx.client.put(`/api/v1/teams/${encodeURIComponent(key)}/default`, {});
|
|
1518
|
+
return {
|
|
1519
|
+
json: body.data,
|
|
1520
|
+
human: `${str2(body.data["key"])} is now the default filing target
|
|
1521
|
+
`
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
async function stateAdd(ctx, opts) {
|
|
1525
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
1526
|
+
if (opts.name === void 0 || opts.type === void 0) {
|
|
1527
|
+
throw new UsageError("usage: harness state add --name <name> --type <type> [--color <color>] [--team <key>]");
|
|
1528
|
+
}
|
|
1529
|
+
let payload = { name: opts.name, type: opts.type };
|
|
1530
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
1531
|
+
const body = await ctx.client.post(`/api/v1/teams/${key}/workflow_states`, payload);
|
|
1532
|
+
return { json: body.data, human: `Added ${clean(body.data["name"])} to ${key}
|
|
1533
|
+
` };
|
|
1534
|
+
}
|
|
1535
|
+
async function stateUpdate(ctx, id, opts) {
|
|
1536
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
1537
|
+
if (id === void 0) throw new UsageError("usage: harness state update <id> [--name <name>] [--type <type>] [--color <color>]");
|
|
1538
|
+
if (opts.name === void 0 && opts.type === void 0 && opts.color === void 0) {
|
|
1539
|
+
throw new UsageError("nothing to change \u2014 pass at least one of --name, --type, --color");
|
|
1540
|
+
}
|
|
1541
|
+
let payload = {};
|
|
1542
|
+
payload = putOpt2(payload, "name", opts.name);
|
|
1543
|
+
payload = putOpt2(payload, "type", opts.type);
|
|
1544
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
1545
|
+
const body = await ctx.client.patch(`/api/v1/teams/${key}/workflow_states/${encodeURIComponent(id)}`, payload);
|
|
1546
|
+
return { json: body.data, human: `Updated ${clean(body.data["name"])}
|
|
1547
|
+
` };
|
|
1548
|
+
}
|
|
1549
|
+
async function stateOrder(ctx, ids, opts) {
|
|
1550
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
1551
|
+
if (ids === void 0) {
|
|
1552
|
+
throw new UsageError("usage: harness state order <id,id,id> [--team <key>] \u2014 every state id, in the order you want");
|
|
1553
|
+
}
|
|
1554
|
+
const state_ids = ids.split(",").map((id) => id.trim()).filter((id) => id !== "");
|
|
1555
|
+
if (state_ids.length === 0) throw new UsageError("no state ids given");
|
|
1556
|
+
const body = await ctx.client.put(`/api/v1/teams/${key}/workflow_states/order`, { state_ids });
|
|
1557
|
+
const human = table(
|
|
1558
|
+
["NAME", "TYPE"],
|
|
1559
|
+
body.data.map((state) => [str2(state["name"]), str2(state["type"])])
|
|
1560
|
+
);
|
|
1561
|
+
return { json: body.data, human };
|
|
1562
|
+
}
|
|
1563
|
+
async function stateDelete(ctx, id, opts) {
|
|
1564
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
1565
|
+
if (id === void 0) throw new UsageError("usage: harness state delete <id> --yes [--team <key>]");
|
|
1566
|
+
requireYes("state delete", id, opts.yes);
|
|
1567
|
+
await ctx.client.delete(`/api/v1/teams/${key}/workflow_states/${encodeURIComponent(id)}`);
|
|
1568
|
+
return { json: { deleted: id }, human: `Deleted workflow state ${id}
|
|
1569
|
+
` };
|
|
1570
|
+
}
|
|
1571
|
+
async function labelUpdate(ctx, id, opts) {
|
|
1572
|
+
if (id === void 0) throw new UsageError("usage: harness label update <id> [--name <name>] [--color <color>]");
|
|
1573
|
+
if (opts.name === void 0 && opts.color === void 0) {
|
|
1574
|
+
throw new UsageError("nothing to change \u2014 pass --name, --color, or both");
|
|
1575
|
+
}
|
|
1576
|
+
let payload = {};
|
|
1577
|
+
payload = putOpt2(payload, "name", opts.name);
|
|
1578
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
1579
|
+
const body = await ctx.client.patch(`/api/v1/labels/${encodeURIComponent(id)}`, payload);
|
|
1580
|
+
return { json: body.data, human: `Updated label ${clean(body.data["name"])}
|
|
1581
|
+
` };
|
|
1582
|
+
}
|
|
1583
|
+
async function labelDelete(ctx, id, opts) {
|
|
1584
|
+
if (id === void 0) throw new UsageError("usage: harness label delete <id> --yes");
|
|
1585
|
+
requireYes("label delete", id, opts.yes);
|
|
1586
|
+
await ctx.client.delete(`/api/v1/labels/${encodeURIComponent(id)}`);
|
|
1587
|
+
return { json: { deleted: id }, human: `Deleted label ${id}
|
|
1588
|
+
` };
|
|
1589
|
+
}
|
|
1590
|
+
async function memberList(ctx) {
|
|
1591
|
+
const body = await ctx.client.get("/api/v1/members");
|
|
1592
|
+
const human = body.data.length === 0 ? "No members.\n" : table(
|
|
1593
|
+
["NAME", "EMAIL", "RANK", "STANDING", "ID"],
|
|
1594
|
+
body.data.map((member) => [
|
|
1595
|
+
str2(field(member, "user", "name")),
|
|
1596
|
+
str2(field(member, "user", "email")),
|
|
1597
|
+
str2(member["role"]) || "-",
|
|
1598
|
+
str2(member["standing"]),
|
|
1599
|
+
str2(field(member, "user", "id"))
|
|
1600
|
+
])
|
|
1601
|
+
);
|
|
1602
|
+
return { json: body.data, human };
|
|
1603
|
+
}
|
|
1604
|
+
async function memberAdd(ctx, opts) {
|
|
1605
|
+
if (opts.email === void 0 || opts.role === void 0) {
|
|
1606
|
+
throw new UsageError("usage: harness member add --email <email> --role <admin|staff|collaborator>");
|
|
1607
|
+
}
|
|
1608
|
+
const body = await ctx.client.post("/api/v1/members", { email: opts.email, role: opts.role });
|
|
1609
|
+
return {
|
|
1610
|
+
json: body.data,
|
|
1611
|
+
human: `Added ${str2(field(body.data, "user", "email"))} as ${str2(body.data["role"])}
|
|
1612
|
+
`
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
async function memberSetRole(ctx, userId, opts) {
|
|
1616
|
+
if (userId === void 0 || opts.role === void 0) {
|
|
1617
|
+
throw new UsageError("usage: harness member set-role <user-id> --role <admin|staff|collaborator|none>");
|
|
1618
|
+
}
|
|
1619
|
+
const body = await ctx.client.put(`/api/v1/members/${encodeURIComponent(userId)}/role`, { role: opts.role });
|
|
1620
|
+
const rank = str2(body.data["role"]);
|
|
1621
|
+
const human = rank === "" ? `Revoked the rank for ${str2(field(body.data, "user", "email"))}
|
|
1622
|
+
` : `${str2(field(body.data, "user", "email"))} is now ${rank}
|
|
1623
|
+
`;
|
|
1624
|
+
return { json: body.data, human };
|
|
1625
|
+
}
|
|
1626
|
+
function requireYes(command, id, yes) {
|
|
1627
|
+
if (yes !== true) throw new UsageError(`${command} ${id} is destructive \u2014 pass --yes to confirm`);
|
|
1628
|
+
}
|
|
1629
|
+
async function upload(ctx, path) {
|
|
1630
|
+
return attachFile(ctx, path);
|
|
1631
|
+
}
|
|
1632
|
+
function indent(text) {
|
|
1633
|
+
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// src/commands/operator-catalog.ts
|
|
1637
|
+
var SCHEMA_VIEWS = ["input", "result"];
|
|
1638
|
+
var MAX_QUERY_BYTES = 200;
|
|
1639
|
+
var MAX_CATALOG_PAGES = 200;
|
|
1640
|
+
var MAX_SCHEMA_PAGES = 1e3;
|
|
1641
|
+
var SCHEMA_ENCODING = "json_fragments_v1";
|
|
1642
|
+
var CAPABILITY_ID_FORMAT = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
1643
|
+
var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a person's approval in the app before they take effect.\n";
|
|
1644
|
+
var REFUSALS = {
|
|
1645
|
+
operator_disabled: "nothing here works until it is switched back on",
|
|
1646
|
+
operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
|
|
1647
|
+
actor_required: `this credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token minted for a user`,
|
|
1648
|
+
run_bound_credential: "a run-bound credential can't read Operator's catalog",
|
|
1649
|
+
forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
|
|
1650
|
+
};
|
|
1651
|
+
async function toolsList(ctx, opts) {
|
|
1652
|
+
requireToolsOpts(opts);
|
|
1653
|
+
const { items, meta } = await capabilityPages(ctx, {}, opts);
|
|
1654
|
+
return {
|
|
1655
|
+
json: items,
|
|
1656
|
+
human: items.length === 0 ? "Operator can use no capabilities here.\n" : capabilitiesTable(items, false),
|
|
1657
|
+
notice: pagingNotice(items, meta, opts, "capabilities")
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
async function toolsSearch(ctx, query, opts) {
|
|
1661
|
+
requireSearchQuery(query);
|
|
1662
|
+
requireToolsOpts(opts);
|
|
1663
|
+
const { items, meta } = await capabilityPages(ctx, { q: query }, opts);
|
|
1664
|
+
return {
|
|
1665
|
+
json: items,
|
|
1666
|
+
human: items.length === 0 ? `No capability Operator can use matches "${clean(query)}".
|
|
1667
|
+
` : capabilitiesTable(items, true),
|
|
1668
|
+
notice: pagingNotice(items, meta, opts, "matches")
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
async function toolsDomains(ctx) {
|
|
1672
|
+
const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/capabilities/domains"));
|
|
1673
|
+
const domains = records(body.data, "domains");
|
|
1674
|
+
return {
|
|
1675
|
+
json: domains,
|
|
1676
|
+
human: domains.length === 0 ? "Operator can use no capabilities here.\n" : table(
|
|
1677
|
+
["DOMAIN", "CAPABILITIES"],
|
|
1678
|
+
domains.map((domain) => [str3(domain["domain"]), str3(domain["count"])])
|
|
1679
|
+
)
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
async function toolShow(ctx, id, opts) {
|
|
1683
|
+
requireCapabilityId(id);
|
|
1684
|
+
const view = requireSchemaView(opts.schema);
|
|
1685
|
+
const fragments = [];
|
|
1686
|
+
let capability = {};
|
|
1687
|
+
let fragmentCount = null;
|
|
1688
|
+
for (let page = 1; ; page++) {
|
|
1689
|
+
const body = await operatorRequest(
|
|
1690
|
+
() => ctx.client.get(`/api/v1/operator/capabilities/${id}`, { schema: view, page }),
|
|
1691
|
+
`Operator can't use a capability ${id} here: it doesn't exist, or it is disabled or switched off`
|
|
1692
|
+
);
|
|
1693
|
+
const data = body.data;
|
|
1694
|
+
if (!isRecord(data)) throw new CommandError("The capability response carried no capability.");
|
|
1695
|
+
if (data["id"] !== id || data["schema"] !== view || data["page"] !== page) {
|
|
1696
|
+
throw new CommandError(
|
|
1697
|
+
`The capability response is not page ${page} of ${id}'s ${view} schema (it says page ${clean(data["page"])} of ${clean(data["id"])}'s ${clean(data["schema"])} schema).`
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
if (data["schema_encoding"] !== SCHEMA_ENCODING) {
|
|
1701
|
+
throw new CommandError(
|
|
1702
|
+
`The capability's schema comes in an encoding this CLI does not read (${clean(data["schema_encoding"])}) \u2014 upgrade the CLI.`
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
const count = data["fragment_count"];
|
|
1706
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 1) {
|
|
1707
|
+
throw new CommandError(`The capability's schema page ${page} carries no valid fragment count.`);
|
|
1708
|
+
}
|
|
1709
|
+
if (fragmentCount !== null && count !== fragmentCount) {
|
|
1710
|
+
throw new CommandError(
|
|
1711
|
+
`The capability's schema pages disagree on the fragment count (${fragmentCount}, then ${count} on page ${page}).`
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
fragmentCount = count;
|
|
1715
|
+
const pageFragments = data["fragments"];
|
|
1716
|
+
if (!Array.isArray(pageFragments) || !pageFragments.every((fragment) => typeof fragment === "string")) {
|
|
1717
|
+
throw new CommandError(`The capability's schema page ${page} carried no fragments.`);
|
|
1718
|
+
}
|
|
1719
|
+
fragments.push(...pageFragments);
|
|
1720
|
+
capability = data;
|
|
1721
|
+
const next = data["next_page"];
|
|
1722
|
+
if (next === null || next === void 0) break;
|
|
1723
|
+
if (next !== page + 1 || next > MAX_SCHEMA_PAGES) {
|
|
1724
|
+
throw new CommandError(`The capability's schema page ${page} names an invalid next page (${clean(next)}).`);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
if (fragments.length !== fragmentCount) {
|
|
1728
|
+
throw new CommandError(
|
|
1729
|
+
`The capability's schema came back incomplete: ${fragments.length} of ${fragmentCount} fragments.`
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
let schema;
|
|
1733
|
+
try {
|
|
1734
|
+
schema = JSON.parse(fragments.join(""));
|
|
1735
|
+
} catch {
|
|
1736
|
+
throw new CommandError("The capability's schema fragments do not join into valid JSON.");
|
|
1737
|
+
}
|
|
1738
|
+
const shown = { ...withoutSchemaPaging(capability), [`${view}_schema`]: schema };
|
|
1739
|
+
return { json: shown, human: capabilityDetail(shown, view, schema) };
|
|
1740
|
+
}
|
|
1741
|
+
async function skillsList(ctx) {
|
|
1742
|
+
const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/skills"));
|
|
1743
|
+
const skills = records(body.data, "skills").map((skill) => ({
|
|
1744
|
+
id: skill["id"],
|
|
1745
|
+
title: skill["title"],
|
|
1746
|
+
invocation_name: skill["invocation_name"],
|
|
1747
|
+
content_sha256: skill["content_sha256"]
|
|
1748
|
+
}));
|
|
1749
|
+
return {
|
|
1750
|
+
json: skills,
|
|
1751
|
+
human: skills.length === 0 ? "No skills are assigned to Operator in this organization.\n" : table(
|
|
1752
|
+
["INVOCATION", "TITLE", "SHA-256", "ID"],
|
|
1753
|
+
skills.map((skill) => [
|
|
1754
|
+
str3(skill.invocation_name),
|
|
1755
|
+
truncate(skill.title),
|
|
1756
|
+
str3(skill.content_sha256),
|
|
1757
|
+
str3(skill.id)
|
|
1758
|
+
])
|
|
1759
|
+
)
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
function requireCapabilityId(id) {
|
|
1763
|
+
if (!CAPABILITY_ID_FORMAT.test(id) || /^\.+$/.test(id)) {
|
|
1764
|
+
throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
|
|
1765
|
+
}
|
|
1766
|
+
if (id === "domains") {
|
|
1767
|
+
throw new UsageError(`domains is not a capability id \u2014 \`${invokedName()} tools domains\` lists the domains`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
function requireSearchQuery(query) {
|
|
1771
|
+
if (query.trim() === "") throw new UsageError("the search query is empty \u2014 say what to search for");
|
|
1772
|
+
if (Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
|
|
1773
|
+
throw new UsageError(`the search query is over ${MAX_QUERY_BYTES} bytes \u2014 shorten it`);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
function requireToolsOpts(opts) {
|
|
1777
|
+
if (opts.domain !== void 0 && opts.domain.trim() === "") {
|
|
1778
|
+
throw new UsageError("the domain is empty \u2014 name one, or leave it out for every domain");
|
|
1779
|
+
}
|
|
1780
|
+
if (opts.cursor !== void 0 && opts.cursor.trim() === "") {
|
|
1781
|
+
throw new UsageError("the cursor is empty \u2014 pass a page's cursor, or leave it out for the first page");
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
function requireSchemaView(view) {
|
|
1785
|
+
if (view === void 0) return "input";
|
|
1786
|
+
if (SCHEMA_VIEWS.includes(view)) return view;
|
|
1787
|
+
throw new UsageError(`--schema must be input or result, not ${clean(view)}`);
|
|
1788
|
+
}
|
|
1789
|
+
async function capabilityPages(ctx, base, opts) {
|
|
1790
|
+
const params = { ...base, domain: opts.domain };
|
|
1791
|
+
let page = await capabilityPage(ctx, { ...params, cursor: opts.cursor }, CALLER_CURSOR);
|
|
1792
|
+
const items = [...page.items];
|
|
1793
|
+
if (opts.all === true) {
|
|
1794
|
+
const seen = new Set(opts.cursor === void 0 ? [] : [opts.cursor]);
|
|
1795
|
+
let pages = 1;
|
|
1796
|
+
while (page.nextCursor !== null) {
|
|
1797
|
+
if (seen.has(page.nextCursor)) throw new CommandError("The capabilities response repeated a cursor.");
|
|
1798
|
+
if (pages >= MAX_CATALOG_PAGES) {
|
|
1799
|
+
throw new CommandError(`The capabilities listing ran past ${MAX_CATALOG_PAGES} pages; stopped there.`);
|
|
1800
|
+
}
|
|
1801
|
+
seen.add(page.nextCursor);
|
|
1802
|
+
page = await capabilityPage(ctx, { ...params, cursor: page.nextCursor }, WALK_CURSOR);
|
|
1803
|
+
pages++;
|
|
1804
|
+
items.push(...page.items);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return { items: dedupeById(items), meta: page.meta };
|
|
1808
|
+
}
|
|
1809
|
+
var CALLER_CURSOR = "start again without the cursor";
|
|
1810
|
+
var WALK_CURSOR = "the listing changed or its cursor expired while following pages; run it again";
|
|
1811
|
+
async function capabilityPage(ctx, params, staleCursor) {
|
|
1812
|
+
const body = await operatorRequest(
|
|
1813
|
+
() => ctx.client.get("/api/v1/operator/capabilities", params),
|
|
1814
|
+
void 0,
|
|
1815
|
+
staleCursor
|
|
1816
|
+
);
|
|
1817
|
+
const items = records(body.data, "capabilities");
|
|
1818
|
+
const meta = body.meta;
|
|
1819
|
+
if (!isRecord(meta)) throw new CommandError("The capabilities response has no pagination metadata.");
|
|
1820
|
+
const nextCursor = meta.next_cursor;
|
|
1821
|
+
if (nextCursor !== null && (typeof nextCursor !== "string" || nextCursor === "")) {
|
|
1822
|
+
throw new CommandError("The capabilities response has an invalid next_cursor.");
|
|
1823
|
+
}
|
|
1824
|
+
if (nextCursor !== null && items.length === 0) {
|
|
1825
|
+
throw new CommandError("The capabilities response says there are more pages but holds no capabilities.");
|
|
1826
|
+
}
|
|
1827
|
+
return { items, meta, nextCursor };
|
|
1828
|
+
}
|
|
1829
|
+
async function operatorRequest(send, notFound2 = "this Harness server has no Operator API yet", staleCursor = CALLER_CURSOR) {
|
|
1830
|
+
try {
|
|
1831
|
+
return await send();
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
throw explain(error, notFound2, staleCursor);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
function explain(error, notFound2, staleCursor) {
|
|
1837
|
+
if (!(error instanceof HttpError)) return error;
|
|
1838
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
1839
|
+
let hint;
|
|
1840
|
+
if (error.status === 403) hint = Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : void 0;
|
|
1841
|
+
else if (error.status === 404) hint = notFound2;
|
|
1842
|
+
else if (error.status === 400 && code === "invalid_cursor") hint = staleCursor;
|
|
1843
|
+
else if (error.status === 400 && /\bdomain\b/.test(error.message)) {
|
|
1844
|
+
hint = "no such domain; listing without one shows each capability's domain";
|
|
1845
|
+
}
|
|
1846
|
+
if (hint === void 0) return error;
|
|
1847
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
1848
|
+
}
|
|
1849
|
+
function capabilitiesTable(items, search2) {
|
|
1850
|
+
const headers = ["ID", "KIND", "DOMAIN", "SUMMARY"];
|
|
1851
|
+
const rows = items.map((item) => [
|
|
1852
|
+
str3(item["id"]),
|
|
1853
|
+
kindName(item),
|
|
1854
|
+
str3(item["domain"]),
|
|
1855
|
+
truncate(item["summary"])
|
|
1856
|
+
]);
|
|
1857
|
+
const table2 = search2 ? table(
|
|
1858
|
+
["SCORE", ...headers],
|
|
1859
|
+
rows.map((row, index) => [str3(items[index]["score"]), ...row])
|
|
1860
|
+
) : table(headers, rows);
|
|
1861
|
+
return table2 + (items.some(needsApproval) ? "\n" + APPROVAL_NOTE : "");
|
|
1862
|
+
}
|
|
1863
|
+
function pagingNotice(items, meta, opts, noun) {
|
|
1864
|
+
if (opts.all === true || typeof meta.next_cursor !== "string") return void 0;
|
|
1865
|
+
const of = typeof meta.count === "number" ? ` of ${meta.count}` : "";
|
|
1866
|
+
return `harness: showing ${items.length}${of} ${noun} \u2014 pass --all for every page, or --cursor ${clean(meta.next_cursor)} for the next one.
|
|
1867
|
+
`;
|
|
1868
|
+
}
|
|
1869
|
+
function capabilityDetail(capability, view, schema) {
|
|
1870
|
+
const kind = kindName(capability);
|
|
1871
|
+
const line = (label, value) => ` ${(label + ":").padEnd(10)}${clean(value)}
|
|
1872
|
+
`;
|
|
1873
|
+
const description = cleanMultiline(str3(capability["description"]));
|
|
1874
|
+
return `${clean(capability["id"])}
|
|
1875
|
+
` + line("Domain", str3(capability["domain"])) + line("Kind", kind === "send" ? "send \u2014 delivered beyond the platform" : kind) + line("Mode", `${str3(capability["mode"])} (effect: ${str3(capability["effect"])})`) + line("Summary", str3(capability["summary"])) + line("Approval", approval(capability)) + (description === "" ? "" : `
|
|
1876
|
+
Description:
|
|
1877
|
+
${indent2(description)}
|
|
1878
|
+
`) + `
|
|
1879
|
+
${view === "input" ? "Input" : "Result"} schema:
|
|
1880
|
+
${indent2(cleanMultiline(JSON.stringify(schema, null, 2)))}
|
|
1881
|
+
`;
|
|
1882
|
+
}
|
|
1883
|
+
function needsApproval(capability) {
|
|
1884
|
+
return capability["kind"] === "write" || capability["kind"] === "send" || capability["mode"] === "proposal";
|
|
1885
|
+
}
|
|
1886
|
+
function approval(capability) {
|
|
1887
|
+
if (needsApproval(capability)) return "a person approves it in the app before it takes effect";
|
|
1888
|
+
if (capability["kind"] === "read") return "not needed for a read";
|
|
1889
|
+
const kind = kindName(capability);
|
|
1890
|
+
return kind === "unknown" ? "unknown: the server named no kind" : `unknown for a capability of kind ${kind}`;
|
|
1891
|
+
}
|
|
1892
|
+
function kindName(capability) {
|
|
1893
|
+
const kind = capability["kind"];
|
|
1894
|
+
return typeof kind === "string" && kind.trim() !== "" ? kind : "unknown";
|
|
1895
|
+
}
|
|
1896
|
+
var SCHEMA_PAGING_FIELDS = /* @__PURE__ */ new Set(["schema", "schema_encoding", "fragments", "fragment_count", "page", "next_page"]);
|
|
1897
|
+
function withoutSchemaPaging(capability) {
|
|
1898
|
+
return Object.fromEntries(Object.entries(capability).filter(([key]) => !SCHEMA_PAGING_FIELDS.has(key)));
|
|
1899
|
+
}
|
|
1900
|
+
function records(value, what) {
|
|
1901
|
+
if (!Array.isArray(value) || !value.every(isRecord)) {
|
|
1902
|
+
throw new CommandError(`The ${what} response carried no list.`);
|
|
1903
|
+
}
|
|
1904
|
+
return value;
|
|
1905
|
+
}
|
|
1906
|
+
function dedupeById(items) {
|
|
1907
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1908
|
+
return items.filter((item) => {
|
|
1909
|
+
if (seen.has(item["id"])) return false;
|
|
1910
|
+
seen.add(item["id"]);
|
|
1911
|
+
return true;
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
function indent2(text) {
|
|
1915
|
+
return text.split("\n").map((line) => " " + line).join("\n");
|
|
1916
|
+
}
|
|
1917
|
+
function isRecord(value) {
|
|
1918
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1919
|
+
}
|
|
1920
|
+
function str3(value) {
|
|
1921
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// src/commands/operator-jobs.ts
|
|
1925
|
+
var JOB_STATES = [
|
|
1926
|
+
"draft",
|
|
1927
|
+
"planning",
|
|
1928
|
+
"ready",
|
|
1929
|
+
"running",
|
|
1930
|
+
"waiting_for_approval",
|
|
1931
|
+
"waiting_for_human",
|
|
1932
|
+
"paused",
|
|
1933
|
+
"failed",
|
|
1934
|
+
"needs_reconciliation",
|
|
1935
|
+
"completed",
|
|
1936
|
+
"cancelled"
|
|
1937
|
+
];
|
|
1938
|
+
var JOB_STATUS_ALIASES = ["open", "waiting", "done"];
|
|
1939
|
+
var JOBS_MAX_LIMIT = 50;
|
|
1940
|
+
var JOBS_MAX_PAGES = 100;
|
|
1941
|
+
var EVENTS_PAGE_LIMIT = 200;
|
|
1942
|
+
var WATCH_INTERVAL_MS = 2e3;
|
|
1943
|
+
var WATCH_WAITING_INTERVAL_MS = 1e4;
|
|
1944
|
+
var WATCH_RETRIES = 3;
|
|
1945
|
+
var RETRY_BASE_MS = 1e3;
|
|
1946
|
+
var RETRY_AFTER_CAP_MS = 3e5;
|
|
1947
|
+
var EXIT_WAITING_FOR_PERSON = 4;
|
|
1948
|
+
var EXIT_JOB_ENDED = 5;
|
|
1949
|
+
var EXIT_INTERRUPTED = 130;
|
|
1950
|
+
var INTERRUPTED = "interrupted";
|
|
1951
|
+
var READER_GONE = "reader-gone";
|
|
1952
|
+
var ENDED_STATES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
|
|
1953
|
+
var CHANGE_SET_FIELDS = [
|
|
1954
|
+
"manifest_id",
|
|
1955
|
+
"version",
|
|
1956
|
+
"digest",
|
|
1957
|
+
"status",
|
|
1958
|
+
"plan_version",
|
|
1959
|
+
"max_effect_count",
|
|
1960
|
+
"actions"
|
|
1961
|
+
];
|
|
1962
|
+
var CHANGE_SET_ACTION_FIELDS = [
|
|
1963
|
+
"id",
|
|
1964
|
+
"action_type",
|
|
1965
|
+
"action_version",
|
|
1966
|
+
"display_order",
|
|
1967
|
+
"effect_count",
|
|
1968
|
+
"required_capability_ids",
|
|
1969
|
+
"digest"
|
|
1970
|
+
];
|
|
1971
|
+
var WITHHELD_FIELDS = /* @__PURE__ */ new Set([
|
|
1972
|
+
"objective",
|
|
1973
|
+
"objective_text",
|
|
1974
|
+
"instruction",
|
|
1975
|
+
"instructions",
|
|
1976
|
+
"description",
|
|
1977
|
+
"result",
|
|
1978
|
+
"results",
|
|
1979
|
+
"outputs",
|
|
1980
|
+
"investigation",
|
|
1981
|
+
"read_material",
|
|
1982
|
+
"public_research",
|
|
1983
|
+
"source_review",
|
|
1984
|
+
"review_actions",
|
|
1985
|
+
"manifest_actions",
|
|
1986
|
+
"recipe",
|
|
1987
|
+
"lua",
|
|
1988
|
+
"lua_source"
|
|
1989
|
+
]);
|
|
1990
|
+
var CODE = /^[a-z0-9_.:-]{1,64}$/;
|
|
1991
|
+
var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
1992
|
+
function requireJobsArgs(opts) {
|
|
1993
|
+
if (opts.status !== void 0) normalizeStatus(opts.status);
|
|
1994
|
+
if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > JOBS_MAX_LIMIT)) {
|
|
1995
|
+
throw new UsageError(`--limit takes an integer from 1 to ${JOBS_MAX_LIMIT}`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
function normalizeStatus(status) {
|
|
1999
|
+
const tokens = status.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
2000
|
+
const known = [...JOB_STATES, ...JOB_STATUS_ALIASES];
|
|
2001
|
+
const unknown = tokens.filter((token) => !known.includes(token));
|
|
2002
|
+
if (tokens.length === 0 || unknown.length > 0) {
|
|
2003
|
+
throw new UsageError(
|
|
2004
|
+
`${tokens.length === 0 ? "--status is empty" : `unknown job status: ${unknown.join(", ")}`} \u2014 pass job states (${JOB_STATES.join(", ")}) or ${JOB_STATUS_ALIASES.join(", ")}, comma-separated`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
return tokens.join(",");
|
|
2008
|
+
}
|
|
2009
|
+
async function jobsList(ctx, opts) {
|
|
2010
|
+
requireJobsArgs(opts);
|
|
2011
|
+
const params = {
|
|
2012
|
+
status: opts.status === void 0 ? void 0 : normalizeStatus(opts.status),
|
|
2013
|
+
mine: opts.mine === true ? true : void 0,
|
|
2014
|
+
// Reading everything: full pages, so the walk takes as few requests as it can.
|
|
2015
|
+
limit: opts.limit ?? (opts.all === true ? JOBS_MAX_LIMIT : void 0)
|
|
2016
|
+
};
|
|
2017
|
+
let page = await jobsPage(ctx, params, void 0);
|
|
2018
|
+
let jobs = page.jobs;
|
|
2019
|
+
if (opts.all !== true) {
|
|
2020
|
+
if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
|
|
2021
|
+
if (jobs.length === 0) {
|
|
2022
|
+
return {
|
|
2023
|
+
json: jobs,
|
|
2024
|
+
human: "No Operator jobs you can see on this page.\n",
|
|
2025
|
+
notice: "harness: older jobs may exist past this page \u2014 pass --all to read on.\n"
|
|
2026
|
+
};
|
|
2027
|
+
}
|
|
2028
|
+
return {
|
|
2029
|
+
json: jobs,
|
|
2030
|
+
human: jobsTable(jobs),
|
|
2031
|
+
notice: `harness: showing the newest ${plural(jobs.length, "Operator job")} \u2014 pass --all for older ones.
|
|
2032
|
+
`
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2036
|
+
let pages = 1;
|
|
2037
|
+
while (page.hasMore && pages < JOBS_MAX_PAGES) {
|
|
2038
|
+
const cursor = page.nextCursor;
|
|
2039
|
+
if (seen.has(cursor)) throw new CommandError("The jobs response repeated a cursor.");
|
|
2040
|
+
seen.add(cursor);
|
|
2041
|
+
page = await jobsPage(ctx, params, cursor);
|
|
2042
|
+
pages += 1;
|
|
2043
|
+
jobs = [...jobs, ...page.jobs];
|
|
2044
|
+
}
|
|
2045
|
+
jobs = uniqueById(jobs);
|
|
2046
|
+
if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
|
|
2047
|
+
return {
|
|
2048
|
+
json: jobs,
|
|
2049
|
+
human: jobsTable(jobs),
|
|
2050
|
+
notice: `harness: stopped after ${JOBS_MAX_PAGES} pages (${plural(jobs.length, "job")}) without reaching the end \u2014 narrow the list with --status or --mine.
|
|
2051
|
+
`,
|
|
2052
|
+
exitCode: 1
|
|
2053
|
+
};
|
|
2054
|
+
}
|
|
2055
|
+
async function jobsPage(ctx, params, before) {
|
|
2056
|
+
const body = await explained(
|
|
2057
|
+
() => ctx.client.get("/api/v1/operator/jobs", { ...params, before }),
|
|
2058
|
+
{ notFound: "this Harness server has no Operator API yet" }
|
|
2059
|
+
);
|
|
2060
|
+
const jobs = objects(body.data, "jobs").map(withheld);
|
|
2061
|
+
const meta = body.meta;
|
|
2062
|
+
if (typeof meta !== "object" || meta === null || typeof meta["has_more"] !== "boolean") {
|
|
2063
|
+
throw new CommandError("The jobs response has no paging metadata.");
|
|
2064
|
+
}
|
|
2065
|
+
if (meta["has_more"] !== true) return { jobs, hasMore: false, nextCursor: null };
|
|
2066
|
+
const cursor = meta["next_cursor"];
|
|
2067
|
+
if (typeof cursor !== "string" || cursor === "") {
|
|
2068
|
+
throw new CommandError("The jobs response says more exist but gives no cursor.");
|
|
2069
|
+
}
|
|
2070
|
+
return { jobs, hasMore: true, nextCursor: cursor };
|
|
2071
|
+
}
|
|
2072
|
+
function requireJobId(id) {
|
|
2073
|
+
if (!isUuid(id)) throw new UsageError(`not an Operator job id: ${id} (expected the job's UUID)`);
|
|
2074
|
+
}
|
|
2075
|
+
async function jobShow(ctx, id, opts = {}) {
|
|
2076
|
+
requireJobId(id);
|
|
2077
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}`), {
|
|
2078
|
+
notFound: missingJob(id)
|
|
2079
|
+
});
|
|
2080
|
+
if (!isRecord2(body.data)) throw new CommandError("The job response carried no job.");
|
|
2081
|
+
const job = withheld(body.data);
|
|
2082
|
+
if (opts.changes !== true && opts.people !== true) return { json: job, human: jobDetail(job) };
|
|
2083
|
+
const document = Object.fromEntries(
|
|
2084
|
+
["id", "state", "waiting_on", "url", "approval_url"].map((key) => [key, job[key] ?? null])
|
|
2085
|
+
);
|
|
2086
|
+
const sections = [];
|
|
2087
|
+
let notice;
|
|
2088
|
+
if (opts.changes === true) {
|
|
2089
|
+
const changeSet = await jobChangeSet(ctx, id);
|
|
2090
|
+
document["change_set"] = changeSet;
|
|
2091
|
+
sections.push(changesText(id, job, changeSet));
|
|
2092
|
+
notice = changeSetRace(job, changeSet);
|
|
2093
|
+
}
|
|
2094
|
+
if (opts.people === true) {
|
|
2095
|
+
const people = jobPeople(job);
|
|
2096
|
+
document["people"] = people;
|
|
2097
|
+
sections.push(peopleText(id, people));
|
|
2098
|
+
}
|
|
2099
|
+
return { json: document, human: sections.join("\n"), ...notice === void 0 ? {} : { notice } };
|
|
2100
|
+
}
|
|
2101
|
+
function changeSetRace(job, changeSet) {
|
|
2102
|
+
const pending = field2(job, "pending_approval", "manifest_id");
|
|
2103
|
+
if (typeof pending !== "string") return void 0;
|
|
2104
|
+
const read = changeSet === null ? null : changeSet["manifest_id"];
|
|
2105
|
+
if (read === pending) return void 0;
|
|
2106
|
+
return `harness: the job waits for approval of change set ${clean(pending)}, but the change set read next is ${read === null ? "none" : clean(read)}: the job changed between the two reads, so run it again.
|
|
2107
|
+
`;
|
|
2108
|
+
}
|
|
2109
|
+
async function jobChangeSet(ctx, id) {
|
|
2110
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}/changes`), {
|
|
2111
|
+
notFound: missingJob(id)
|
|
2112
|
+
});
|
|
2113
|
+
if (body.data === null) return null;
|
|
2114
|
+
if (!isRecord2(body.data)) throw new CommandError("The change set response carried no change set.");
|
|
2115
|
+
return {
|
|
2116
|
+
...pick(body.data, CHANGE_SET_FIELDS),
|
|
2117
|
+
actions: records2(body.data["actions"]).map((action) => pick(action, CHANGE_SET_ACTION_FIELDS))
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function jobPeople(job) {
|
|
2121
|
+
const participants = records2(field2(job, "people", "participants"));
|
|
2122
|
+
const owner = isRecord2(job["owner"]) ? job["owner"] : null;
|
|
2123
|
+
const names = /* @__PURE__ */ new Map();
|
|
2124
|
+
for (const named2 of owner === null ? participants : [...participants, owner]) {
|
|
2125
|
+
if (typeof named2["user_id"] === "string" && typeof named2["name"] === "string" && named2["name"] !== "") {
|
|
2126
|
+
if (!names.has(named2["user_id"])) names.set(named2["user_id"], named2["name"]);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
|
|
2130
|
+
const some = (people) => people.filter((entry) => entry !== null);
|
|
2131
|
+
const approvers = field2(job, "people", "approver_user_ids");
|
|
2132
|
+
return {
|
|
2133
|
+
requester: person(job["requester_user_id"]),
|
|
2134
|
+
execution_principal: person(job["execution_principal_user_id"]),
|
|
2135
|
+
owner: owner === null ? null : person(owner["user_id"]),
|
|
2136
|
+
approvers: some((Array.isArray(approvers) ? approvers : []).map(person)),
|
|
2137
|
+
collaborators: some(participants.filter((entry) => entry["role"] === "collaborator").map((entry) => person(entry["user_id"])))
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
async function jobWatch(ctx, id, opts) {
|
|
2141
|
+
requireJobId(id);
|
|
2142
|
+
if (opts.after !== void 0 && (!Number.isInteger(opts.after) || opts.after < 0)) {
|
|
2143
|
+
throw new UsageError("--after takes a non-negative event sequence");
|
|
2144
|
+
}
|
|
2145
|
+
const emit = opts.onLine ?? (() => {
|
|
2146
|
+
});
|
|
2147
|
+
const sleep = opts.sleep ?? abortableSleep;
|
|
2148
|
+
const signal = opts.signal;
|
|
2149
|
+
let after = opts.after ?? 0;
|
|
2150
|
+
const events = [];
|
|
2151
|
+
let job = null;
|
|
2152
|
+
let shown = null;
|
|
2153
|
+
let shownApprovalUrl = null;
|
|
2154
|
+
const document = (outcome, extra = {}) => ({
|
|
2155
|
+
id,
|
|
2156
|
+
outcome,
|
|
2157
|
+
...extra,
|
|
2158
|
+
job: job === null ? null : jobStateJson(job),
|
|
2159
|
+
...opts.collectEvents === true ? { events } : {},
|
|
2160
|
+
next_after: after
|
|
2161
|
+
});
|
|
2162
|
+
const stopped = (reason) => reason === READER_GONE ? (
|
|
2163
|
+
// Nobody is reading any more: end quietly.
|
|
2164
|
+
{ json: document("stopped"), human: "", exitCode: 0 }
|
|
2165
|
+
) : { json: document("interrupted"), human: "", notice: resumeLine(id, after), exitCode: EXIT_INTERRUPTED };
|
|
2166
|
+
for (; ; ) {
|
|
2167
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
2168
|
+
let page;
|
|
2169
|
+
try {
|
|
2170
|
+
page = await withRetries(() => eventsPage(ctx, id, after), sleep, signal);
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
2173
|
+
if (!transient(error)) throw error;
|
|
2174
|
+
const message = failureMessage(error);
|
|
2175
|
+
return {
|
|
2176
|
+
json: document("error", {
|
|
2177
|
+
error: { message, status: error instanceof HttpError ? error.status : null }
|
|
2178
|
+
}),
|
|
2179
|
+
human: "",
|
|
2180
|
+
notice: `harness: ${message}
|
|
2181
|
+
` + resumeLine(id, after),
|
|
2182
|
+
exitCode: error instanceof TransportError ? 3 : 1
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
for (const event of page.events) {
|
|
2186
|
+
if (opts.collectEvents === true) events.push(eventJson(event));
|
|
2187
|
+
emit(eventLine(event));
|
|
2188
|
+
}
|
|
2189
|
+
after = page.nextAfter;
|
|
2190
|
+
job = page.job;
|
|
2191
|
+
if (page.hasMore) continue;
|
|
2192
|
+
const state = str4(job["state"]);
|
|
2193
|
+
const waitingOn = typeof job["waiting_on"] === "string" ? job["waiting_on"] : null;
|
|
2194
|
+
if (shown === null || shown.state !== state || shown.waitingOn !== waitingOn) {
|
|
2195
|
+
emit(stateLine(shown?.state ?? null, state, waitingOn));
|
|
2196
|
+
shown = { state, waitingOn };
|
|
2197
|
+
}
|
|
2198
|
+
const approvalUrl = typeof job["approval_url"] === "string" ? job["approval_url"] : null;
|
|
2199
|
+
if (opts.throughWaits === true && approvalUrl !== null && approvalUrl !== shownApprovalUrl) {
|
|
2200
|
+
emit(`approve in the app: ${clean(approvalUrl)}
|
|
2201
|
+
`);
|
|
2202
|
+
}
|
|
2203
|
+
shownApprovalUrl = approvalUrl;
|
|
2204
|
+
let outcome = null;
|
|
2205
|
+
if (ENDED_STATES.has(state)) outcome = state;
|
|
2206
|
+
else if (waitingOn !== null && opts.throughWaits !== true) outcome = "waiting";
|
|
2207
|
+
if (outcome !== null) {
|
|
2208
|
+
return {
|
|
2209
|
+
json: document(outcome),
|
|
2210
|
+
human: outcomeText(id, outcome, job),
|
|
2211
|
+
exitCode: outcome === "completed" ? 0 : outcome === "waiting" ? EXIT_WAITING_FOR_PERSON : EXIT_JOB_ENDED
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
2215
|
+
await sleep(waitingOn !== null || state === "paused" ? WATCH_WAITING_INTERVAL_MS : WATCH_INTERVAL_MS, signal);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
function resumeLine(id, after) {
|
|
2219
|
+
const name = invokedName();
|
|
2220
|
+
return `${name}: stopped watching job ${id}; resume with: ${name} watch ${id} --after ${after}
|
|
2221
|
+
`;
|
|
2222
|
+
}
|
|
2223
|
+
function watchStopSignal(stdout = process.stdout, signals = process) {
|
|
2224
|
+
const controller = new AbortController();
|
|
2225
|
+
const readerGone = () => controller.abort(READER_GONE);
|
|
2226
|
+
stdout.once("error", readerGone);
|
|
2227
|
+
stdout.once("close", readerGone);
|
|
2228
|
+
signals.once("SIGINT", () => controller.abort(INTERRUPTED));
|
|
2229
|
+
return controller.signal;
|
|
2230
|
+
}
|
|
2231
|
+
function aborted(signal) {
|
|
2232
|
+
return signal?.aborted === true;
|
|
2233
|
+
}
|
|
2234
|
+
function abortableSleep(ms, signal) {
|
|
2235
|
+
return new Promise((resolve) => {
|
|
2236
|
+
if (aborted(signal)) {
|
|
2237
|
+
resolve();
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const done = () => {
|
|
2241
|
+
clearTimeout(timer);
|
|
2242
|
+
signal?.removeEventListener("abort", done);
|
|
2243
|
+
resolve();
|
|
2244
|
+
};
|
|
2245
|
+
const timer = setTimeout(done, ms);
|
|
2246
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
2247
|
+
});
|
|
2248
|
+
}
|
|
2249
|
+
function transient(error) {
|
|
2250
|
+
if (error instanceof TransportError) return true;
|
|
2251
|
+
return error instanceof HttpError && !(error instanceof UpgradeRequiredError) && (error.status >= 500 || error.status === 429);
|
|
2252
|
+
}
|
|
2253
|
+
async function withRetries(send, sleep, signal) {
|
|
2254
|
+
for (let attempt = 0; ; attempt++) {
|
|
2255
|
+
try {
|
|
2256
|
+
return await send();
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
if (attempt >= WATCH_RETRIES || !transient(error) || aborted(signal)) throw error;
|
|
2259
|
+
await sleep(retryDelay(error, attempt), signal);
|
|
2260
|
+
if (aborted(signal)) throw error;
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
function retryDelay(error, attempt) {
|
|
2265
|
+
const backoff = RETRY_BASE_MS * 2 ** attempt;
|
|
2266
|
+
const asked = error instanceof HttpError ? retryAfterMs(error.retryAfter) : null;
|
|
2267
|
+
return asked === null ? backoff : Math.max(backoff, Math.min(asked, RETRY_AFTER_CAP_MS));
|
|
2268
|
+
}
|
|
2269
|
+
function retryAfterMs(header) {
|
|
2270
|
+
if (header === null) return null;
|
|
2271
|
+
const value = header.trim();
|
|
2272
|
+
if (/^\d+$/.test(value)) return Number.parseInt(value, 10) * 1e3;
|
|
2273
|
+
const at = Date.parse(value);
|
|
2274
|
+
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
|
|
2275
|
+
}
|
|
2276
|
+
function failureMessage(error) {
|
|
2277
|
+
if (error instanceof TransportError) return `cannot reach the API: ${clean(error.message)}`;
|
|
2278
|
+
return `${clean(error.message)} (HTTP ${error.status})${errorDetails(error.body)}`;
|
|
2279
|
+
}
|
|
2280
|
+
async function eventsPage(ctx, id, after) {
|
|
2281
|
+
const body = await explained(
|
|
2282
|
+
() => ctx.client.get(`/api/v1/operator/jobs/${id}/events`, {
|
|
2283
|
+
after,
|
|
2284
|
+
limit: EVENTS_PAGE_LIMIT
|
|
2285
|
+
}),
|
|
2286
|
+
{ notFound: missingJob(id) }
|
|
2287
|
+
);
|
|
2288
|
+
const events = objects(body.data, "events");
|
|
2289
|
+
const meta = body.meta;
|
|
2290
|
+
if (typeof meta !== "object" || meta === null) throw new CommandError("The events response has no metadata.");
|
|
2291
|
+
const nextAfter = meta["next_after"];
|
|
2292
|
+
const hasMore = meta["has_more"];
|
|
2293
|
+
const job = meta["job"];
|
|
2294
|
+
if (typeof nextAfter !== "number" || !Number.isInteger(nextAfter) || nextAfter < after) {
|
|
2295
|
+
throw new CommandError("The events response has an invalid next_after.");
|
|
2296
|
+
}
|
|
2297
|
+
if (typeof hasMore !== "boolean") throw new CommandError("The events response has an invalid has_more.");
|
|
2298
|
+
if (!isRecord2(job) || typeof job["state"] !== "string") {
|
|
2299
|
+
throw new CommandError("The events response does not carry the job's state.");
|
|
2300
|
+
}
|
|
2301
|
+
if (hasMore && nextAfter === after) {
|
|
2302
|
+
throw new CommandError("The events response says more exist but did not move its cursor.");
|
|
2303
|
+
}
|
|
2304
|
+
return { events, nextAfter, hasMore, job };
|
|
2305
|
+
}
|
|
2306
|
+
var REFUSAL_HINTS = {
|
|
2307
|
+
operator_disabled: "nothing can be read until Operator is switched back on for this organization",
|
|
2308
|
+
operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
|
|
2309
|
+
actor_required: `the credential acts as no person: sign in with \`${invokedName()} login\`, or use an API token a person created`,
|
|
2310
|
+
forbidden: `the credential lacks the operator:read scope (\`${invokedName()} login\` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization`,
|
|
2311
|
+
run_bound_credential: `a run-bound credential cannot read Operator jobs; sign in with \`${invokedName()} login\``
|
|
2312
|
+
};
|
|
2313
|
+
function missingJob(id) {
|
|
2314
|
+
return `no Operator job ${id} you can see: it does not exist, is in another organization, or is not shared with you (or this Harness server has no Operator API yet)`;
|
|
2315
|
+
}
|
|
2316
|
+
async function explained(send, why) {
|
|
2317
|
+
try {
|
|
2318
|
+
return await send();
|
|
2319
|
+
} catch (error) {
|
|
2320
|
+
if (!(error instanceof HttpError)) throw error;
|
|
2321
|
+
const code = error.body["error"];
|
|
2322
|
+
let hint;
|
|
2323
|
+
if (error.status === 403 && typeof code === "string") hint = REFUSAL_HINTS[code];
|
|
2324
|
+
if (error.status === 404) hint = why.notFound;
|
|
2325
|
+
if (error.status === 400 && code === "invalid_cursor") hint = "run the list again from the start";
|
|
2326
|
+
if (hint === void 0) throw error;
|
|
2327
|
+
throw new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
function jobsTable(jobs) {
|
|
2331
|
+
if (jobs.length === 0) return "No Operator jobs.\n";
|
|
2332
|
+
const rows = jobs.map((job) => [
|
|
2333
|
+
str4(job["id"]),
|
|
2334
|
+
str4(job["state"]) || "-",
|
|
2335
|
+
str4(job["waiting_on"]) || "-",
|
|
2336
|
+
str4(job["objective_kind"]) || "-",
|
|
2337
|
+
personName(job["owner"]),
|
|
2338
|
+
str4(field2(job, "plan_step", "issue")) || "-",
|
|
2339
|
+
str4(job["updated_at"]) || "-"
|
|
2340
|
+
]);
|
|
2341
|
+
const table2 = table(["ID", "STATE", "WAITING ON", "KIND", "OWNER", "PLAN STEP", "UPDATED"], rows);
|
|
2342
|
+
const approvals = jobs.filter((job) => typeof job["approval_url"] === "string");
|
|
2343
|
+
if (approvals.length === 0) return table2;
|
|
2344
|
+
return table2 + "\nWaiting for approval. Approve in the app; the CLI cannot:\n" + approvals.map((job) => ` ${clean(job["id"])} ${clean(job["approval_url"])}
|
|
2345
|
+
`).join("");
|
|
2346
|
+
}
|
|
2347
|
+
function jobDetail(job) {
|
|
2348
|
+
const waitingOn = str4(job["waiting_on"]);
|
|
2349
|
+
const header = `Operator job ${clean(job["id"])} \xB7 ${clean(job["state"])}` + (waitingOn === "" ? "" : ` \xB7 waiting on ${clean(waitingOn)}`) + "\n";
|
|
2350
|
+
const people = records2(field2(job, "people", "participants"));
|
|
2351
|
+
const fields = [
|
|
2352
|
+
["Kind", str4(job["objective_kind"]) || "-"],
|
|
2353
|
+
["Role", str4(job["viewer_role"]) || null],
|
|
2354
|
+
["Owner", isRecord2(job["owner"]) ? personName(job["owner"]) : null],
|
|
2355
|
+
["Plan step", planStepLine(job["plan_step"])],
|
|
2356
|
+
["Plan", planLine(job["plan"])],
|
|
2357
|
+
["Progress", progressLine(job["progress"])],
|
|
2358
|
+
["Window", windowLine(job["window"])],
|
|
2359
|
+
["Spend", spendLine(job["spend"])],
|
|
2360
|
+
["Verification", str4(field2(job, "verification", "status")) || null],
|
|
2361
|
+
["Effects", effectsLine(job["effects"])],
|
|
2362
|
+
["Skill", skillLine(job["skill"])],
|
|
2363
|
+
["Grant", grantLine(job["grant"], people)],
|
|
2364
|
+
["Created", str4(job["inserted_at"]) || null],
|
|
2365
|
+
["Updated", str4(job["updated_at"]) || null],
|
|
2366
|
+
["Workbench", str4(job["url"]) || null]
|
|
2367
|
+
];
|
|
2368
|
+
return header + labelled(fields) + approvalSection(job["pending_approval"], people) + listSection("Blockers", records2(job["blockers"]), (item) => `${str4(item["kind"])}: ${str4(item["state"])}`) + listSection("Artifacts", records2(job["artifacts"]), (item) => `${str4(item["id"])} ${str4(item["url"])}`) + peopleSection(people) + listSection(
|
|
2369
|
+
"Procedures",
|
|
2370
|
+
records2(job["procedures"]),
|
|
2371
|
+
(procedure) => `${str4(procedure["name"])} v${str4(procedure["current_version"])} ${str4(procedure["id"])}`
|
|
2372
|
+
) + controlsLine(job["viewer_can"]) + "\nThe objective's text is not shown: the API never returns it, because it can quote what Operator read. Open the workbench link to read it.\n";
|
|
2373
|
+
}
|
|
2374
|
+
function labelled(fields, width = 15) {
|
|
2375
|
+
return fields.filter((entry) => entry[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(width)}${clean(value)}
|
|
2376
|
+
`).join("");
|
|
2377
|
+
}
|
|
2378
|
+
function planStepLine(step) {
|
|
2379
|
+
if (!isRecord2(step)) return null;
|
|
2380
|
+
return `${str4(step["issue"])} (plan ${str4(step["plan_id"])})`;
|
|
2381
|
+
}
|
|
2382
|
+
function planLine(plan) {
|
|
2383
|
+
if (!isRecord2(plan)) return "none yet";
|
|
2384
|
+
const count = plan["capability_count"];
|
|
2385
|
+
return `version ${str4(plan["version"])} \xB7 instructions v${str4(plan["instruction_version"])} \xB7 ${str4(count)} capabilit${count === 1 ? "y" : "ies"}`;
|
|
2386
|
+
}
|
|
2387
|
+
function progressLine(progress) {
|
|
2388
|
+
if (!isRecord2(progress)) return null;
|
|
2389
|
+
const parts = [str4(progress["stage"]), str4(progress["situation"])].filter((part) => part !== "");
|
|
2390
|
+
if (progress["needs_viewer"] === true) parts.push("needs you");
|
|
2391
|
+
return parts.length === 0 ? null : parts.join(" \xB7 ");
|
|
2392
|
+
}
|
|
2393
|
+
function windowLine(window) {
|
|
2394
|
+
if (!isRecord2(window)) return null;
|
|
2395
|
+
const left = duration(window["remaining_ms"]);
|
|
2396
|
+
const budget = duration(window["budget_ms"]);
|
|
2397
|
+
const sequence = window["sequence"];
|
|
2398
|
+
return `${left ?? "?"} left${budget === null ? "" : ` of ${budget}`} \xB7 ${str4(window["state"])}` + (typeof sequence === "number" ? ` (window ${sequence})` : "");
|
|
2399
|
+
}
|
|
2400
|
+
function spendLine(spend) {
|
|
2401
|
+
if (!isRecord2(spend)) return null;
|
|
2402
|
+
const spent = `${str4(spend["spent_cents"]) || "0"} cents spent`;
|
|
2403
|
+
if (typeof spend["plan_budget_cents"] !== "number") return spent;
|
|
2404
|
+
const remaining = spend["plan_remaining_cents"];
|
|
2405
|
+
return `${spent} \xB7 plan budget ${spend["plan_budget_cents"]} cents` + (typeof remaining === "number" ? `, ${remaining} cents left` : "");
|
|
2406
|
+
}
|
|
2407
|
+
function effectsLine(effects) {
|
|
2408
|
+
if (!isRecord2(effects)) return null;
|
|
2409
|
+
return ["staged", "succeeded", "failed", "unknown"].map((key) => `${str4(effects[key]) || "0"} ${key}`).join(" \xB7 ");
|
|
2410
|
+
}
|
|
2411
|
+
function skillLine(skill) {
|
|
2412
|
+
if (!isRecord2(skill)) return null;
|
|
2413
|
+
return `${str4(skill["title"])} (${str4(skill["invocation_name"])}) \xB7 sha256 ${str4(skill["content_sha256"])} \xB7 pinned ${str4(skill["pinned_at"])}`;
|
|
2414
|
+
}
|
|
2415
|
+
function grantLine(grant, people) {
|
|
2416
|
+
if (!isRecord2(grant)) return null;
|
|
2417
|
+
const approvedAt = str4(grant["approved_at"]);
|
|
2418
|
+
const expiresAt = str4(grant["expires_at"]);
|
|
2419
|
+
return `${str4(grant["status"])} v${str4(grant["version"])} \xB7 approved by ${nameOf(grant["approver_user_id"], people)}` + (approvedAt === "" ? "" : ` at ${approvedAt}`) + ` \xB7 ${expiresAt === "" ? "no expiry" : `expires ${expiresAt}`}`;
|
|
2420
|
+
}
|
|
2421
|
+
function approvalSection(pending, people) {
|
|
2422
|
+
if (!isRecord2(pending)) return "";
|
|
2423
|
+
const approvers = Array.isArray(pending["approver_user_ids"]) ? pending["approver_user_ids"].map((id) => nameOf(id, people)).join(", ") : "";
|
|
2424
|
+
const canApprove = pending["viewer_can_approve"] === true ? "you can approve" : "you cannot approve it";
|
|
2425
|
+
return "\nWaiting for approval. Approve it in the app; the CLI cannot:\n" + labelled([
|
|
2426
|
+
[
|
|
2427
|
+
"Change set",
|
|
2428
|
+
`${str4(pending["manifest_id"])} v${str4(pending["version"])} \xB7 ${str4(pending["action_count"])} actions \xB7 up to ${str4(pending["max_effect_count"])} effects \xB7 plan version ${str4(pending["plan_version"])}`
|
|
2429
|
+
],
|
|
2430
|
+
["Digest", str4(pending["digest"]) || null],
|
|
2431
|
+
["Approvers", approvers === "" ? canApprove : `${approvers} \xB7 ${canApprove}`],
|
|
2432
|
+
["Approve at", str4(pending["url"]) || null]
|
|
2433
|
+
]);
|
|
2434
|
+
}
|
|
2435
|
+
function peopleSection(people) {
|
|
2436
|
+
if (people.length === 0) return "";
|
|
2437
|
+
const rows = people.map((person) => [
|
|
2438
|
+
str4(person["name"]) || "(no name)",
|
|
2439
|
+
str4(person["role"]),
|
|
2440
|
+
str4(person["user_id"])
|
|
2441
|
+
]);
|
|
2442
|
+
const lines = table(["", "", ""], rows).split("\n").slice(1, -1);
|
|
2443
|
+
return "\nPeople:\n" + lines.map((line) => ` ${line}
|
|
2444
|
+
`).join("");
|
|
2445
|
+
}
|
|
2446
|
+
function changesText(id, job, changeSet) {
|
|
2447
|
+
if (changeSet === null) return `No pending change set: Operator has proposed nothing for job ${id} yet.
|
|
2448
|
+
`;
|
|
2449
|
+
const approvalUrl = str4(job["approval_url"]);
|
|
2450
|
+
const rows = records2(changeSet["actions"]).map((action) => [
|
|
2451
|
+
str4(action["display_order"]) || "-",
|
|
2452
|
+
str4(action["action_type"]),
|
|
2453
|
+
str4(action["action_version"]),
|
|
2454
|
+
str4(action["effect_count"]),
|
|
2455
|
+
(Array.isArray(action["required_capability_ids"]) ? action["required_capability_ids"].map(str4).join(", ") : "") || "-",
|
|
2456
|
+
str4(action["digest"]),
|
|
2457
|
+
str4(action["id"])
|
|
2458
|
+
]);
|
|
2459
|
+
const actions = rows.length === 0 ? "\nActions: none\n" : "\nActions:\n" + table(["#", "ACTION", "VERSION", "EFFECTS", "CAPABILITIES", "DIGEST", "ID"], rows).split("\n").slice(0, -1).map((line) => ` ${line}
|
|
2460
|
+
`).join("");
|
|
2461
|
+
return `Change set of Operator job ${id} \xB7 ${clean(changeSet["status"])}
|
|
2462
|
+
` + labelled([
|
|
2463
|
+
[
|
|
2464
|
+
"Change set",
|
|
2465
|
+
`${str4(changeSet["manifest_id"])} v${str4(changeSet["version"])} \xB7 plan version ${str4(changeSet["plan_version"])} \xB7 up to ${str4(changeSet["max_effect_count"])} effects`
|
|
2466
|
+
],
|
|
2467
|
+
["Digest", str4(changeSet["digest"]) || null],
|
|
2468
|
+
approvalUrl === "" ? ["Workbench", str4(job["url"]) || null] : ["Approve at", approvalUrl]
|
|
2469
|
+
]) + actions + "\nValues and targets are not shown: review them in the app at the " + (approvalUrl === "" ? "workbench link.\n" : "approval link. The CLI cannot approve.\n");
|
|
2470
|
+
}
|
|
2471
|
+
function peopleText(id, people) {
|
|
2472
|
+
const one = (entry) => isRecord2(entry) ? str4(entry["name"]) || str4(entry["user_id"]) : "-";
|
|
2473
|
+
const many = (entries) => (Array.isArray(entries) ? entries.map(one).join(", ") : "") || "none";
|
|
2474
|
+
return `People on Operator job ${id}:
|
|
2475
|
+
` + labelled(
|
|
2476
|
+
[
|
|
2477
|
+
["Requester", one(people["requester"])],
|
|
2478
|
+
["Execution principal", one(people["execution_principal"])],
|
|
2479
|
+
["Owner", one(people["owner"])],
|
|
2480
|
+
["Approvers", many(people["approvers"])],
|
|
2481
|
+
["Collaborators", many(people["collaborators"])]
|
|
2482
|
+
],
|
|
2483
|
+
22
|
|
2484
|
+
);
|
|
2485
|
+
}
|
|
2486
|
+
function listSection(title, items, line) {
|
|
2487
|
+
if (items.length === 0) return "";
|
|
2488
|
+
return `
|
|
2489
|
+
${title}:
|
|
2490
|
+
` + items.map((item) => ` ${clean(line(item))}
|
|
2491
|
+
`).join("");
|
|
2492
|
+
}
|
|
2493
|
+
function controlsLine(viewerCan) {
|
|
2494
|
+
const controls = isRecord2(viewerCan) ? ["steer", "cancel", "continue", "pause", "save_procedure"].filter((key) => viewerCan[key] === true) : [];
|
|
2495
|
+
return `
|
|
2496
|
+
You can, in the workbench: ${controls.length === 0 ? "nothing right now" : controls.join(", ")}
|
|
2497
|
+
`;
|
|
2498
|
+
}
|
|
2499
|
+
function eventLine(event) {
|
|
2500
|
+
const actor = isRecord2(event["actor"]) ? event["actor"] : {};
|
|
2501
|
+
const actorKind = safeValue(actor["kind"]) || "?";
|
|
2502
|
+
const actorId = safeValue(actor["delegated_grant_id"] ?? actor["user_id"]);
|
|
2503
|
+
const data = isRecord2(event["data"]) ? Object.entries(event["data"]).map(([key, value]) => `${clean(key)}=${safeValue(value) || "\u2026"}`) : [];
|
|
2504
|
+
return `#${clean(event["sequence"])} ${clean(event["occurred_at"])} ${clean(event["kind"])} ${actorId === "" ? actorKind : `${actorKind} ${actorId}`}` + (data.length === 0 ? "" : ` ${data.join(" ")}`) + "\n";
|
|
2505
|
+
}
|
|
2506
|
+
function eventJson(event) {
|
|
2507
|
+
const actor = isRecord2(event["actor"]) ? event["actor"] : {};
|
|
2508
|
+
const data = isRecord2(event["data"]) ? event["data"] : {};
|
|
2509
|
+
return {
|
|
2510
|
+
sequence: typeof event["sequence"] === "number" ? event["sequence"] : null,
|
|
2511
|
+
kind: safeValue(event["kind"]) || null,
|
|
2512
|
+
occurred_at: typeof event["occurred_at"] === "string" && TIMESTAMP.test(event["occurred_at"]) ? event["occurred_at"] : null,
|
|
2513
|
+
actor: Object.fromEntries(
|
|
2514
|
+
["kind", "user_id", "delegated_grant_id"].filter((key) => key in actor && (actor[key] === null || safeValue(actor[key]) !== "")).map((key) => [key, actor[key]])
|
|
2515
|
+
),
|
|
2516
|
+
data: Object.fromEntries(Object.entries(data).filter(([key, value]) => CODE.test(key) && safeValue(value) !== ""))
|
|
2517
|
+
};
|
|
2518
|
+
}
|
|
2519
|
+
function jobStateJson(job) {
|
|
2520
|
+
return Object.fromEntries(
|
|
2521
|
+
["state", "waiting_on", "updated_at", "url", "approval_url"].filter((key) => key in job).map((key) => [key, job[key]])
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
function stateLine(previous, state, waitingOn) {
|
|
2525
|
+
const now = clean(state) + (waitingOn === null ? "" : `, waiting on ${clean(waitingOn)}`);
|
|
2526
|
+
return previous === null || previous === state ? `state: ${now}
|
|
2527
|
+
` : `state: ${clean(previous)} \u2192 ${now}
|
|
2528
|
+
`;
|
|
2529
|
+
}
|
|
2530
|
+
function outcomeText(id, outcome, job) {
|
|
2531
|
+
const url = clean(job["url"]);
|
|
2532
|
+
switch (outcome) {
|
|
2533
|
+
case "completed":
|
|
2534
|
+
return `Job ${id} completed.
|
|
2535
|
+
Workbench: ${url}
|
|
2536
|
+
`;
|
|
2537
|
+
case "failed":
|
|
2538
|
+
return `Job ${id} failed.
|
|
2539
|
+
Workbench: ${url}
|
|
2540
|
+
`;
|
|
2541
|
+
case "cancelled":
|
|
2542
|
+
return `Job ${id} was cancelled.
|
|
2543
|
+
Workbench: ${url}
|
|
2544
|
+
`;
|
|
2545
|
+
case "waiting": {
|
|
2546
|
+
const waitingOn = job["waiting_on"];
|
|
2547
|
+
if (waitingOn === "approval") {
|
|
2548
|
+
const link = clean(job["approval_url"]) || url;
|
|
2549
|
+
return `Job ${id} is waiting for approval. Approve it in the app; the CLI cannot:
|
|
2550
|
+
${link}
|
|
2551
|
+
`;
|
|
2552
|
+
}
|
|
2553
|
+
const what = waitingOn === "person" ? "a person" : clean(waitingOn);
|
|
2554
|
+
return `Job ${id} is waiting on ${what}; a person has to act in the workbench:
|
|
2555
|
+
${url}
|
|
2556
|
+
`;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
function withheld(job) {
|
|
2561
|
+
return Object.fromEntries(
|
|
2562
|
+
Object.entries(job).filter(([key]) => !WITHHELD_FIELDS.has(key) && !key.startsWith("knowledge"))
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
function pick(record, keys) {
|
|
2566
|
+
return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
2567
|
+
}
|
|
2568
|
+
function objects(value, what) {
|
|
2569
|
+
if (!Array.isArray(value) || !value.every(isRecord2)) {
|
|
2570
|
+
throw new CommandError(`The ${what} response data is not a list of objects.`);
|
|
2571
|
+
}
|
|
2572
|
+
return value;
|
|
2573
|
+
}
|
|
2574
|
+
function plural(count, noun) {
|
|
2575
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
2576
|
+
}
|
|
2577
|
+
function uniqueById(jobs) {
|
|
2578
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2579
|
+
return jobs.filter((job) => {
|
|
2580
|
+
const id = str4(job["id"]);
|
|
2581
|
+
if (seen.has(id)) return false;
|
|
2582
|
+
seen.add(id);
|
|
2583
|
+
return true;
|
|
2584
|
+
});
|
|
2585
|
+
}
|
|
2586
|
+
function personName(person) {
|
|
2587
|
+
if (!isRecord2(person)) return "-";
|
|
2588
|
+
return str4(person["name"]) || str4(person["user_id"]) || "-";
|
|
2589
|
+
}
|
|
2590
|
+
function nameOf(userId, people) {
|
|
2591
|
+
const id = str4(userId);
|
|
2592
|
+
const person = people.find((candidate) => candidate["user_id"] === id);
|
|
2593
|
+
return (person === void 0 ? "" : str4(person["name"])) || id || "?";
|
|
2594
|
+
}
|
|
2595
|
+
function duration(ms) {
|
|
2596
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return null;
|
|
2597
|
+
const seconds = Math.max(0, Math.floor(ms / 1e3));
|
|
2598
|
+
const hours = Math.floor(seconds / 3600);
|
|
2599
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
2600
|
+
const rest = seconds % 60;
|
|
2601
|
+
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
2602
|
+
if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
2603
|
+
return `${rest}s`;
|
|
2604
|
+
}
|
|
2605
|
+
function safeValue(value) {
|
|
2606
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
2607
|
+
if (typeof value === "boolean") return String(value);
|
|
2608
|
+
if (typeof value === "string" && CODE.test(value)) return value;
|
|
2609
|
+
return "";
|
|
2610
|
+
}
|
|
2611
|
+
function field2(record, ...keys) {
|
|
2612
|
+
let current = record;
|
|
2613
|
+
for (const key of keys) {
|
|
2614
|
+
if (!isRecord2(current)) return null;
|
|
2615
|
+
current = current[key];
|
|
2616
|
+
}
|
|
2617
|
+
return current ?? null;
|
|
2618
|
+
}
|
|
2619
|
+
function records2(value) {
|
|
2620
|
+
return Array.isArray(value) ? value.filter(isRecord2) : [];
|
|
2621
|
+
}
|
|
2622
|
+
function isRecord2(value) {
|
|
2623
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2624
|
+
}
|
|
2625
|
+
function str4(value) {
|
|
2626
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/commands/operator-write.ts
|
|
2630
|
+
var ACCESS_HINTS = {
|
|
2631
|
+
...REFUSAL_HINTS,
|
|
2632
|
+
operator_disabled: "nothing can be changed until Operator is switched back on for this organization",
|
|
2633
|
+
actor_required: "the credential acts as no person: use a browser login, or an API token a person created",
|
|
2634
|
+
forbidden: "the credential lacks the operator:write scope (a browser login carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
|
|
2635
|
+
run_bound_credential: "a run-bound credential cannot change Operator jobs; use a browser login"
|
|
2636
|
+
};
|
|
2637
|
+
var BAD_REQUEST_HINT = "nothing changed: the server refused a field this CLI sent, so this CLI and the server disagree about the request \u2014 update the CLI";
|
|
2638
|
+
var NEVER_SENT = /* @__PURE__ */ new Set([
|
|
2639
|
+
"ENOTFOUND",
|
|
2640
|
+
"EAI_AGAIN",
|
|
2641
|
+
"EAI_FAIL",
|
|
2642
|
+
"ECONNREFUSED",
|
|
2643
|
+
"ENETUNREACH",
|
|
2644
|
+
"EHOSTUNREACH",
|
|
2645
|
+
"EHOSTDOWN",
|
|
2646
|
+
"EADDRNOTAVAIL",
|
|
2647
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
2648
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
2649
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
2650
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
2651
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
2652
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
2653
|
+
"CERT_HAS_EXPIRED",
|
|
2654
|
+
"CERT_NOT_YET_VALID",
|
|
2655
|
+
"CERT_REVOKED",
|
|
2656
|
+
"CERT_UNTRUSTED",
|
|
2657
|
+
"CERT_REJECTED",
|
|
2658
|
+
"HOSTNAME_MISMATCH",
|
|
2659
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
2660
|
+
"ERR_SSL_WRONG_VERSION_NUMBER"
|
|
2661
|
+
]);
|
|
2662
|
+
function neverSent(error) {
|
|
2663
|
+
return !(error instanceof MalformedResponseError) && error.code !== null && NEVER_SENT.has(error.code);
|
|
2664
|
+
}
|
|
2665
|
+
function outcomeUnknown(why, write2) {
|
|
2666
|
+
return new CommandError(
|
|
2667
|
+
`Outcome unknown: ${why}, so ${write2.mayHave}. It is not retried; check ${write2.check} before trying again.`
|
|
2668
|
+
);
|
|
2669
|
+
}
|
|
2670
|
+
async function sendOnce(ctx, path, body, write2) {
|
|
2671
|
+
try {
|
|
2672
|
+
return await ctx.client.post(path, body, { actor: false });
|
|
2673
|
+
} catch (error) {
|
|
2674
|
+
if (error instanceof TransportError) {
|
|
2675
|
+
if (neverSent(error)) {
|
|
2676
|
+
throw new TransportError(
|
|
2677
|
+
`${error.message} \u2014 the request never reached the server, so nothing changed`,
|
|
2678
|
+
error.code
|
|
2679
|
+
);
|
|
2680
|
+
}
|
|
2681
|
+
throw outcomeUnknown(
|
|
2682
|
+
error instanceof MalformedResponseError ? `the answer could not be read (${error.message})` : `the request failed (${error.message}) after it may have reached the server`,
|
|
2683
|
+
write2
|
|
2684
|
+
);
|
|
2685
|
+
}
|
|
2686
|
+
if (error instanceof HttpError && !(error instanceof UpgradeRequiredError) && error.status >= 500) {
|
|
2687
|
+
throw new HttpError(
|
|
2688
|
+
error.status,
|
|
2689
|
+
`${error.message} \u2014 outcome unknown: ${write2.mayHave}. It is not retried; check ${write2.check} before trying again`,
|
|
2690
|
+
error.body,
|
|
2691
|
+
error.retryAfter
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
throw error;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
function explainRefusal(error, notFound2, hints2) {
|
|
2698
|
+
if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
|
|
2699
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
2700
|
+
const own = Object.hasOwn(hints2, code) ? hints2[code] : void 0;
|
|
2701
|
+
let hint;
|
|
2702
|
+
if (error.status >= 500) hint = own;
|
|
2703
|
+
else if (error.status === 404) hint = notFound2;
|
|
2704
|
+
else if (own !== void 0) hint = own;
|
|
2705
|
+
else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
|
|
2706
|
+
else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
|
|
2707
|
+
if (hint === void 0) return error;
|
|
2708
|
+
const message = error.status < 500 ? error.message : typeof error.body["message"] === "string" ? error.body["message"] : own !== void 0 ? `The server answered ${error.status}` : error.message;
|
|
2709
|
+
return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// src/commands/operator-run.ts
|
|
2713
|
+
var OUTCOME_MAX_CHARACTERS = 1e4;
|
|
2714
|
+
var BUDGET_MAX_DOLLARS = 1e6;
|
|
2715
|
+
var SKILL = /^\/?[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
2716
|
+
var SKILL_MAX_CHARACTERS = 65;
|
|
2717
|
+
var BUDGET = /^\d+(\.\d{1,2})?$/;
|
|
2718
|
+
function prepareRun(outcome, opts = {}, surface = CLI_SURFACE) {
|
|
2719
|
+
const trimmed = outcome.trim();
|
|
2720
|
+
if (trimmed === "") throw new UsageError(`the outcome is empty \u2014 say what the job should achieve${surface.outcomeHint}`);
|
|
2721
|
+
const length = graphemeLength(trimmed);
|
|
2722
|
+
if (length > OUTCOME_MAX_CHARACTERS) {
|
|
2723
|
+
throw new UsageError(
|
|
2724
|
+
`the outcome is ${length} characters once trimmed; a job takes at most ${OUTCOME_MAX_CHARACTERS}`
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
const body = { outcome: trimmed };
|
|
2728
|
+
if (opts.budget !== void 0) {
|
|
2729
|
+
const budget = opts.budget.trim();
|
|
2730
|
+
const amount = BUDGET.test(budget) ? Number(budget) : NaN;
|
|
2731
|
+
if (!(amount > 0) || amount > BUDGET_MAX_DOLLARS) {
|
|
2732
|
+
throw new UsageError(
|
|
2733
|
+
`${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
body["budget"] = amount;
|
|
2737
|
+
}
|
|
2738
|
+
if (opts.skill !== void 0) {
|
|
2739
|
+
const skill = opts.skill.trim();
|
|
2740
|
+
if (skill.length > SKILL_MAX_CHARACTERS || !SKILL.test(skill)) {
|
|
2741
|
+
throw new UsageError(
|
|
2742
|
+
`${surface.skill} takes a skill's invocation name, like /summarize-lps (${surface.skills} lists them)`
|
|
2743
|
+
);
|
|
2744
|
+
}
|
|
2745
|
+
body["skill"] = skill;
|
|
2746
|
+
}
|
|
2747
|
+
return { body };
|
|
2748
|
+
}
|
|
2749
|
+
var CLI_SURFACE = {
|
|
2750
|
+
budget: "--budget",
|
|
2751
|
+
skill: "--skill",
|
|
2752
|
+
outcomeHint: ': run "<outcome>"',
|
|
2753
|
+
skills: "`skills`",
|
|
2754
|
+
look: "`jobs --mine` for the new job",
|
|
2755
|
+
again: "run again"
|
|
2756
|
+
};
|
|
2757
|
+
var MCP_SURFACE = {
|
|
2758
|
+
budget: "budget",
|
|
2759
|
+
skill: "skill",
|
|
2760
|
+
outcomeHint: "",
|
|
2761
|
+
skills: "`skills_list`",
|
|
2762
|
+
look: "`jobs_list` with mine for the new job",
|
|
2763
|
+
again: "call again"
|
|
2764
|
+
};
|
|
2765
|
+
async function startRun(ctx, request, surface = CLI_SURFACE) {
|
|
2766
|
+
const write2 = { mayHave: "a job may have been started", check: surface.look };
|
|
2767
|
+
let data;
|
|
2768
|
+
try {
|
|
2769
|
+
data = (await sendOnce(ctx, "/api/v1/operator/jobs", request.body, write2)).data;
|
|
2770
|
+
} catch (error) {
|
|
2771
|
+
throw explainRefusal(error, "this Harness server has no Operator API for starting jobs yet", hints(error, surface));
|
|
2772
|
+
}
|
|
2773
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
2774
|
+
throw outcomeUnknown("the answer named no job", write2);
|
|
2775
|
+
}
|
|
2776
|
+
const record = data;
|
|
2777
|
+
const jobId = record["job_id"];
|
|
2778
|
+
if (typeof jobId !== "string" || jobId === "") throw outcomeUnknown("the answer named no job", write2);
|
|
2779
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
2780
|
+
return {
|
|
2781
|
+
job_id: jobId,
|
|
2782
|
+
state: text(record["state"]),
|
|
2783
|
+
workbench_url: text(record["workbench_url"]),
|
|
2784
|
+
approval_url: text(record["approval_url"]) === "" ? null : text(record["approval_url"])
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
function hints(error, surface) {
|
|
2788
|
+
const retryAfter = error instanceof HttpError ? error.retryAfter : null;
|
|
2789
|
+
return {
|
|
2790
|
+
budget_not_permitted: `nothing was started: only the organization's admins, who manage its Operator budget, can pass ${surface.budget} \u2014 ${surface.again} without it`,
|
|
2791
|
+
budget_exceeded: `nothing was started: the organization's monthly Operator budget has less than ${surface.budget} left this month \u2014 pass a smaller budget, or ask an admin`,
|
|
2792
|
+
concurrent_change: `nothing was started: Operator or your access changed while the job started \u2014 check that you can still use Operator, then ${surface.again}`,
|
|
2793
|
+
invalid_outcome: `nothing was started: pass the outcome as text of 1 to ${OUTCOME_MAX_CHARACTERS} characters once trimmed, without control characters`,
|
|
2794
|
+
invalid_budget: `nothing was started: ${surface.budget} takes a positive amount in US dollars with at most two decimals, up to ${BUDGET_MAX_DOLLARS}`,
|
|
2795
|
+
unknown_skill: `nothing was started: no Operator skill of that name that you may run in the organization (${surface.skills} lists them)`,
|
|
2796
|
+
rate_limited: "nothing was started: you can start 30 jobs an hour this way" + (retryAfter === null ? "" : `; try again in ${clean(retryAfter)} s`),
|
|
2797
|
+
service_unavailable: "nothing was started; try again later"
|
|
2798
|
+
};
|
|
2799
|
+
}
|
|
2800
|
+
function startedText(job) {
|
|
2801
|
+
return `Started Operator job ${clean(job.job_id)}.
|
|
2802
|
+
` + labelled([
|
|
2803
|
+
["State", clean(job.state) || null],
|
|
2804
|
+
["Workbench", clean(job.workbench_url) || null],
|
|
2805
|
+
["Approve at", job.approval_url === null ? null : clean(job.approval_url)]
|
|
2806
|
+
]) + (job.approval_url === null ? "" : "\nApproving happens in the app; the CLI cannot approve.\n");
|
|
2807
|
+
}
|
|
2808
|
+
async function run(ctx, request, opts = {}) {
|
|
2809
|
+
const job = await startRun(ctx, request);
|
|
2810
|
+
if (opts.watch !== true) return { json: job, human: startedText(job) };
|
|
2811
|
+
const json = opts.json === true;
|
|
2812
|
+
if (!json) opts.onLine?.(startedText(job) + "\n");
|
|
2813
|
+
let watched;
|
|
2814
|
+
try {
|
|
2815
|
+
watched = await jobWatch(ctx, job.job_id, {
|
|
2816
|
+
throughWaits: true,
|
|
2817
|
+
collectEvents: json,
|
|
2818
|
+
onLine: json ? void 0 : opts.onLine,
|
|
2819
|
+
signal: opts.signal,
|
|
2820
|
+
sleep: opts.sleep
|
|
2821
|
+
});
|
|
2822
|
+
} catch (error) {
|
|
2823
|
+
const follow = ` \u2014 the job was started: follow it with \`watch ${job.job_id}\``;
|
|
2824
|
+
if (error instanceof HttpError) {
|
|
2825
|
+
throw new HttpError(error.status, `${error.message}${follow}`, error.body, error.retryAfter);
|
|
2826
|
+
}
|
|
2827
|
+
if (error instanceof TransportError) throw new TransportError(`${error.message}${follow}`, error.code);
|
|
2828
|
+
if (error instanceof CommandError) throw new CommandError(`${error.message}${follow}`);
|
|
2829
|
+
throw error;
|
|
2830
|
+
}
|
|
2831
|
+
return { ...watched, json: { ...job, watch: watched.json } };
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// src/commands/operator-continue.ts
|
|
2835
|
+
async function jobContinue(ctx, id) {
|
|
2836
|
+
requireJobId(id);
|
|
2837
|
+
const write2 = { mayHave: "the job may have been continued", check: `\`job ${id}\`` };
|
|
2838
|
+
let data;
|
|
2839
|
+
try {
|
|
2840
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/continue`, {}, write2)).data;
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
throw explainRefusal(error, notFound(id), refusals(id));
|
|
2843
|
+
}
|
|
2844
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
2845
|
+
throw outcomeUnknown("the answer carried no job", write2);
|
|
2846
|
+
}
|
|
2847
|
+
const job = withheld(data);
|
|
2848
|
+
return { json: job, human: `Continued Operator job ${id}.
|
|
2849
|
+
|
|
2850
|
+
` + jobDetail(job) };
|
|
2851
|
+
}
|
|
2852
|
+
function notFound(id) {
|
|
2853
|
+
return `${missingJob(id)}. If you could see the job until now, it may have gone out of sight just as it was continued: check \`job ${id}\` before continuing it again`;
|
|
2854
|
+
}
|
|
2855
|
+
function refusals(id) {
|
|
2856
|
+
return {
|
|
2857
|
+
principal_required: "only the person the job runs as (its execution principal) can continue it",
|
|
2858
|
+
invalid_state: `the job has nothing to continue in its current state; \`job ${id}\` shows where it stands`,
|
|
2859
|
+
reconciliation_required: "reconcile its partial effects in the app first, from the job's workbench link",
|
|
2860
|
+
principal_inactive: "the person the job runs as lost the rights to run it, so it can't continue until those are restored",
|
|
2861
|
+
deliveries_unsettled: `a delivery is still sending, or its outcome is unknown: if \`job ${id}\` shows it waiting on delivery, settle it in the app; otherwise continue it once the delivery has sent`
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// src/core/textinput.ts
|
|
2866
|
+
import { readFileSync as readFileSync2, statSync as statSync3 } from "fs";
|
|
2867
|
+
import { isatty } from "tty";
|
|
2868
|
+
var STDIN_PATH = "-";
|
|
2869
|
+
var MAX_TEXT_BYTES = 1e6;
|
|
2870
|
+
var TEXT_LIMIT = { bytes: MAX_TEXT_BYTES, why: "that is not prose; check the path" };
|
|
2871
|
+
var DOCUMENT_LIMIT = { bytes: 8e6, why: "larger than the API accepts in one request" };
|
|
2872
|
+
function flagOf(name) {
|
|
2873
|
+
return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
|
|
2874
|
+
}
|
|
2875
|
+
function resolveTextSources(sources) {
|
|
2876
|
+
const resolved = {};
|
|
2877
|
+
const toRead = [];
|
|
2878
|
+
let stdinClaimedBy;
|
|
2879
|
+
for (const { name, inline, file } of sources) {
|
|
2880
|
+
if (inline !== void 0 && file !== void 0) {
|
|
2881
|
+
throw new UsageError(
|
|
2882
|
+
`--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2885
|
+
if (file === void 0) {
|
|
2886
|
+
if (inline !== void 0) resolved[name] = inline;
|
|
2887
|
+
continue;
|
|
2888
|
+
}
|
|
2889
|
+
if (file === STDIN_PATH) {
|
|
2890
|
+
if (isatty(0)) {
|
|
2891
|
+
throw new UsageError(
|
|
2892
|
+
`--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
|
|
2893
|
+
);
|
|
2894
|
+
}
|
|
2895
|
+
if (stdinClaimedBy !== void 0) {
|
|
2896
|
+
throw new UsageError(
|
|
2897
|
+
`--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2900
|
+
stdinClaimedBy = name;
|
|
2901
|
+
}
|
|
2902
|
+
if (file !== STDIN_PATH) checkReadable(file, `--${flagOf(name)}-file`);
|
|
2903
|
+
toRead.push({ name, file });
|
|
2904
|
+
}
|
|
2905
|
+
for (const { name, file } of [...toRead].sort(byStdinLast)) {
|
|
2906
|
+
resolved[name] = stripOneTrailingNewline(readTextFile(file, `--${flagOf(name)}-file`));
|
|
2907
|
+
}
|
|
2908
|
+
return resolved;
|
|
2909
|
+
}
|
|
2910
|
+
function readFileOption(flag, path) {
|
|
2911
|
+
if (path === STDIN_PATH) {
|
|
2912
|
+
if (isatty(0)) {
|
|
2913
|
+
throw new UsageError(`${flag} - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`);
|
|
2914
|
+
}
|
|
2915
|
+
} else {
|
|
2916
|
+
checkReadable(path, flag, DOCUMENT_LIMIT);
|
|
2917
|
+
}
|
|
2918
|
+
return readTextFile(path, flag, DOCUMENT_LIMIT);
|
|
2919
|
+
}
|
|
2920
|
+
function readTextFile(path, option, limit = TEXT_LIMIT) {
|
|
2921
|
+
if (path === STDIN_PATH) {
|
|
2922
|
+
let piped;
|
|
2923
|
+
try {
|
|
2924
|
+
piped = readFileSync2(0);
|
|
2925
|
+
} catch (error) {
|
|
2926
|
+
throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
|
|
2927
|
+
}
|
|
2928
|
+
if (piped.length > limit.bytes) {
|
|
2929
|
+
throw new UsageError(oversize(`${option} -`, limit));
|
|
2930
|
+
}
|
|
2931
|
+
return decodeUtf8(piped, `${option} -`);
|
|
2932
|
+
}
|
|
2933
|
+
const flag = `${option} ${path}`;
|
|
2934
|
+
let bytes;
|
|
2935
|
+
try {
|
|
2936
|
+
bytes = readFileSync2(path);
|
|
2937
|
+
} catch (error) {
|
|
2938
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
2939
|
+
}
|
|
2940
|
+
if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
2941
|
+
return decodeUtf8(bytes, flag);
|
|
2942
|
+
}
|
|
2943
|
+
function decodeUtf8(bytes, flag) {
|
|
2944
|
+
try {
|
|
2945
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
2946
|
+
} catch {
|
|
2947
|
+
throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
function oversize(what, limit) {
|
|
2951
|
+
return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
|
|
2952
|
+
}
|
|
2953
|
+
function checkReadable(path, option, limit = TEXT_LIMIT) {
|
|
2954
|
+
const flag = `${option} ${path}`;
|
|
2955
|
+
let stats3;
|
|
2956
|
+
try {
|
|
2957
|
+
stats3 = statSync3(path);
|
|
2958
|
+
} catch (error) {
|
|
2959
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
2960
|
+
}
|
|
2961
|
+
if (!stats3.isFile()) {
|
|
2962
|
+
throw new UsageError(
|
|
2963
|
+
`${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
if (stats3.size > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
2967
|
+
}
|
|
2968
|
+
function byStdinLast(a, b) {
|
|
2969
|
+
return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
|
|
2970
|
+
}
|
|
2971
|
+
function stripOneTrailingNewline(text) {
|
|
2972
|
+
if (text.endsWith("\r\n")) return text.slice(0, -2);
|
|
2973
|
+
if (text.endsWith("\n")) return text.slice(0, -1);
|
|
2974
|
+
return text;
|
|
2975
|
+
}
|
|
2976
|
+
function describe(error) {
|
|
2977
|
+
const code = error?.code;
|
|
2978
|
+
if (code === "ENOENT") return "no such file";
|
|
2979
|
+
if (code === "EISDIR") return "is a directory";
|
|
2980
|
+
if (code === "EACCES") return "permission denied";
|
|
2981
|
+
return error instanceof Error ? error.message : String(error);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/commands/operator-controls.ts
|
|
2985
|
+
var STEER_MAX_CHARACTERS = 1e4;
|
|
2986
|
+
var DONE = { steer: "steered", cancel: "cancelled", pause: "paused" };
|
|
2987
|
+
function requireInstruction(instruction) {
|
|
2988
|
+
const trimmed = instruction.trim();
|
|
2989
|
+
if (trimmed === "") throw new UsageError("the instruction is empty \u2014 say what the job should change");
|
|
2990
|
+
const length = graphemeLength(trimmed);
|
|
2991
|
+
if (length > STEER_MAX_CHARACTERS) {
|
|
2992
|
+
throw new UsageError(
|
|
2993
|
+
`the instruction is ${length} characters once trimmed; a job takes at most ${STEER_MAX_CHARACTERS}`
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
return trimmed;
|
|
2997
|
+
}
|
|
2998
|
+
function instructionFrom(words, file) {
|
|
2999
|
+
if (file === void 0) return requireInstruction(words.join(" "));
|
|
3000
|
+
if (words.length > 0) {
|
|
3001
|
+
throw new UsageError(
|
|
3002
|
+
"instruction words and --instruction-file both given \u2014 pass one; there is no precedence between them"
|
|
3003
|
+
);
|
|
3004
|
+
}
|
|
3005
|
+
return requireInstruction(resolveTextSources([{ name: "instruction", inline: void 0, file }])["instruction"] ?? "");
|
|
3006
|
+
}
|
|
3007
|
+
async function jobSteer(ctx, id, instruction) {
|
|
3008
|
+
requireJobId(id);
|
|
3009
|
+
const job = await control(ctx, id, "steer", { instruction: requireInstruction(instruction) });
|
|
3010
|
+
return {
|
|
3011
|
+
json: job,
|
|
3012
|
+
human: `Steered Operator job ${id}: the instruction is posted in its room, attributed to you. Work it changes needs a fresh approval (earlier approvals no longer apply), and a run in progress stops to take it.
|
|
3013
|
+
|
|
3014
|
+
` + jobDetail(job)
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
3017
|
+
async function jobCancel(ctx, id) {
|
|
3018
|
+
requireJobId(id);
|
|
3019
|
+
const job = await control(ctx, id, "cancel", {});
|
|
3020
|
+
const effects = effectsLine(job["effects"]);
|
|
3021
|
+
return {
|
|
3022
|
+
json: job,
|
|
3023
|
+
human: `Cancelled Operator job ${id}. ` + (effects === null ? "Committed effects stay committed; the answer carried no counts.\n" : `Committed effects stay committed: ${clean(effects)} (staged effects that never ran count as unknown).
|
|
3024
|
+
`) + "\n" + jobDetail(job)
|
|
3025
|
+
};
|
|
3026
|
+
}
|
|
3027
|
+
async function jobPause(ctx, id) {
|
|
3028
|
+
requireJobId(id);
|
|
3029
|
+
const job = await control(ctx, id, "pause", {});
|
|
3030
|
+
return {
|
|
3031
|
+
json: job,
|
|
3032
|
+
human: `Paused Operator job ${id}. Its active-time budget is kept; continue it to run again.
|
|
3033
|
+
|
|
3034
|
+
` + jobDetail(job)
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
async function control(ctx, id, verb, body) {
|
|
3038
|
+
const write2 = { mayHave: `the job may have been ${DONE[verb]}`, check: `\`job ${id}\`` };
|
|
3039
|
+
let data;
|
|
3040
|
+
try {
|
|
3041
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/${verb}`, body, write2)).data;
|
|
3042
|
+
} catch (error) {
|
|
3043
|
+
throw explained2(error, id, verb);
|
|
3044
|
+
}
|
|
3045
|
+
if (!isRecord3(data)) throw outcomeUnknown("the answer carried no job", write2);
|
|
3046
|
+
return withheld(data);
|
|
3047
|
+
}
|
|
3048
|
+
function explained2(error, id, verb) {
|
|
3049
|
+
if (!(error instanceof HttpError) || error.status >= 500) return error;
|
|
3050
|
+
const hint = refusalHint(error, id, verb);
|
|
3051
|
+
if (hint === void 0) return error;
|
|
3052
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
3053
|
+
}
|
|
3054
|
+
function refusalHint(error, id, verb) {
|
|
3055
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
3056
|
+
const job = `\`job ${id}\``;
|
|
3057
|
+
switch (error.status) {
|
|
3058
|
+
case 400:
|
|
3059
|
+
return code === "bad_request" ? BAD_REQUEST_HINT : void 0;
|
|
3060
|
+
case 403:
|
|
3061
|
+
if (code !== "principal_required") return ACCESS_HINTS[code];
|
|
3062
|
+
return verb === "pause" ? "only the job's execution principal may pause it; as its owner or a collaborator you can steer it instead, which stops its current run" : `only the job's execution principal may ${verb} it; ask them to`;
|
|
3063
|
+
case 404:
|
|
3064
|
+
return missingJob(id);
|
|
3065
|
+
case 409:
|
|
3066
|
+
return conflictHint(code, verb, job);
|
|
3067
|
+
case 422:
|
|
3068
|
+
if (code === "invalid_instruction") {
|
|
3069
|
+
return `pass the instruction as text of 1 to ${STEER_MAX_CHARACTERS} characters once trimmed`;
|
|
3070
|
+
}
|
|
3071
|
+
return code === "validation_error" && verb === "steer" ? "the job's room refused the instruction, so nothing was posted" : void 0;
|
|
3072
|
+
default:
|
|
3073
|
+
return void 0;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
function conflictHint(code, verb, job) {
|
|
3077
|
+
switch (code) {
|
|
3078
|
+
case "invalid_state":
|
|
3079
|
+
return {
|
|
3080
|
+
steer: `the app doesn't offer Steer for the job now: it has finished, failed, waits for approval or needs reconciliation \u2014 ${job} shows where it stands`,
|
|
3081
|
+
cancel: `the job has already ended \u2014 ${job} shows how`,
|
|
3082
|
+
pause: `only a running job can be paused \u2014 ${job} shows where it stands`
|
|
3083
|
+
}[verb];
|
|
3084
|
+
case "reconciliation_required":
|
|
3085
|
+
return `its partial effects need reconciling first: reconcile them in the app (${job} has the workbench link)`;
|
|
3086
|
+
case "deliveries_unsettled":
|
|
3087
|
+
return `a delivery is still sending, or its outcome is unknown: if ${job} shows it waiting on delivery, settle that in the app (the workbench link is there); otherwise ${verb} it again once the delivery has sent`;
|
|
3088
|
+
case "principal_inactive":
|
|
3089
|
+
return "nothing changed: the job's execution principal needs their admin or member role back first";
|
|
3090
|
+
case "cancellation_scope_changed":
|
|
3091
|
+
return `the job was not cancelled; read it with ${job} and decide again`;
|
|
3092
|
+
default:
|
|
3093
|
+
return void 0;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
function isRecord3(value) {
|
|
3097
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
// src/commands/operator-procedures.ts
|
|
3101
|
+
var PROCEDURE_FIELDS = [
|
|
3102
|
+
"id",
|
|
3103
|
+
"name",
|
|
3104
|
+
"job_id",
|
|
3105
|
+
"job_url",
|
|
3106
|
+
"current_version",
|
|
3107
|
+
"parameter_schema",
|
|
3108
|
+
"versions",
|
|
3109
|
+
"inserted_at",
|
|
3110
|
+
"updated_at"
|
|
3111
|
+
];
|
|
3112
|
+
var PROCEDURE_VERSION_FIELDS = [
|
|
3113
|
+
"version",
|
|
3114
|
+
"recipe_digest",
|
|
3115
|
+
"capability_ids",
|
|
3116
|
+
"action_roster",
|
|
3117
|
+
"inserted_at"
|
|
3118
|
+
];
|
|
3119
|
+
var RUN_NOTE = "A run is a new job: fresh inputs, your current rights and a change set that needs its own approval. Nothing of the saved job carries over. Approving happens in the app; the CLI cannot approve.\n";
|
|
3120
|
+
function requireProcedureId(id) {
|
|
3121
|
+
if (!isUuid(id)) throw new UsageError(`not an Operator procedure id: ${id} (expected the procedure's UUID)`);
|
|
3122
|
+
}
|
|
3123
|
+
function missingProcedure(id) {
|
|
3124
|
+
return `no Operator procedure ${id} you can see: it does not exist or was deleted, is in another organization, was saved from a job you can't see, or was saved for the whole organization rather than a job (or this Harness server has no procedures API yet)`;
|
|
3125
|
+
}
|
|
3126
|
+
function requireProcedureName(name) {
|
|
3127
|
+
if (name === void 0 || name.trim() === "") {
|
|
3128
|
+
throw new UsageError('a procedure needs a name: procedure save <job-id> --name "\u2026"');
|
|
3129
|
+
}
|
|
3130
|
+
return name;
|
|
3131
|
+
}
|
|
3132
|
+
async function procedureSave(ctx, jobId, name) {
|
|
3133
|
+
requireJobId(jobId);
|
|
3134
|
+
const named2 = requireProcedureName(name);
|
|
3135
|
+
const write2 = {
|
|
3136
|
+
mayHave: "a procedure may have been saved",
|
|
3137
|
+
check: `the job's procedures with \`job ${jobId}\``
|
|
3138
|
+
};
|
|
3139
|
+
let data;
|
|
3140
|
+
try {
|
|
3141
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named2 }, write2)).data;
|
|
3142
|
+
} catch (error) {
|
|
3143
|
+
throw explainRefusal(error, missingJob(jobId), {
|
|
3144
|
+
owner_required: "only the job's owner can save it as a procedure; ask them to save it",
|
|
3145
|
+
not_saveable: "only a job that completed with its work verified, and has work to repeat, can be saved",
|
|
3146
|
+
validation_error: "the name must be plain text, without control characters"
|
|
3147
|
+
});
|
|
3148
|
+
}
|
|
3149
|
+
if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
|
|
3150
|
+
throw outcomeUnknown("the answer named no procedure", write2);
|
|
3151
|
+
}
|
|
3152
|
+
const saved = `Saved job ${jobId} as procedure ${clean(data["id"])}`;
|
|
3153
|
+
if (!Array.isArray(data["versions"])) {
|
|
3154
|
+
return {
|
|
3155
|
+
json: pick2(data, ["id", "url"]),
|
|
3156
|
+
human: `${saved}, but it can't be read back as you.
|
|
3157
|
+
` + labelled([["Workbench", str5(data["url"]) || null]])
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
const procedure = procedureJson(data);
|
|
3161
|
+
return { json: procedure, human: `${saved}.
|
|
3162
|
+
|
|
3163
|
+
` + procedureDetail(procedure) };
|
|
3164
|
+
}
|
|
3165
|
+
async function procedureShow(ctx, id) {
|
|
3166
|
+
requireProcedureId(id);
|
|
3167
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/procedures/${id}`), {
|
|
3168
|
+
notFound: missingProcedure(id)
|
|
3169
|
+
});
|
|
3170
|
+
if (!isRecord4(body.data)) throw new CommandError("The procedure response carried no procedure.");
|
|
3171
|
+
const procedure = procedureJson(body.data);
|
|
3172
|
+
return { json: procedure, human: procedureDetail(procedure) };
|
|
3173
|
+
}
|
|
3174
|
+
function prepareProcedureRun(id, opts) {
|
|
3175
|
+
requireProcedureId(id);
|
|
3176
|
+
const body = {};
|
|
3177
|
+
const version2 = runVersion(opts.version);
|
|
3178
|
+
if (version2 !== void 0) body["version"] = version2;
|
|
3179
|
+
const given = [
|
|
3180
|
+
opts.param !== void 0 && opts.param.length > 0 ? "--param" : null,
|
|
3181
|
+
opts.paramsFile !== void 0 ? "--params-file" : null,
|
|
3182
|
+
opts.parameters !== void 0 ? "parameters" : null
|
|
3183
|
+
].filter((source) => source !== null);
|
|
3184
|
+
if (given.length > 1) {
|
|
3185
|
+
throw new UsageError(`${given.join(" and ")} both given \u2014 pass the parameters one way; there is no precedence`);
|
|
3186
|
+
}
|
|
3187
|
+
if (opts.parameters !== void 0 && !isRecord4(opts.parameters)) {
|
|
3188
|
+
throw new UsageError("parameters must be one object of names and values");
|
|
3189
|
+
}
|
|
3190
|
+
const fromFlags = opts.param !== void 0 && opts.param.length > 0 ? parseParams(opts.param) : void 0;
|
|
3191
|
+
if (opts.group !== void 0) body["group_id"] = bareId(opts.group, "--group");
|
|
3192
|
+
if (opts.source !== void 0) body["source_resource_id"] = bareId(opts.source, "--source");
|
|
3193
|
+
const parameters = fromFlags ?? opts.parameters ?? (opts.paramsFile === void 0 ? void 0 : readParamsFile(opts.paramsFile));
|
|
3194
|
+
if (parameters !== void 0) body["parameters"] = parameters;
|
|
3195
|
+
return { id, body };
|
|
3196
|
+
}
|
|
3197
|
+
async function startProcedureRun(ctx, request) {
|
|
3198
|
+
const { id } = request;
|
|
3199
|
+
const write2 = { mayHave: "a job may have been started", check: "`jobs` for the new job" };
|
|
3200
|
+
let data;
|
|
3201
|
+
try {
|
|
3202
|
+
data = (await sendOnce(ctx, `/api/v1/operator/procedures/${id}/runs`, request.body, write2)).data;
|
|
3203
|
+
} catch (error) {
|
|
3204
|
+
throw explainRefusal(error, missingProcedure(id), {
|
|
3205
|
+
version_conflict: `the procedure has a newer current version (\`procedure show ${id}\` names it): run that one, or run without a version to take the current one`,
|
|
3206
|
+
concurrent_change: `read the procedure again (\`procedure show ${id}\`) before running it again`,
|
|
3207
|
+
invalid_rerun: `check the parameters against the procedure's parameter schema (\`procedure show ${id}\`), and that your current rights cover the run`,
|
|
3208
|
+
// The server refuses with it before the run starts.
|
|
3209
|
+
service_unavailable: "nothing was started; try again later"
|
|
3210
|
+
});
|
|
3211
|
+
}
|
|
3212
|
+
if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
|
|
3213
|
+
throw outcomeUnknown("the answer named no job", write2);
|
|
3214
|
+
}
|
|
3215
|
+
const started = `Started Operator job ${clean(data["id"])} from procedure ${id}`;
|
|
3216
|
+
if (typeof data["state"] !== "string") {
|
|
3217
|
+
return {
|
|
3218
|
+
json: pick2(data, ["id", "url"]),
|
|
3219
|
+
human: `${started}, but it can't be read back as you.
|
|
3220
|
+
` + labelled([["Workbench", str5(data["url"]) || null]]) + "\n" + RUN_NOTE
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
const job = withheld(data);
|
|
3224
|
+
const waitingOn = str5(job["waiting_on"]);
|
|
3225
|
+
return {
|
|
3226
|
+
json: job,
|
|
3227
|
+
human: `${started}.
|
|
3228
|
+
` + labelled([
|
|
3229
|
+
["State", str5(job["state"]) + (waitingOn === "" ? "" : ` \xB7 waiting on ${waitingOn}`)],
|
|
3230
|
+
["Workbench", str5(job["url"]) || null],
|
|
3231
|
+
["Approve at", str5(job["approval_url"]) || null]
|
|
3232
|
+
]) + "\n" + RUN_NOTE
|
|
3233
|
+
};
|
|
3234
|
+
}
|
|
3235
|
+
async function procedureRun(ctx, id, opts) {
|
|
3236
|
+
return startProcedureRun(ctx, prepareProcedureRun(id, opts));
|
|
3237
|
+
}
|
|
3238
|
+
function runVersion(version2) {
|
|
3239
|
+
if (version2 === void 0) return void 0;
|
|
3240
|
+
const text = String(version2).trim();
|
|
3241
|
+
const parsed = typeof version2 === "number" ? version2 : /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
3242
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
3243
|
+
throw new UsageError(`--version takes a positive integer, the version expected to be current (got ${text})`);
|
|
3244
|
+
}
|
|
3245
|
+
return parsed;
|
|
3246
|
+
}
|
|
3247
|
+
function parseParams(entries) {
|
|
3248
|
+
const parsed = [];
|
|
3249
|
+
const names = /* @__PURE__ */ new Set();
|
|
3250
|
+
for (const entry of entries) {
|
|
3251
|
+
const equals = entry.indexOf("=");
|
|
3252
|
+
if (equals === -1) throw new UsageError(`--param ${entry}: expected name=value, or name:=<json> for a JSON value`);
|
|
3253
|
+
const typed = equals > 0 && entry[equals - 1] === ":";
|
|
3254
|
+
const name = entry.slice(0, typed ? equals - 1 : equals);
|
|
3255
|
+
const raw = entry.slice(equals + 1);
|
|
3256
|
+
if (name.trim() === "") throw new UsageError(`--param ${entry}: the parameter has no name`);
|
|
3257
|
+
if (name !== name.trim()) {
|
|
3258
|
+
throw new UsageError(`--param ${entry}: the name starts or ends with whitespace \u2014 write it without`);
|
|
3259
|
+
}
|
|
3260
|
+
if (names.has(name)) throw new UsageError(`--param ${name} given twice \u2014 pass each parameter once`);
|
|
3261
|
+
names.add(name);
|
|
3262
|
+
parsed.push([name, typed ? jsonValue(raw, `--param ${entry}`) : raw]);
|
|
3263
|
+
}
|
|
3264
|
+
return Object.fromEntries(parsed);
|
|
3265
|
+
}
|
|
3266
|
+
function jsonValue(text, what) {
|
|
3267
|
+
try {
|
|
3268
|
+
return JSON.parse(text);
|
|
3269
|
+
} catch (error) {
|
|
3270
|
+
throw new UsageError(`${what}: not valid JSON after := (${firstLine(error)}); quote a string as name=value`);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
function readParamsFile(path) {
|
|
3274
|
+
const text = readFileOption("--params-file", path);
|
|
3275
|
+
let parsed;
|
|
3276
|
+
try {
|
|
3277
|
+
parsed = JSON.parse(text);
|
|
3278
|
+
} catch (error) {
|
|
3279
|
+
throw new UsageError(`--params-file ${path}: not valid JSON (${firstLine(error)})`);
|
|
3280
|
+
}
|
|
3281
|
+
if (!isRecord4(parsed)) {
|
|
3282
|
+
throw new UsageError(`--params-file ${path}: the parameters are one JSON object of names and values`);
|
|
3283
|
+
}
|
|
3284
|
+
return parsed;
|
|
3285
|
+
}
|
|
3286
|
+
function bareId(value, flag) {
|
|
3287
|
+
if (value.trim() === "") throw new UsageError(`${flag} is empty`);
|
|
3288
|
+
if (value !== value.trim()) throw new UsageError(`${flag} starts or ends with whitespace \u2014 pass the id alone`);
|
|
3289
|
+
return value;
|
|
3290
|
+
}
|
|
3291
|
+
function procedureJson(data) {
|
|
3292
|
+
const procedure = pick2(data, PROCEDURE_FIELDS);
|
|
3293
|
+
if ("versions" in procedure) {
|
|
3294
|
+
procedure["versions"] = records3(procedure["versions"]).map((version2) => pick2(version2, PROCEDURE_VERSION_FIELDS));
|
|
3295
|
+
}
|
|
3296
|
+
return procedure;
|
|
3297
|
+
}
|
|
3298
|
+
function procedureDetail(procedure) {
|
|
3299
|
+
const current = procedure["current_version"];
|
|
3300
|
+
const versions = records3(procedure["versions"]).map(
|
|
3301
|
+
(version2) => ` ${clean(`v${str5(version2["version"])}${version2["version"] === current ? " (current)" : ""} ${str5(version2["inserted_at"])} recipe ${str5(version2["recipe_digest"])}`)}
|
|
3302
|
+
${"Capabilities:".padEnd(15)}${clean(list2(version2["capability_ids"]))}
|
|
3303
|
+
${"Actions:".padEnd(15)}${clean(list2(version2["action_roster"]))}
|
|
3304
|
+
`
|
|
3305
|
+
);
|
|
3306
|
+
return `Procedure ${clean(procedure["name"])} \xB7 v${clean(current)} \xB7 ${clean(procedure["id"])}
|
|
3307
|
+
` + labelled([
|
|
3308
|
+
["Saved from", `job ${str5(procedure["job_id"])}`],
|
|
3309
|
+
["Workbench", str5(procedure["job_url"]) || null],
|
|
3310
|
+
["Created", str5(procedure["inserted_at"]) || null],
|
|
3311
|
+
["Updated", str5(procedure["updated_at"]) || null]
|
|
3312
|
+
]) + (versions.length === 0 ? "\nVersions: none\n" : "\nVersions:\n" + versions.join("")) + parametersSection(procedure["parameter_schema"]) + "\n" + RUN_NOTE;
|
|
3313
|
+
}
|
|
3314
|
+
function parametersSection(schema) {
|
|
3315
|
+
if (!isRecord4(schema) || Object.keys(schema).length === 0) return "\nParameters a run takes: none\n";
|
|
3316
|
+
const lines = JSON.stringify(schema, null, 2).split("\n").map((line) => ` ${clean(line)}
|
|
3317
|
+
`).join("");
|
|
3318
|
+
const scoped = isRecord4(schema["scoped_inputs"]) ? schema["scoped_inputs"]["required"] : void 0;
|
|
3319
|
+
const scopes = Array.isArray(scoped) ? scoped.map(str5) : [];
|
|
3320
|
+
let needs = "";
|
|
3321
|
+
if (scopes.includes("contact_reconciliation")) {
|
|
3322
|
+
needs = "Its runs also need --group <contact-group-id> and --source <uploaded-file-id> (contact_reconciliation).\n";
|
|
3323
|
+
} else if (scopes.length > 0) {
|
|
3324
|
+
needs = `Its runs also need inputs resolved afresh: ${clean(scopes.join(", "))}.
|
|
3325
|
+
`;
|
|
3326
|
+
}
|
|
3327
|
+
return "\nParameters a run takes (JSON schema):\n" + lines + needs;
|
|
3328
|
+
}
|
|
3329
|
+
function list2(value) {
|
|
3330
|
+
return (Array.isArray(value) ? value.map(str5).join(", ") : "") || "none";
|
|
3331
|
+
}
|
|
3332
|
+
function pick2(record, keys) {
|
|
3333
|
+
return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
3334
|
+
}
|
|
3335
|
+
function records3(value) {
|
|
3336
|
+
return Array.isArray(value) ? value.filter(isRecord4) : [];
|
|
3337
|
+
}
|
|
3338
|
+
function isRecord4(value) {
|
|
3339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3340
|
+
}
|
|
3341
|
+
function str5(value) {
|
|
3342
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
3343
|
+
}
|
|
3344
|
+
function firstLine(error) {
|
|
3345
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
3346
|
+
}
|
|
3347
|
+
|
|
3348
|
+
// src/commands/plans.ts
|
|
3349
|
+
import { extname as extname2 } from "path";
|
|
3350
|
+
import { parseDocument } from "yaml";
|
|
3351
|
+
var PLAN_EXECUTORS = ["none", "operator"];
|
|
3352
|
+
var CONDITION_KINDS = ["not_before", "gate", "event"];
|
|
3353
|
+
var GATE_OWNER_ROLES = ["gp", "compliance_officer", "valuer", "accountant", "auditor", "counsel"];
|
|
3354
|
+
var PLAN_STATUSES = ["held", "released", "running", "completed", "needs_attention"];
|
|
3355
|
+
var PLAN_LIST_MAX_LIMIT = 100;
|
|
3356
|
+
var PLAN_LIST_DEFAULT_LIMIT = 50;
|
|
3357
|
+
function parsePlanDocument(text, source) {
|
|
3358
|
+
const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
|
|
3359
|
+
if (!isRecord5(parsed)) {
|
|
3360
|
+
throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
|
|
3361
|
+
}
|
|
3362
|
+
return parsed;
|
|
3363
|
+
}
|
|
3364
|
+
function parseJson(text, source) {
|
|
3365
|
+
try {
|
|
3366
|
+
return JSON.parse(text);
|
|
3367
|
+
} catch (error) {
|
|
3368
|
+
throw new UsageError(`${source}: not valid JSON (${firstLine2(error)})`);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
function parseYamlDocument(text, source) {
|
|
3372
|
+
const document = parseDocument(text, { resolveKnownTags: false });
|
|
3373
|
+
const problem = document.errors[0] ?? document.warnings[0];
|
|
3374
|
+
if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine2(problem)})`);
|
|
3375
|
+
let value;
|
|
3376
|
+
try {
|
|
3377
|
+
value = document.toJS();
|
|
3378
|
+
} catch (error) {
|
|
3379
|
+
throw new UsageError(`${source}: not valid YAML (${firstLine2(error)})`);
|
|
3380
|
+
}
|
|
3381
|
+
if (!jsonSafe(value)) {
|
|
3382
|
+
throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
|
|
3383
|
+
}
|
|
3384
|
+
return value;
|
|
3385
|
+
}
|
|
3386
|
+
function jsonSafe(value) {
|
|
3387
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
3388
|
+
if (Array.isArray(value)) return value.every(jsonSafe);
|
|
3389
|
+
if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
|
|
3390
|
+
return true;
|
|
3391
|
+
}
|
|
3392
|
+
function firstLine2(error) {
|
|
3393
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
3394
|
+
}
|
|
3395
|
+
function readPlanFile(path, flag = "--file") {
|
|
3396
|
+
return parsePlanDocument(readFileOption(flag, path), path);
|
|
3397
|
+
}
|
|
3398
|
+
async function planCreate(ctx, plan, opts) {
|
|
3399
|
+
requireDocument(plan, "a plan is one object: the root's fields and its steps");
|
|
3400
|
+
const team = requiredTeam(ctx, opts.team);
|
|
3401
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
3402
|
+
let body;
|
|
3403
|
+
try {
|
|
3404
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/plans`, plan, { idempotencyKey: opts.idempotencyKey });
|
|
3405
|
+
} catch (error) {
|
|
3406
|
+
throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
|
|
3407
|
+
}
|
|
3408
|
+
const created = body.data;
|
|
3409
|
+
const id = str6(created["id"]);
|
|
3410
|
+
return {
|
|
3411
|
+
json: created,
|
|
3412
|
+
human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
|
|
3413
|
+
|
|
3414
|
+
` + planDetail(created) + `
|
|
3415
|
+
Nothing runs until it is released: ${invokedName()} plan release ${clean(id)}
|
|
3416
|
+
`
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
function requirePlanListArgs(opts) {
|
|
3420
|
+
if (opts.status !== void 0 && !PLAN_STATUSES.includes(opts.status)) {
|
|
3421
|
+
throw new UsageError(`status must be one of: ${PLAN_STATUSES.join(", ")}`);
|
|
3422
|
+
}
|
|
3423
|
+
if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > PLAN_LIST_MAX_LIMIT)) {
|
|
3424
|
+
throw new UsageError(`--limit takes 1 to ${PLAN_LIST_MAX_LIMIT}: the server returns at most ${PLAN_LIST_MAX_LIMIT} plans`);
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
async function planList(ctx, opts) {
|
|
3428
|
+
requirePlanListArgs(opts);
|
|
3429
|
+
const team = requiredTeam(ctx, opts.team);
|
|
3430
|
+
let body;
|
|
3431
|
+
try {
|
|
3432
|
+
body = await ctx.client.get(`/api/v1/teams/${team}/plans`, { status: opts.status, limit: opts.limit });
|
|
3433
|
+
} catch (error) {
|
|
3434
|
+
throw explainMissing(error, `no team ${team} here, or this Harness server cannot list plans yet`);
|
|
3435
|
+
}
|
|
3436
|
+
if (!Array.isArray(body.data)) throw new CommandError("The plan list response carried no list.");
|
|
3437
|
+
const list3 = body.data.filter(isRecord5);
|
|
3438
|
+
const limit = opts.limit ?? PLAN_LIST_DEFAULT_LIMIT;
|
|
3439
|
+
let notice;
|
|
3440
|
+
if (list3.length >= limit) {
|
|
3441
|
+
notice = limit < PLAN_LIST_MAX_LIMIT ? `harness: showing the newest ${list3.length} plans \u2014 pass --limit (up to ${PLAN_LIST_MAX_LIMIT}) for older ones.
|
|
3442
|
+
` : `harness: showing the newest ${list3.length} plans, the most one list returns \u2014 narrow it with --status.
|
|
3443
|
+
`;
|
|
3444
|
+
}
|
|
3445
|
+
const what = opts.status === void 0 ? "plans" : `${opts.status} plans`;
|
|
3446
|
+
const human = list3.length === 0 ? `No ${what} in team ${team}.
|
|
3447
|
+
` : plansTable(list3);
|
|
3448
|
+
return notice === void 0 ? { json: body.data, human } : { json: body.data, human, notice };
|
|
3449
|
+
}
|
|
3450
|
+
async function planShow(ctx, id) {
|
|
3451
|
+
requirePlanRef(id);
|
|
3452
|
+
const plan = await planRequest(id, () => ctx.client.get(`/api/v1/plans/${id}`));
|
|
3453
|
+
return { json: plan, human: planDetail(plan) };
|
|
3454
|
+
}
|
|
3455
|
+
async function planEdit(ctx, id, edits) {
|
|
3456
|
+
requirePlanRef(id);
|
|
3457
|
+
requireDocument(edits, "a plan edit is one object: steps to change and budgets");
|
|
3458
|
+
const plan = await planRequest(id, () => ctx.client.patch(`/api/v1/plans/${id}`, edits));
|
|
3459
|
+
const held = Array.isArray(plan["held_steps"]) && plan["held_steps"].length > 0;
|
|
3460
|
+
return {
|
|
3461
|
+
json: plan,
|
|
3462
|
+
human: `Updated plan ${clean(id)}
|
|
3463
|
+
|
|
3464
|
+
` + planDetail(plan) + (held ? `
|
|
3465
|
+
Only Operator steps are held until the next release; release the plan again to take in the changes: ${invokedName()} plan release ${clean(id)}
|
|
3466
|
+
` : "")
|
|
3467
|
+
};
|
|
3468
|
+
}
|
|
3469
|
+
async function planRelease(ctx, id, opts) {
|
|
3470
|
+
requirePlanRef(id);
|
|
3471
|
+
const body = {};
|
|
3472
|
+
if (opts.collaborators !== void 0 && opts.collaborators.length > 0) body["collaborators"] = opts.collaborators;
|
|
3473
|
+
if (opts.retryBudget !== void 0) body["retry_budget"] = opts.retryBudget;
|
|
3474
|
+
if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
|
|
3475
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
|
|
3476
|
+
const revision = plan["revision"] ?? {};
|
|
3477
|
+
const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord5(step) && step["ready"] === true).length : 0;
|
|
3478
|
+
return {
|
|
3479
|
+
json: plan,
|
|
3480
|
+
human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
|
|
3481
|
+
`
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
async function planHold(ctx, id) {
|
|
3485
|
+
requirePlanRef(id);
|
|
3486
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/hold`));
|
|
3487
|
+
return {
|
|
3488
|
+
json: plan,
|
|
3489
|
+
human: `Held plan ${clean(id)}: none of its steps is ready until it is released again
|
|
3490
|
+
`
|
|
3491
|
+
};
|
|
3492
|
+
}
|
|
3493
|
+
async function gateSet(ctx, issue, gate, satisfied) {
|
|
3494
|
+
requireIdentifiers([issue]);
|
|
3495
|
+
const wanted = gate.trim();
|
|
3496
|
+
if (wanted === "") throw new UsageError("name the gate by its key, its label or its condition id");
|
|
3497
|
+
const expert = isExpertToken(ctx.config.token);
|
|
3498
|
+
let list3 = null;
|
|
3499
|
+
if (!isUuid(wanted)) {
|
|
3500
|
+
try {
|
|
3501
|
+
list3 = await readConditions(ctx, issue, expert);
|
|
3502
|
+
} catch (error) {
|
|
3503
|
+
if (error instanceof HttpError && error.status === 403) {
|
|
3504
|
+
throw new HttpError(
|
|
3505
|
+
error.status,
|
|
3506
|
+
`${error.message} \u2014 find the gate by its condition id; only Harness staff can list an issue's conditions`,
|
|
3507
|
+
error.body,
|
|
3508
|
+
error.retryAfter
|
|
3509
|
+
);
|
|
3510
|
+
}
|
|
3511
|
+
throw error;
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
const target = list3 === null ? { id: wanted.toLowerCase(), label: wanted.toLowerCase() } : findGate(list3, issue, wanted, expert);
|
|
3515
|
+
const id = str6(target["id"]);
|
|
3516
|
+
let body;
|
|
3517
|
+
try {
|
|
3518
|
+
body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, { satisfied });
|
|
3519
|
+
} catch (error) {
|
|
3520
|
+
throw await explainNotGateOwner(ctx, error, target, issue, satisfied, list3 !== null);
|
|
3521
|
+
}
|
|
3522
|
+
const others = list3?.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length ?? 0;
|
|
3523
|
+
let tail = "";
|
|
3524
|
+
if (satisfied && list3 !== null && expert) {
|
|
3525
|
+
tail = others > 0 ? ` \u2014 ${others} other gate${others === 1 ? "" : "s"} of yours on it ${others === 1 ? "is" : "are"} open` : "";
|
|
3526
|
+
} else if (satisfied && list3 !== null) {
|
|
3527
|
+
tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
|
|
3528
|
+
}
|
|
3529
|
+
const label = list3 === null && isRecord5(body.data) ? body.data["label"] ?? id : target["label"];
|
|
3530
|
+
return {
|
|
3531
|
+
json: body.data,
|
|
3532
|
+
human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(label)}" on ${issue}${tail}
|
|
3533
|
+
`
|
|
3534
|
+
};
|
|
3535
|
+
}
|
|
3536
|
+
async function conditionList(ctx, issue) {
|
|
3537
|
+
requireIdentifiers([issue]);
|
|
3538
|
+
const expert = isExpertToken(ctx.config.token);
|
|
3539
|
+
const list3 = await readConditions(ctx, issue, expert);
|
|
3540
|
+
let human;
|
|
3541
|
+
if (list3.length === 0) {
|
|
3542
|
+
human = expert ? `You own no gate on ${issue}.
|
|
3543
|
+
` : `No wait conditions on ${issue}.
|
|
3544
|
+
`;
|
|
3545
|
+
} else {
|
|
3546
|
+
human = (expert ? `Your gates on ${issue}:
|
|
3547
|
+
` : `Wait conditions on ${issue}:
|
|
3548
|
+
`) + list3.map((condition) => ` ${conditionLine(condition, { expertView: expert })}
|
|
3549
|
+
`).join("");
|
|
3550
|
+
}
|
|
3551
|
+
return { json: list3, human };
|
|
3552
|
+
}
|
|
3553
|
+
async function readConditions(ctx, issue, expert) {
|
|
3554
|
+
let conditions;
|
|
3555
|
+
try {
|
|
3556
|
+
conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
|
|
3557
|
+
} catch (error) {
|
|
3558
|
+
throw explainMissing(
|
|
3559
|
+
error,
|
|
3560
|
+
expert ? `no step ${issue} assigned to you: a pxc_ credential reads only its own steps` : `no issue ${issue} here, or this Harness server has no wait conditions yet`
|
|
3561
|
+
);
|
|
3562
|
+
}
|
|
3563
|
+
if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
|
|
3564
|
+
return conditions.filter(isRecord5);
|
|
3565
|
+
}
|
|
3566
|
+
function requirePlanRef(id) {
|
|
3567
|
+
if (!isUuid(id) && !isIdentifier(id)) {
|
|
3568
|
+
throw new UsageError(`not a plan: ${id} (expected the plan's id or its root issue's identifier, e.g. FR-1)`);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
function requireDocument(value, what) {
|
|
3572
|
+
if (!isRecord5(value)) throw new UsageError(what);
|
|
3573
|
+
}
|
|
3574
|
+
async function planRequest(id, send) {
|
|
3575
|
+
try {
|
|
3576
|
+
return (await send()).data;
|
|
3577
|
+
} catch (error) {
|
|
3578
|
+
throw explainMissing(
|
|
3579
|
+
error,
|
|
3580
|
+
isUuid(id) ? `no plan ${id} in this organization, or this Harness server has no plan API yet` : `${id} roots no plan in this organization (${invokedName()} show ${id} names the plan an issue belongs to, by its root), or this Harness server opens plans by their id only`
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
function explainMissing(error, why) {
|
|
3585
|
+
if (!(error instanceof HttpError) || error.status !== 404) return error;
|
|
3586
|
+
return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
|
|
3587
|
+
}
|
|
3588
|
+
async function explainNotGateOwner(ctx, error, gate, issue, satisfied, allowLookup) {
|
|
3589
|
+
if (!(error instanceof HttpError) || error.status !== 403 || error.body["reason"] !== "not_gate_owner") return error;
|
|
3590
|
+
let current;
|
|
3591
|
+
let currentOwner = error.body["owner"];
|
|
3592
|
+
if (allowLookup && currentOwner !== null && !isRecord5(currentOwner)) {
|
|
3593
|
+
try {
|
|
3594
|
+
const fresh = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
|
|
3595
|
+
if (Array.isArray(fresh)) current = fresh.filter(isRecord5).find((condition) => condition["id"] === gate["id"]);
|
|
3596
|
+
} catch {
|
|
3597
|
+
}
|
|
3598
|
+
currentOwner = current?.["owner"];
|
|
3599
|
+
}
|
|
3600
|
+
const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
|
|
3601
|
+
const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
|
|
3602
|
+
const named2 = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
|
|
3603
|
+
const message = satisfied ? `Cannot satisfy ${named2}: only ${subject} can tick it` : `Cannot un-satisfy ${named2}: only ${subject} or Harness staff can untick it`;
|
|
3604
|
+
return new HttpError(error.status, message, error.body, error.retryAfter);
|
|
3605
|
+
}
|
|
3606
|
+
var gateLabel = (condition) => str6(condition["label"]).trim();
|
|
3607
|
+
var gateKey = (condition) => str6(condition["key"]);
|
|
3608
|
+
function exactMatches(conditions, wanted) {
|
|
3609
|
+
return conditions.filter(
|
|
3610
|
+
(condition) => condition["kind"] === "gate" && (gateKey(condition) !== "" && gateKey(condition) === wanted || gateLabel(condition) === wanted)
|
|
3611
|
+
);
|
|
3612
|
+
}
|
|
3613
|
+
function ambiguous(conditions, issue, wanted, matches) {
|
|
3614
|
+
const listed = matches.map((condition) => {
|
|
3615
|
+
const what = condition["kind"] === "gate" ? `gate ${gateName({ label: gateLabel(condition), key: gateKey(condition) })}` : `${clean(condition["kind"])} condition`;
|
|
3616
|
+
return `${clean(condition["id"])} (${what})`;
|
|
3617
|
+
}).join(", ");
|
|
3618
|
+
return new CommandError(
|
|
3619
|
+
`several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean`
|
|
3620
|
+
);
|
|
3621
|
+
}
|
|
3622
|
+
function findGate(conditions, issue, wanted, expert = false) {
|
|
3623
|
+
const exact = exactMatches(conditions, wanted);
|
|
3624
|
+
if (exact.length > 1) throw ambiguous(conditions, issue, wanted, exact);
|
|
3625
|
+
if (exact.length === 1) {
|
|
3626
|
+
const only = exact[0];
|
|
3627
|
+
return only;
|
|
3628
|
+
}
|
|
3629
|
+
const gates = conditions.filter((condition) => condition["kind"] === "gate");
|
|
3630
|
+
const folded = wanted.toLowerCase();
|
|
3631
|
+
const loose = gates.filter(
|
|
3632
|
+
(condition) => gateLabel(condition).toLowerCase() === folded || gateKey(condition) !== "" && gateKey(condition) === folded
|
|
3633
|
+
);
|
|
3634
|
+
if (loose.length > 1) throw ambiguous(conditions, issue, wanted, loose);
|
|
3635
|
+
if (loose.length === 1) return loose[0];
|
|
3636
|
+
const none = expert ? `No gate of yours on ${issue}` : `No gate on ${issue}`;
|
|
3637
|
+
if (gates.length === 0) {
|
|
3638
|
+
throw new CommandError(`${none} matches "${wanted}" \u2014 ${expert ? "you own no gate on it" : "it has no gates"}`);
|
|
3639
|
+
}
|
|
3640
|
+
const names = gates.map((condition) => gateName({ label: gateLabel(condition), key: gateKey(condition) })).join(", ");
|
|
3641
|
+
throw new CommandError(`${none} matches "${wanted}". ${expert ? "Your" : "Its"} gates: ${names}`);
|
|
3642
|
+
}
|
|
3643
|
+
function isRecord5(value) {
|
|
3644
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3645
|
+
}
|
|
3646
|
+
function str6(value) {
|
|
3647
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// src/commands/expert.ts
|
|
3651
|
+
async function assignments(ctx) {
|
|
3652
|
+
const data = (await ctx.client.get(EXPERT_ASSIGNMENTS_PATH)).data;
|
|
3653
|
+
if (!isRecord6(data) || !Array.isArray(data["assignments"])) {
|
|
3654
|
+
throw new CommandError("The assignments response carried no list.");
|
|
3655
|
+
}
|
|
3656
|
+
const steps = data["assignments"].filter(isRecord6);
|
|
3657
|
+
const expert = isRecord6(data["expert"]) ? data["expert"] : {};
|
|
3658
|
+
const plan = isRecord6(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
|
|
3659
|
+
const header = expertSummary({ role: expert["role"], plan, assigned_steps: steps.length });
|
|
3660
|
+
return { json: data, human: `${header}
|
|
3661
|
+
${expertAssignments(steps)}` };
|
|
3662
|
+
}
|
|
3663
|
+
function isRecord6(value) {
|
|
3664
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3665
|
+
}
|
|
3666
|
+
|
|
3667
|
+
export {
|
|
3668
|
+
MAX_IDEMPOTENCY_KEY_BYTES,
|
|
3669
|
+
IDEMPOTENCY_KEY_FORMAT,
|
|
3670
|
+
requireIdentifiers,
|
|
3671
|
+
ready,
|
|
3672
|
+
requireReadyClaimArgs,
|
|
3673
|
+
blocked,
|
|
3674
|
+
list,
|
|
3675
|
+
search,
|
|
3676
|
+
show,
|
|
3677
|
+
ACTIVITY_MAX_LIMIT,
|
|
3678
|
+
activity,
|
|
3679
|
+
stats2 as stats,
|
|
3680
|
+
prime,
|
|
3681
|
+
version,
|
|
3682
|
+
create,
|
|
3683
|
+
validateIdempotencyKey,
|
|
3684
|
+
update,
|
|
3685
|
+
requireCloseArgs,
|
|
3686
|
+
close,
|
|
3687
|
+
archive,
|
|
3688
|
+
comments,
|
|
3689
|
+
dep,
|
|
3690
|
+
RELATION_CREATE_TYPES,
|
|
3691
|
+
RELATION_UPDATE_TYPES,
|
|
3692
|
+
relationList,
|
|
3693
|
+
relationAdd,
|
|
3694
|
+
relationUpdate,
|
|
3695
|
+
relationRm,
|
|
3696
|
+
comment,
|
|
3697
|
+
editComment,
|
|
3698
|
+
deleteComment,
|
|
3699
|
+
requireCommentId,
|
|
3700
|
+
attachImage,
|
|
3701
|
+
requiredTeam,
|
|
3702
|
+
projectList,
|
|
3703
|
+
projectShow,
|
|
3704
|
+
projectCreate,
|
|
3705
|
+
projectUpdate,
|
|
3706
|
+
milestoneList,
|
|
3707
|
+
milestoneCreate,
|
|
3708
|
+
docList,
|
|
3709
|
+
docShow,
|
|
3710
|
+
docCreate,
|
|
3711
|
+
docUpdate,
|
|
3712
|
+
linkList,
|
|
3713
|
+
linkAdd,
|
|
3714
|
+
linkUpdate,
|
|
3715
|
+
linkRm,
|
|
3716
|
+
labelList,
|
|
3717
|
+
labelCreate,
|
|
3718
|
+
teamCreate,
|
|
3719
|
+
teamList,
|
|
3720
|
+
workflowStates,
|
|
3721
|
+
userList,
|
|
3722
|
+
teamRename,
|
|
3723
|
+
teamSetDefault,
|
|
3724
|
+
stateAdd,
|
|
3725
|
+
stateUpdate,
|
|
3726
|
+
stateOrder,
|
|
3727
|
+
stateDelete,
|
|
3728
|
+
labelUpdate,
|
|
3729
|
+
labelDelete,
|
|
3730
|
+
memberList,
|
|
3731
|
+
memberAdd,
|
|
3732
|
+
memberSetRole,
|
|
3733
|
+
upload,
|
|
3734
|
+
toolsList,
|
|
3735
|
+
toolsSearch,
|
|
3736
|
+
toolsDomains,
|
|
3737
|
+
toolShow,
|
|
3738
|
+
skillsList,
|
|
3739
|
+
requireCapabilityId,
|
|
3740
|
+
requireSearchQuery,
|
|
3741
|
+
requireToolsOpts,
|
|
3742
|
+
requireSchemaView,
|
|
3743
|
+
JOB_STATES,
|
|
3744
|
+
JOB_STATUS_ALIASES,
|
|
3745
|
+
JOBS_MAX_LIMIT,
|
|
3746
|
+
JOBS_MAX_PAGES,
|
|
3747
|
+
EXIT_WAITING_FOR_PERSON,
|
|
3748
|
+
EXIT_JOB_ENDED,
|
|
3749
|
+
EXIT_INTERRUPTED,
|
|
3750
|
+
requireJobsArgs,
|
|
3751
|
+
jobsList,
|
|
3752
|
+
requireJobId,
|
|
3753
|
+
jobShow,
|
|
3754
|
+
jobWatch,
|
|
3755
|
+
watchStopSignal,
|
|
3756
|
+
OUTCOME_MAX_CHARACTERS,
|
|
3757
|
+
BUDGET_MAX_DOLLARS,
|
|
3758
|
+
prepareRun,
|
|
3759
|
+
MCP_SURFACE,
|
|
3760
|
+
startRun,
|
|
3761
|
+
run,
|
|
3762
|
+
jobContinue,
|
|
3763
|
+
resolveTextSources,
|
|
3764
|
+
STEER_MAX_CHARACTERS,
|
|
3765
|
+
instructionFrom,
|
|
3766
|
+
jobSteer,
|
|
3767
|
+
jobCancel,
|
|
3768
|
+
jobPause,
|
|
3769
|
+
requireProcedureId,
|
|
3770
|
+
requireProcedureName,
|
|
3771
|
+
procedureSave,
|
|
3772
|
+
procedureShow,
|
|
3773
|
+
prepareProcedureRun,
|
|
3774
|
+
startProcedureRun,
|
|
3775
|
+
procedureRun,
|
|
3776
|
+
PLAN_EXECUTORS,
|
|
3777
|
+
CONDITION_KINDS,
|
|
3778
|
+
GATE_OWNER_ROLES,
|
|
3779
|
+
PLAN_STATUSES,
|
|
3780
|
+
PLAN_LIST_MAX_LIMIT,
|
|
3781
|
+
readPlanFile,
|
|
3782
|
+
planCreate,
|
|
3783
|
+
requirePlanListArgs,
|
|
3784
|
+
planList,
|
|
3785
|
+
planShow,
|
|
3786
|
+
planEdit,
|
|
3787
|
+
planRelease,
|
|
3788
|
+
planHold,
|
|
3789
|
+
gateSet,
|
|
3790
|
+
conditionList,
|
|
3791
|
+
requirePlanRef,
|
|
3792
|
+
assignments
|
|
3793
|
+
};
|