@allocator-one/harness-cli 1.13.0 → 1.15.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/README.md +726 -31
- package/dist/chunk-I2S2JVCQ.js +4761 -0
- package/dist/cli.js +495 -173
- package/dist/{server-CYBKYG5E.js → server-O7CX2ZCC.js} +333 -15
- package/package.json +3 -1
- package/spec/openapi.json +13784 -1496
- package/dist/chunk-XFMECLOU.js +0 -2258
|
@@ -0,0 +1,4761 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/errors.ts
|
|
4
|
+
var HttpError = class extends Error {
|
|
5
|
+
constructor(status, message, body = {}, retryAfter = null) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.body = body;
|
|
9
|
+
this.retryAfter = retryAfter;
|
|
10
|
+
this.name = "HttpError";
|
|
11
|
+
}
|
|
12
|
+
status;
|
|
13
|
+
body;
|
|
14
|
+
retryAfter;
|
|
15
|
+
};
|
|
16
|
+
var UpgradeRequiredError = class extends HttpError {
|
|
17
|
+
constructor(message, body = {}) {
|
|
18
|
+
super(426, message, body);
|
|
19
|
+
this.name = "UpgradeRequiredError";
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
var TransportError = class extends Error {
|
|
23
|
+
constructor(message, code = null) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.name = "TransportError";
|
|
27
|
+
}
|
|
28
|
+
code;
|
|
29
|
+
};
|
|
30
|
+
var MalformedResponseError = class extends TransportError {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "MalformedResponseError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var UsageError = class extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "UsageError";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var LocalError = class extends Error {
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "LocalError";
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var CommandError = class extends Error {
|
|
49
|
+
constructor(message) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "CommandError";
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// src/version.ts
|
|
56
|
+
import { readFileSync } from "fs";
|
|
57
|
+
var pkg = JSON.parse(
|
|
58
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
59
|
+
);
|
|
60
|
+
var CLI_VERSION = pkg.version;
|
|
61
|
+
var CLI_PACKAGE = pkg.name;
|
|
62
|
+
|
|
63
|
+
// src/core/expert.ts
|
|
64
|
+
var EXPERT_TOKEN_PREFIX = "pxc_";
|
|
65
|
+
var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
|
|
66
|
+
function isExpertToken(token) {
|
|
67
|
+
return token.startsWith(EXPERT_TOKEN_PREFIX);
|
|
68
|
+
}
|
|
69
|
+
var EXPERT_REACH = "an outside expert's pxc_ credential only lists its own assignments and reads and ticks the gates it owns on them: assignments, conditions <issue> and gate satisfy|unsatisfy (assignment_list, condition_list and gate_set over MCP)";
|
|
70
|
+
var EXPERT_LAPSED = "the pxc_ credential is not valid here: it has expired (it lasts at most 30 days) or was revoked, its seat was revoked or has ended, the account is suspended, its TOTP enrolment changed, or it was minted for another host. If your seat is still open, mint a new one in your expert view";
|
|
71
|
+
var NOT_AN_EXPERT = "this credential is a member's login or an API token, not an outside expert's pxc_ credential";
|
|
72
|
+
function explainExpertRefusal(error, token) {
|
|
73
|
+
if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
|
|
74
|
+
const code = error.body["error"];
|
|
75
|
+
let hint;
|
|
76
|
+
if (error.status === 403 && code === "expert_bound_credential") hint = EXPERT_REACH;
|
|
77
|
+
else if (error.status === 403 && code === "expert_credential_required") hint = NOT_AN_EXPERT;
|
|
78
|
+
else if (error.status === 401 && isExpertToken(token)) hint = EXPERT_LAPSED;
|
|
79
|
+
if (hint === void 0) return error;
|
|
80
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/core/client.ts
|
|
84
|
+
var RECEIVE_TIMEOUT_MS = 3e4;
|
|
85
|
+
var UPLOAD_RECEIVE_TIMEOUT_MS = 6e4;
|
|
86
|
+
var UPLOAD_FLOOR_BYTES_PER_MS = 100;
|
|
87
|
+
function uploadTimeoutMs(bodyBytes) {
|
|
88
|
+
return UPLOAD_RECEIVE_TIMEOUT_MS + Math.ceil(bodyBytes / UPLOAD_FLOOR_BYTES_PER_MS);
|
|
89
|
+
}
|
|
90
|
+
var DEFAULT_MESSAGES = {
|
|
91
|
+
401: "Missing or invalid token (check HARNESS_API_TOKEN)",
|
|
92
|
+
403: "Token not permitted (scope or admin gate)",
|
|
93
|
+
404: "Not found",
|
|
94
|
+
409: "Conflict",
|
|
95
|
+
422: "Validation error",
|
|
96
|
+
426: "CLI too old for this API \u2014 upgrade: npm i -g @allocator-one/harness-cli@latest",
|
|
97
|
+
429: "Rate limited \u2014 retry later"
|
|
98
|
+
};
|
|
99
|
+
var Client = class {
|
|
100
|
+
constructor(config) {
|
|
101
|
+
this.config = config;
|
|
102
|
+
this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
103
|
+
}
|
|
104
|
+
config;
|
|
105
|
+
fetchImpl;
|
|
106
|
+
get cliVersion() {
|
|
107
|
+
return CLI_VERSION;
|
|
108
|
+
}
|
|
109
|
+
get baseUrl() {
|
|
110
|
+
return this.config.baseUrl;
|
|
111
|
+
}
|
|
112
|
+
async get(path2, params) {
|
|
113
|
+
return this.json(await this.raw("GET", path2, { params }));
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `actor: false` keeps a configured actor out of the body, for endpoints
|
|
117
|
+
* whose body is closed: they refuse a field they do not name, and record
|
|
118
|
+
* the credential's own person instead.
|
|
119
|
+
*/
|
|
120
|
+
async post(path2, body = {}, options = {}) {
|
|
121
|
+
return this.json(
|
|
122
|
+
await this.raw("POST", path2, {
|
|
123
|
+
json: options.actor === false ? body : this.withActor(body),
|
|
124
|
+
idempotencyKey: options.idempotencyKey
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
async patch(path2, body) {
|
|
129
|
+
return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
|
|
130
|
+
}
|
|
131
|
+
async put(path2, body) {
|
|
132
|
+
return this.json(await this.raw("PUT", path2, { json: this.withActor(body) }));
|
|
133
|
+
}
|
|
134
|
+
async delete(path2) {
|
|
135
|
+
return this.json(await this.raw("DELETE", path2, {}));
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Posts multipart/form-data — the one endpoint that does not take JSON.
|
|
139
|
+
* Shares auth, the version header, and error mapping with the JSON verbs,
|
|
140
|
+
* but never the actor: there is no JSON body to carry an `actor` field,
|
|
141
|
+
* and the server's file contract is the form itself.
|
|
142
|
+
*/
|
|
143
|
+
async postMultipart(path2, form, bodyBytes = 0) {
|
|
144
|
+
return this.json(await this.raw("POST", path2, { form, timeoutMs: uploadTimeoutMs(bodyBytes) }));
|
|
145
|
+
}
|
|
146
|
+
/** Binary download (attachments); non-2xx still maps through the JSON error envelope. */
|
|
147
|
+
async getBytes(path2) {
|
|
148
|
+
const response = await this.raw("GET", path2, {});
|
|
149
|
+
if (!response.ok) throw await this.toHttpError(response);
|
|
150
|
+
try {
|
|
151
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The server's API version, for `version`'s server side. The header rides
|
|
158
|
+
* on every response — the versioning plug runs before auth — so even a
|
|
159
|
+
* 401 answers. A pxc_ credential probes the one route it reaches, so the
|
|
160
|
+
* probe is never a refusal in the server's log.
|
|
161
|
+
*/
|
|
162
|
+
async serverVersion() {
|
|
163
|
+
const path2 = isExpertToken(this.config.token) ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats";
|
|
164
|
+
const response = await this.raw("GET", path2, {});
|
|
165
|
+
return response.headers.get("x-harness-api-version") ?? "unknown";
|
|
166
|
+
}
|
|
167
|
+
// Only an explicitly configured actor is sent, and never over one the
|
|
168
|
+
// caller already put in the body.
|
|
169
|
+
withActor(body) {
|
|
170
|
+
if (this.config.actor === null || "actor" in body) return body;
|
|
171
|
+
return { ...body, actor: this.config.actor };
|
|
172
|
+
}
|
|
173
|
+
async raw(method, path2, options) {
|
|
174
|
+
const url = new URL(this.config.baseUrl + path2);
|
|
175
|
+
for (const [key, value] of Object.entries(options.params ?? {})) {
|
|
176
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
177
|
+
}
|
|
178
|
+
const headers = {
|
|
179
|
+
authorization: `Bearer ${this.config.token}`,
|
|
180
|
+
"x-harness-cli-version": CLI_VERSION
|
|
181
|
+
};
|
|
182
|
+
if (options.idempotencyKey !== void 0) {
|
|
183
|
+
headers["idempotency-key"] = options.idempotencyKey;
|
|
184
|
+
}
|
|
185
|
+
let body;
|
|
186
|
+
if (options.json !== void 0) {
|
|
187
|
+
headers["content-type"] = "application/json";
|
|
188
|
+
body = JSON.stringify(options.json);
|
|
189
|
+
} else if (options.form !== void 0) {
|
|
190
|
+
body = options.form;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
return await this.fetchImpl(url, {
|
|
194
|
+
method,
|
|
195
|
+
headers,
|
|
196
|
+
body,
|
|
197
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? RECEIVE_TIMEOUT_MS),
|
|
198
|
+
redirect: "manual"
|
|
199
|
+
});
|
|
200
|
+
} catch (error) {
|
|
201
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async json(response) {
|
|
205
|
+
if (!response.ok) throw await this.toHttpError(response);
|
|
206
|
+
let text;
|
|
207
|
+
try {
|
|
208
|
+
text = await response.text();
|
|
209
|
+
} catch (error) {
|
|
210
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
211
|
+
}
|
|
212
|
+
if (text === "") return { data: void 0 };
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = JSON.parse(text);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
|
|
218
|
+
}
|
|
219
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
220
|
+
throw new MalformedResponseError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
|
|
221
|
+
}
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
224
|
+
async toHttpError(response) {
|
|
225
|
+
let parsed = {};
|
|
226
|
+
try {
|
|
227
|
+
const candidate = JSON.parse(await response.text());
|
|
228
|
+
if (typeof candidate === "object" && candidate !== null) {
|
|
229
|
+
parsed = candidate;
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
const fromBody = typeof parsed["message"] === "string" ? parsed["message"] : null;
|
|
234
|
+
const message = fromBody ?? DEFAULT_MESSAGES[response.status] ?? `HTTP ${response.status}`;
|
|
235
|
+
if (response.status === 426) return new UpgradeRequiredError(message, parsed);
|
|
236
|
+
return new HttpError(response.status, message, parsed, response.headers.get("retry-after"));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
function transportCode(error) {
|
|
240
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
241
|
+
if (typeof cause !== "object" || cause === null) return null;
|
|
242
|
+
const code = cause.code;
|
|
243
|
+
if (typeof code === "string") return code;
|
|
244
|
+
const errors = cause.errors;
|
|
245
|
+
if (!Array.isArray(errors)) return null;
|
|
246
|
+
const codes = new Set(errors.map((inner) => inner?.code));
|
|
247
|
+
const [only] = codes;
|
|
248
|
+
return codes.size === 1 && typeof only === "string" ? only : null;
|
|
249
|
+
}
|
|
250
|
+
function transportMessage(error) {
|
|
251
|
+
if (error instanceof Error) {
|
|
252
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
|
|
253
|
+
const cause = error.cause;
|
|
254
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
255
|
+
return error.message;
|
|
256
|
+
}
|
|
257
|
+
return String(error);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// src/core/output.ts
|
|
261
|
+
var TITLE_WIDTH = 70;
|
|
262
|
+
var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
263
|
+
function graphemes(text) {
|
|
264
|
+
return [...segmenter.segment(text)].map((piece) => piece.segment);
|
|
265
|
+
}
|
|
266
|
+
function graphemeLength(text) {
|
|
267
|
+
return graphemes(text).length;
|
|
268
|
+
}
|
|
269
|
+
function padGraphemes(text, width) {
|
|
270
|
+
const shortfall = width - graphemeLength(text);
|
|
271
|
+
return shortfall > 0 ? text + " ".repeat(shortfall) : text;
|
|
272
|
+
}
|
|
273
|
+
function clean(value) {
|
|
274
|
+
if (value === null || value === void 0) return "";
|
|
275
|
+
return String(value).replace(/[\x00-\x1F\x7F\u0080-\u009F]/gu, "");
|
|
276
|
+
}
|
|
277
|
+
function json(term) {
|
|
278
|
+
return JSON.stringify(term) + "\n";
|
|
279
|
+
}
|
|
280
|
+
var OPERATOR_TEAM_KEY = /^OPERATOR\d*$/;
|
|
281
|
+
var OPERATOR_TEAM_NAME = "Operator";
|
|
282
|
+
var AGENT_MARK = "[agent]";
|
|
283
|
+
var AGENT_LEGEND = `
|
|
284
|
+
${AGENT_MARK} Operator runs this step for its plan; only the plan driver claims it.
|
|
285
|
+
`;
|
|
286
|
+
function isAgentStep(issue) {
|
|
287
|
+
return OPERATOR_TEAM_KEY.test(str(path(issue, "team", "key"))) && str(path(issue, "team", "name")) === OPERATOR_TEAM_NAME;
|
|
288
|
+
}
|
|
289
|
+
function issuesTable(issues, options = {}) {
|
|
290
|
+
if (issues.length === 0) return "No issues.\n";
|
|
291
|
+
let marked = false;
|
|
292
|
+
const rows = issues.map((issue) => {
|
|
293
|
+
const agentStep = options.markAgentSteps === true && isAgentStep(issue);
|
|
294
|
+
if (agentStep) marked = true;
|
|
295
|
+
return [
|
|
296
|
+
str(issue["identifier"]),
|
|
297
|
+
str(path(issue, "state", "name")) || "-",
|
|
298
|
+
str(issue["priority_label"]) || "-",
|
|
299
|
+
str(path(issue, "assignee", "name")) || "-",
|
|
300
|
+
agentStep ? truncate(`${AGENT_MARK} ${clean(issue["title"])}`) : truncate(issue["title"])
|
|
301
|
+
];
|
|
302
|
+
});
|
|
303
|
+
const rendered = table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
|
|
304
|
+
return marked ? rendered + AGENT_LEGEND : rendered;
|
|
305
|
+
}
|
|
306
|
+
function issueDetail(issue, images = []) {
|
|
307
|
+
const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
|
|
308
|
+
`;
|
|
309
|
+
const fields = [
|
|
310
|
+
["State", path(issue, "state", "name")],
|
|
311
|
+
["Priority", issue["priority_label"]],
|
|
312
|
+
["Assignee", path(issue, "assignee", "name")],
|
|
313
|
+
["Team", path(issue, "team", "key")],
|
|
314
|
+
["Labels", labelsLine(issue["labels"])],
|
|
315
|
+
["Blocked", blockedLine(issue)],
|
|
316
|
+
["Waiting", waitingLine(issue)],
|
|
317
|
+
["Due", issue["due_date"]],
|
|
318
|
+
["Updated", issue["updated_at"]]
|
|
319
|
+
];
|
|
320
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
321
|
+
`).join("");
|
|
322
|
+
const sections = [
|
|
323
|
+
["Description", issue["description"]],
|
|
324
|
+
["Design", issue["design"]],
|
|
325
|
+
["Acceptance criteria", issue["acceptance_criteria"]],
|
|
326
|
+
["Notes", issue["notes"]]
|
|
327
|
+
];
|
|
328
|
+
const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
|
|
329
|
+
${title}:
|
|
330
|
+
${indent(String(text))}
|
|
331
|
+
`).join("");
|
|
332
|
+
return header + fieldLines + sectionText + conditionsSection(issue["conditions"]) + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
|
|
333
|
+
}
|
|
334
|
+
function activityEntries(entries) {
|
|
335
|
+
if (entries.length === 0) return "No activity.\n";
|
|
336
|
+
return entries.map(activityEntry).join("\n");
|
|
337
|
+
}
|
|
338
|
+
function activityEntry(entry) {
|
|
339
|
+
switch (entry["kind"]) {
|
|
340
|
+
case "comment":
|
|
341
|
+
return activityComment(entry);
|
|
342
|
+
case "state_event":
|
|
343
|
+
return activityStateEvent(entry);
|
|
344
|
+
case "assignee_event":
|
|
345
|
+
return activityAssigneeEvent(entry);
|
|
346
|
+
case "condition_event":
|
|
347
|
+
return activityConditionEvent(entry);
|
|
348
|
+
case "field_event":
|
|
349
|
+
return activityFieldEvent(entry);
|
|
350
|
+
default:
|
|
351
|
+
throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function activityComment(entry) {
|
|
355
|
+
const comment2 = requiredRecord(entry["comment"], "activity comment");
|
|
356
|
+
const authorValue = comment2["author"];
|
|
357
|
+
const author = authorValue === null ? "unknown" : optionalLabel(requiredRecord(authorValue, "comment author")["name"], "unknown", "comment author name");
|
|
358
|
+
const deleted = comment2["deleted"];
|
|
359
|
+
if (typeof deleted !== "boolean") throw new CommandError("Invalid comment deleted flag.");
|
|
360
|
+
const body = activityCommentBody(comment2["body"], deleted);
|
|
361
|
+
const text = cleanMultiline(body).split("\n").map((line) => " " + line).join("\n");
|
|
362
|
+
return `[${occurredAt(entry)}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
|
|
363
|
+
${text}
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
function activityCommentBody(value, deleted) {
|
|
367
|
+
if (deleted) return "[deleted comment]";
|
|
368
|
+
if (value === null || value === "") return "[comment has no text]";
|
|
369
|
+
return requiredString(value, "comment body");
|
|
370
|
+
}
|
|
371
|
+
function activityStateEvent(entry) {
|
|
372
|
+
const actor = activityWho(entry);
|
|
373
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
374
|
+
let description;
|
|
375
|
+
switch (entry["action"]) {
|
|
376
|
+
case "created":
|
|
377
|
+
description = `${actor} created the issue in ${stateName(entry["to_state"], "created to_state")}`;
|
|
378
|
+
break;
|
|
379
|
+
case "transition":
|
|
380
|
+
description = `${actor} moved the issue from ${nullableStateName(entry["from_state"], "transition from_state")} to ${stateName(entry["to_state"], "transition to_state")}`;
|
|
381
|
+
break;
|
|
382
|
+
case "milestone":
|
|
383
|
+
description = `${actor} recorded the ${stateName(entry["to_state"], "milestone to_state")} milestone`;
|
|
384
|
+
break;
|
|
385
|
+
default:
|
|
386
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
387
|
+
}
|
|
388
|
+
return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
|
|
389
|
+
`;
|
|
390
|
+
}
|
|
391
|
+
function activityAssigneeEvent(entry) {
|
|
392
|
+
const actor = activityWho(entry);
|
|
393
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
394
|
+
let description;
|
|
395
|
+
switch (entry["action"]) {
|
|
396
|
+
case "assigned":
|
|
397
|
+
description = `${actor} assigned the issue to ${assigneeName(entry["to_assignee"])}`;
|
|
398
|
+
break;
|
|
399
|
+
case "reassigned":
|
|
400
|
+
description = `${actor} reassigned the issue from ${assigneeName(entry["from_assignee"])} to ${assigneeName(entry["to_assignee"])}`;
|
|
401
|
+
break;
|
|
402
|
+
case "unassigned":
|
|
403
|
+
description = `${actor} unassigned the issue from ${assigneeName(entry["from_assignee"])}`;
|
|
404
|
+
break;
|
|
405
|
+
default:
|
|
406
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
407
|
+
}
|
|
408
|
+
return `[${occurredAt(entry)}] ${description} - ${provenance}
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
function activityConditionEvent(entry) {
|
|
412
|
+
const actor = activityWho(entry);
|
|
413
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
414
|
+
const condition = conditionSubject(requiredRecord(entry["condition"], "activity condition"));
|
|
415
|
+
let description;
|
|
416
|
+
switch (entry["action"]) {
|
|
417
|
+
case "added":
|
|
418
|
+
description = `${actor} added ${condition}`;
|
|
419
|
+
break;
|
|
420
|
+
case "changed":
|
|
421
|
+
description = `${actor} changed ${condition}`;
|
|
422
|
+
break;
|
|
423
|
+
case "removed":
|
|
424
|
+
description = `${actor} removed ${condition}`;
|
|
425
|
+
break;
|
|
426
|
+
case "satisfied":
|
|
427
|
+
description = `${actor} satisfied ${condition}`;
|
|
428
|
+
break;
|
|
429
|
+
case "unsatisfied":
|
|
430
|
+
description = `${actor} marked ${condition} unsatisfied`;
|
|
431
|
+
break;
|
|
432
|
+
default:
|
|
433
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
434
|
+
}
|
|
435
|
+
return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
|
|
436
|
+
`;
|
|
437
|
+
}
|
|
438
|
+
function conditionSubject(condition) {
|
|
439
|
+
const label = clean(condition["label"]);
|
|
440
|
+
switch (condition["kind"]) {
|
|
441
|
+
case "gate":
|
|
442
|
+
return label === "" ? "the gate" : `the gate "${label}"`;
|
|
443
|
+
case "not_before": {
|
|
444
|
+
const at = clean(condition["not_before"]);
|
|
445
|
+
return at === "" ? "the not-before date" : `the not-before date ${at}`;
|
|
446
|
+
}
|
|
447
|
+
case "event":
|
|
448
|
+
return label === "" ? "the wait condition" : `the system event "${label}"`;
|
|
449
|
+
default:
|
|
450
|
+
return "the wait condition";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function activityFieldEvent(entry) {
|
|
454
|
+
const actor = activityWho(entry);
|
|
455
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
456
|
+
const change = fieldChange(requiredString(entry["field"], "activity field"), entry["from"], entry["to"]);
|
|
457
|
+
return `[${occurredAt(entry)}] ${actor} ${change} \xB7 ${provenance}
|
|
458
|
+
`;
|
|
459
|
+
}
|
|
460
|
+
function fieldChange(field3, from, to) {
|
|
461
|
+
switch (field3) {
|
|
462
|
+
case "priority":
|
|
463
|
+
return `changed priority from ${priorityName(from)} to ${priorityName(to)}`;
|
|
464
|
+
case "title":
|
|
465
|
+
return `renamed the issue from \u201C${activityTitle(from)}\u201D to \u201C${activityTitle(to)}\u201D`;
|
|
466
|
+
case "description":
|
|
467
|
+
return "edited the description";
|
|
468
|
+
case "labels":
|
|
469
|
+
return labelsChange(to);
|
|
470
|
+
case "parent":
|
|
471
|
+
return parentChange(from, to);
|
|
472
|
+
case "archived":
|
|
473
|
+
if (typeof to !== "boolean") throw new CommandError("Invalid activity archived value.");
|
|
474
|
+
return to ? "archived the issue" : "unarchived the issue";
|
|
475
|
+
default:
|
|
476
|
+
return `changed the ${clean(field3)} field`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
var ACTIVITY_PRIORITIES = {
|
|
480
|
+
0: "No priority",
|
|
481
|
+
1: "Urgent",
|
|
482
|
+
2: "High",
|
|
483
|
+
3: "Medium",
|
|
484
|
+
4: "Low"
|
|
485
|
+
};
|
|
486
|
+
function priorityName(value) {
|
|
487
|
+
if (typeof value !== "number" || !Number.isInteger(value)) throw new CommandError("Invalid activity priority.");
|
|
488
|
+
return ACTIVITY_PRIORITIES[value] ?? String(value);
|
|
489
|
+
}
|
|
490
|
+
function activityTitle(value) {
|
|
491
|
+
if (typeof value !== "string") throw new CommandError("Invalid activity title.");
|
|
492
|
+
return clean(value);
|
|
493
|
+
}
|
|
494
|
+
function labelsChange(to) {
|
|
495
|
+
const change = requiredRecord(to, "activity labels");
|
|
496
|
+
const added = labelNames(change["added"]);
|
|
497
|
+
const removed = labelNames(change["removed"]);
|
|
498
|
+
const parts = [
|
|
499
|
+
...added.length > 0 ? [`added ${labelList(added)}`] : [],
|
|
500
|
+
...removed.length > 0 ? [`removed ${labelList(removed)}`] : []
|
|
501
|
+
];
|
|
502
|
+
return parts.length === 0 ? "changed the labels" : parts.join(" and ");
|
|
503
|
+
}
|
|
504
|
+
function labelNames(value) {
|
|
505
|
+
if (!Array.isArray(value)) throw new CommandError("Invalid activity labels.");
|
|
506
|
+
return value.map((label) => clean(requiredString(requiredRecord(label, "activity labels")["name"], "activity labels")));
|
|
507
|
+
}
|
|
508
|
+
function labelList(names) {
|
|
509
|
+
return `${names.length === 1 ? "label" : "labels"} ${names.join(", ")}`;
|
|
510
|
+
}
|
|
511
|
+
function parentChange(from, to) {
|
|
512
|
+
if (from === null && to === null) return "changed the parent";
|
|
513
|
+
if (from === null) return `set the parent to ${parentName(to)}`;
|
|
514
|
+
if (to === null) return `removed the parent ${parentName(from)}`;
|
|
515
|
+
return `changed the parent from ${parentName(from)} to ${parentName(to)}`;
|
|
516
|
+
}
|
|
517
|
+
function parentName(value) {
|
|
518
|
+
const parent = requiredRecord(value, "activity parent");
|
|
519
|
+
return optionalLabel(parent["identifier"], "an unknown issue", "activity parent identifier");
|
|
520
|
+
}
|
|
521
|
+
function activityWho(entry) {
|
|
522
|
+
const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
|
|
523
|
+
const label = entry["actor_label"];
|
|
524
|
+
if (label === void 0 || label === null || label === "") return actor;
|
|
525
|
+
if (typeof label !== "string") throw new CommandError("Invalid activity actor label.");
|
|
526
|
+
const cleaned = clean(label);
|
|
527
|
+
return cleaned === "" ? actor : `${actor} via \u201C${cleaned}\u201D`;
|
|
528
|
+
}
|
|
529
|
+
function occurredAt(entry) {
|
|
530
|
+
return clean(requiredString(entry["occurred_at"], "activity occurrence time"));
|
|
531
|
+
}
|
|
532
|
+
function assigneeName(value) {
|
|
533
|
+
const assignee = requiredRecord(value, "activity assignee");
|
|
534
|
+
if (assignee["kind"] === "unknown") return "Unknown assignee";
|
|
535
|
+
return activityActor(assignee);
|
|
536
|
+
}
|
|
537
|
+
function activityActor(actor) {
|
|
538
|
+
switch (actor["kind"]) {
|
|
539
|
+
case "user":
|
|
540
|
+
return optionalLabel(actor["name"], "Unknown actor", "activity actor name");
|
|
541
|
+
case "system":
|
|
542
|
+
return "System";
|
|
543
|
+
case "deleted_user":
|
|
544
|
+
return "Deleted user";
|
|
545
|
+
case "unknown":
|
|
546
|
+
return "Unknown actor";
|
|
547
|
+
default:
|
|
548
|
+
throw new CommandError(`Unknown activity actor kind: ${clean(actor["kind"])}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function activityProvenance(provenance) {
|
|
552
|
+
switch (provenance["kind"]) {
|
|
553
|
+
case "live":
|
|
554
|
+
case "import":
|
|
555
|
+
case "backfill":
|
|
556
|
+
return String(provenance["kind"]);
|
|
557
|
+
default:
|
|
558
|
+
throw new CommandError(`Unknown activity provenance: ${clean(provenance["kind"])}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function stateName(value, field3) {
|
|
562
|
+
return optionalLabel(requiredRecord(value, field3)["name"], "Unknown state", `${field3} name`);
|
|
563
|
+
}
|
|
564
|
+
function nullableStateName(value, field3) {
|
|
565
|
+
return value === null ? "Unknown state" : stateName(value, field3);
|
|
566
|
+
}
|
|
567
|
+
function optionalLabel(value, fallback, field3) {
|
|
568
|
+
if (value === null || value === "") return fallback;
|
|
569
|
+
return clean(requiredString(value, field3));
|
|
570
|
+
}
|
|
571
|
+
function requiredRecord(value, field3) {
|
|
572
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
573
|
+
throw new CommandError(`Invalid ${field3}.`);
|
|
574
|
+
}
|
|
575
|
+
return value;
|
|
576
|
+
}
|
|
577
|
+
function requiredString(value, field3) {
|
|
578
|
+
if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field3}.`);
|
|
579
|
+
return value;
|
|
580
|
+
}
|
|
581
|
+
function stats(data) {
|
|
582
|
+
const teams = data["teams"] ?? [];
|
|
583
|
+
const totals = data["totals"] ?? {};
|
|
584
|
+
const rows = teams.map((team) => [
|
|
585
|
+
str(path(team, "team", "key")),
|
|
586
|
+
str(team["open"]),
|
|
587
|
+
str(team["started"]),
|
|
588
|
+
str(team["blocked"]),
|
|
589
|
+
str(team["ready"]),
|
|
590
|
+
str(team["closed"])
|
|
591
|
+
]);
|
|
592
|
+
const totalsRow = ["TOTAL", ...["open", "started", "blocked", "ready", "closed"].map((key) => str(totals[key]))];
|
|
593
|
+
return table(["TEAM", "OPEN", "STARTED", "BLOCKED", "READY", "CLOSED"], [...rows, totalsRow]);
|
|
594
|
+
}
|
|
595
|
+
function table(headers, rows) {
|
|
596
|
+
const all = [headers, ...rows.map((row) => row.map((cell) => clean(cell)))];
|
|
597
|
+
const widths = headers.map((_, column) => Math.max(...all.map((row) => graphemeLength(row[column] ?? ""))));
|
|
598
|
+
return all.map(
|
|
599
|
+
(row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
|
|
600
|
+
).join("");
|
|
601
|
+
}
|
|
602
|
+
function records(value) {
|
|
603
|
+
if (!Array.isArray(value)) return [];
|
|
604
|
+
return value.filter(
|
|
605
|
+
(entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
function waitingLine(issue) {
|
|
609
|
+
if (issue["waiting_on_condition"] !== true) return null;
|
|
610
|
+
const conditions = records(issue["conditions"]);
|
|
611
|
+
const open = conditions.filter((condition) => condition["satisfied"] !== true).length;
|
|
612
|
+
return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
|
|
613
|
+
}
|
|
614
|
+
function conditionsSection(conditions) {
|
|
615
|
+
const list3 = records(conditions);
|
|
616
|
+
if (list3.length === 0) return "";
|
|
617
|
+
return "\nWait conditions:\n" + list3.map((condition) => ` ${conditionLine(condition)}
|
|
618
|
+
`).join("");
|
|
619
|
+
}
|
|
620
|
+
function conditionLine(condition, options = {}) {
|
|
621
|
+
const satisfied = condition["satisfied"] === true;
|
|
622
|
+
let what;
|
|
623
|
+
switch (condition["kind"]) {
|
|
624
|
+
case "gate": {
|
|
625
|
+
const owner = condition["owner"];
|
|
626
|
+
let ownedBy = "";
|
|
627
|
+
if (typeof owner === "object" && owner !== null) {
|
|
628
|
+
const record = owner;
|
|
629
|
+
ownedBy = options.expertView === true && record["kind"] === "expert" ? "you" : ownerText(record);
|
|
630
|
+
}
|
|
631
|
+
what = `gate ${gateName(condition)}${ownedBy === "" ? "" : ` owned by ${ownedBy}`}`;
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case "not_before":
|
|
635
|
+
what = `not before ${clean(condition["not_before"])}`;
|
|
636
|
+
break;
|
|
637
|
+
case "event":
|
|
638
|
+
what = "system event";
|
|
639
|
+
break;
|
|
640
|
+
default:
|
|
641
|
+
what = clean(condition["kind"]) || "condition";
|
|
642
|
+
}
|
|
643
|
+
let when = "";
|
|
644
|
+
if (satisfied && typeof condition["satisfied_at"] === "string") {
|
|
645
|
+
let by = clean(path(condition, "satisfied_by", "name"));
|
|
646
|
+
if (by === "" && condition["satisfied_by_kind"] === "expert") by = "an outside expert";
|
|
647
|
+
let role = typeof condition["satisfied_as_role"] === "string" ? roleName(condition["satisfied_as_role"]) : "";
|
|
648
|
+
switch (path(condition, "satisfied_by", "kind")) {
|
|
649
|
+
case "you":
|
|
650
|
+
by = "you";
|
|
651
|
+
break;
|
|
652
|
+
case "role": {
|
|
653
|
+
const as = path(condition, "satisfied_by", "role");
|
|
654
|
+
if (typeof as === "string" && as !== "") role = roleName(as);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
case "expert":
|
|
658
|
+
by = "an outside expert";
|
|
659
|
+
break;
|
|
660
|
+
case "member":
|
|
661
|
+
by = "a member";
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`}${role === "" ? "" : ` as ${role}`})`;
|
|
665
|
+
}
|
|
666
|
+
return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
|
|
667
|
+
}
|
|
668
|
+
function gateName(gate) {
|
|
669
|
+
const key = clean(gate["key"]);
|
|
670
|
+
return `"${clean(gate["label"])}"${key === "" ? "" : ` [${key}]`}`;
|
|
671
|
+
}
|
|
672
|
+
function ownerText(owner) {
|
|
673
|
+
const label = clean(owner["label"]) || (owner["kind"] === "role" && typeof owner["role"] === "string" ? roleName(owner["role"]) : "");
|
|
674
|
+
const ref = clean(owner["ref"]);
|
|
675
|
+
if (owner["kind"] === "expert") {
|
|
676
|
+
const email = label || ref.replace(/^expert:/, "");
|
|
677
|
+
return email === "" ? "" : `${email} (expert)`;
|
|
678
|
+
}
|
|
679
|
+
if (label === "") return ref;
|
|
680
|
+
return ref === "" || ref === label ? label : `${label} (${ref})`;
|
|
681
|
+
}
|
|
682
|
+
function expertSummary(seat) {
|
|
683
|
+
const role = typeof seat.role === "string" && seat.role !== "" ? roleName(seat.role) : "Outside expert";
|
|
684
|
+
const status = clean(seat.plan?.status);
|
|
685
|
+
const plan = seat.plan === null ? ", no plan" : ` on plan ${clean(seat.plan.id)}${status === "" ? "" : ` (${status})`}`;
|
|
686
|
+
const steps = seat.assigned_steps;
|
|
687
|
+
const count = steps === 0 ? "no assigned steps" : `${steps} assigned step${steps === 1 ? "" : "s"}`;
|
|
688
|
+
return `${role}${plan} \xB7 ${count}`;
|
|
689
|
+
}
|
|
690
|
+
function expertAssignments(steps) {
|
|
691
|
+
return steps.map((step) => "\n" + assignedStep(step)).join("");
|
|
692
|
+
}
|
|
693
|
+
function assignedStep(step) {
|
|
694
|
+
const blockers = typeof step["open_blockers"] === "number" ? step["open_blockers"] : 0;
|
|
695
|
+
const fields = [
|
|
696
|
+
["State", path(step, "state", "name")],
|
|
697
|
+
["Due", step["due_date"]],
|
|
698
|
+
["Blocked", blockers > 0 ? `yes, by ${blockers} open issue${blockers === 1 ? "" : "s"}` : null]
|
|
699
|
+
];
|
|
700
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
701
|
+
`).join("");
|
|
702
|
+
const gates = records(step["gates"]);
|
|
703
|
+
const gateLines = gates.length === 0 ? " Gates: none of yours\n" : " Gates:\n" + gates.map((gate) => ` ${conditionLine(gate, { expertView: true })}
|
|
704
|
+
`).join("");
|
|
705
|
+
const description = step["description"];
|
|
706
|
+
const descriptionLines = typeof description === "string" && description !== "" ? " Description:\n" + indent(indent(description)) + "\n" : "";
|
|
707
|
+
return `${clean(step["identifier"])} \xB7 ${clean(step["title"])}
|
|
708
|
+
${fieldLines}${gateLines}${descriptionLines}`;
|
|
709
|
+
}
|
|
710
|
+
var ROLE_NAMES = { gp: "GP" };
|
|
711
|
+
function roleName(role) {
|
|
712
|
+
const slug = clean(role);
|
|
713
|
+
if (Object.hasOwn(ROLE_NAMES, slug)) return ROLE_NAMES[slug];
|
|
714
|
+
const words = slug.replace(/_/g, " ");
|
|
715
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
716
|
+
}
|
|
717
|
+
var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
|
|
718
|
+
function planDetail(plan) {
|
|
719
|
+
const steps = records(plan["steps"]);
|
|
720
|
+
const fields = [
|
|
721
|
+
["Root", planRootLine(plan["root"])],
|
|
722
|
+
["Revision", planRevisionLine(plan)],
|
|
723
|
+
["Budgets", planBudgetsLine(plan)],
|
|
724
|
+
["Estimate", planEstimateLine(plan["estimate"])]
|
|
725
|
+
];
|
|
726
|
+
const fieldLines = fields.filter((field3) => field3[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
|
|
727
|
+
`).join("");
|
|
728
|
+
return `Plan ${clean(plan["id"])} \xB7 ${clean(plan["status"])}
|
|
729
|
+
` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planWaitingSection(steps) + planHeldSection(plan["held_steps"]);
|
|
730
|
+
}
|
|
731
|
+
function planRootLine(root) {
|
|
732
|
+
if (typeof root !== "object" || root === null) return null;
|
|
733
|
+
const record = root;
|
|
734
|
+
return `${clean(record["identifier"])} \xB7 ${clean(record["title"])}`;
|
|
735
|
+
}
|
|
736
|
+
function planRevisionLine(plan) {
|
|
737
|
+
const revision = plan["revision"];
|
|
738
|
+
if (typeof revision !== "object" || revision === null) return "none yet (never released)";
|
|
739
|
+
const number = clean(revision["number"]);
|
|
740
|
+
const by = clean(path(plan, "released_by", "name"));
|
|
741
|
+
const at = clean(plan["released_at"]);
|
|
742
|
+
if (by === "" && at === "") return number;
|
|
743
|
+
return `${number} \xB7 released${by === "" ? "" : ` by ${by}`}${at === "" ? "" : ` at ${at}`}`;
|
|
744
|
+
}
|
|
745
|
+
function planBudgetsLine(plan) {
|
|
746
|
+
const retries = plan["retry_budget"];
|
|
747
|
+
const retryText = typeof retries === "number" ? `${retries} ${retries === 1 ? "retry" : "retries"} per failed Operator step` : null;
|
|
748
|
+
const spend = plan["spend_budget_cents"];
|
|
749
|
+
const spendText = typeof spend === "number" ? `spend budget ${spend} cents` : "no spend budget";
|
|
750
|
+
return [retryText, spendText].filter((part) => part !== null).join(" \xB7 ");
|
|
751
|
+
}
|
|
752
|
+
function planEstimateLine(estimate) {
|
|
753
|
+
if (typeof estimate !== "object" || estimate === null) return null;
|
|
754
|
+
const record = estimate;
|
|
755
|
+
const ceiling = record["ceiling_cents"];
|
|
756
|
+
if (typeof ceiling !== "number") return null;
|
|
757
|
+
const minimum = record["minimum_budget_cents"];
|
|
758
|
+
return `up to ${ceiling} cents${typeof minimum === "number" ? ` (minimum budget ${minimum} cents)` : ""}`;
|
|
759
|
+
}
|
|
760
|
+
function planStepsSection(steps, planStatus) {
|
|
761
|
+
if (steps.length === 0) return "";
|
|
762
|
+
const depths = stepDepths(steps);
|
|
763
|
+
const rows = steps.map((step) => [
|
|
764
|
+
" ".repeat(depths.get(str(step["identifier"])) ?? 0) + str(step["identifier"]),
|
|
765
|
+
str(step["key"]) || "-",
|
|
766
|
+
str(step["executor"]) || "-",
|
|
767
|
+
str(path(step, "state", "name")) || "-",
|
|
768
|
+
stepStatus(step, planStatus),
|
|
769
|
+
truncate(step["title"])
|
|
770
|
+
]);
|
|
771
|
+
const rendered = table(["STEP", "KEY", "EXECUTOR", "STATE", "STATUS", "TITLE"], rows);
|
|
772
|
+
return "\nSteps:\n" + indentLines(rendered);
|
|
773
|
+
}
|
|
774
|
+
function stepDepths(steps) {
|
|
775
|
+
const parents = new Map(steps.map((step) => [str(step["identifier"]), str(step["parent"])]));
|
|
776
|
+
const depths = /* @__PURE__ */ new Map();
|
|
777
|
+
for (const identifier of parents.keys()) {
|
|
778
|
+
let depth = 0;
|
|
779
|
+
let parent = parents.get(identifier) ?? "";
|
|
780
|
+
const seen = /* @__PURE__ */ new Set([identifier]);
|
|
781
|
+
while (parent !== "" && parents.has(parent) && !seen.has(parent)) {
|
|
782
|
+
seen.add(parent);
|
|
783
|
+
depth += 1;
|
|
784
|
+
parent = parents.get(parent) ?? "";
|
|
785
|
+
}
|
|
786
|
+
depths.set(identifier, depth);
|
|
787
|
+
}
|
|
788
|
+
return depths;
|
|
789
|
+
}
|
|
790
|
+
function stepStatus(step, planStatus) {
|
|
791
|
+
const stateType = path(step, "state", "type");
|
|
792
|
+
if (step["archived"] === true) return "archived";
|
|
793
|
+
if (stateType === "completed") return "done";
|
|
794
|
+
if (stateType === "canceled") return "cancelled";
|
|
795
|
+
if (step["container"] === true) return "container";
|
|
796
|
+
if (step["ready"] === true) return "ready";
|
|
797
|
+
if (step["held"] === true) return "held";
|
|
798
|
+
if (step["in_revision"] === false && LIVE_PLAN_STATUSES.includes(planStatus)) return "not in revision";
|
|
799
|
+
if (records(step["conditions"]).some((condition) => condition["satisfied"] !== true)) return "waiting";
|
|
800
|
+
return "-";
|
|
801
|
+
}
|
|
802
|
+
function planEdgesSection(edges) {
|
|
803
|
+
const list3 = records(edges);
|
|
804
|
+
if (list3.length === 0) return "";
|
|
805
|
+
return "\nEdges (blocker \u2192 blocked):\n" + list3.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
|
|
806
|
+
`).join("");
|
|
807
|
+
}
|
|
808
|
+
function planConditionsSection(steps) {
|
|
809
|
+
const lines = steps.flatMap(
|
|
810
|
+
(step) => records(step["conditions"]).map((condition) => [clean(step["identifier"]), conditionLine(condition)])
|
|
811
|
+
);
|
|
812
|
+
if (lines.length === 0) return "";
|
|
813
|
+
const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
|
|
814
|
+
return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
|
|
815
|
+
`).join("");
|
|
816
|
+
}
|
|
817
|
+
function planWaitingSection(steps) {
|
|
818
|
+
const lines = steps.flatMap(
|
|
819
|
+
(step) => records(step["waiting_for"]).map((wait) => [clean(step["identifier"]), waitLine(wait)])
|
|
820
|
+
);
|
|
821
|
+
if (lines.length === 0) return "";
|
|
822
|
+
const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
|
|
823
|
+
return "\nWaiting for:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
|
|
824
|
+
`).join("");
|
|
825
|
+
}
|
|
826
|
+
function waitLine(wait) {
|
|
827
|
+
const name = gateName(wait);
|
|
828
|
+
const owner = wait["owner"];
|
|
829
|
+
if (typeof owner !== "object" || owner === null) return `${name} \xB7 no owner, anyone with standing can tick`;
|
|
830
|
+
const holders = wait["holders"];
|
|
831
|
+
let who;
|
|
832
|
+
if (!Array.isArray(holders)) who = "who can tick is not known";
|
|
833
|
+
else if (holders.length === 0) who = "nobody can tick";
|
|
834
|
+
else who = `can tick: ${records(holders).map(holderText).join(", ")}`;
|
|
835
|
+
return `${name} \xB7 ${ownerText(owner)} \xB7 ${who}`;
|
|
836
|
+
}
|
|
837
|
+
function holderText(holder) {
|
|
838
|
+
const name = clean(holder["name"]);
|
|
839
|
+
if (holder["kind"] !== "expert") return name;
|
|
840
|
+
const organization = clean(holder["organization"]);
|
|
841
|
+
return `${name} (expert${organization === "" ? "" : `, ${organization}`})`;
|
|
842
|
+
}
|
|
843
|
+
function planHeldSection(heldSteps) {
|
|
844
|
+
if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
|
|
845
|
+
return `
|
|
846
|
+
Held by an edit until the plan is released again: ${heldSteps.map((step) => clean(step)).join(", ")}
|
|
847
|
+
`;
|
|
848
|
+
}
|
|
849
|
+
function indentLines(text) {
|
|
850
|
+
return text.split("\n").map((line) => line === "" ? line : " " + line).join("\n");
|
|
851
|
+
}
|
|
852
|
+
function errorDetails(body) {
|
|
853
|
+
return changesetDetails(body["details"]) + violationDetails(body["violations"]);
|
|
854
|
+
}
|
|
855
|
+
function changesetDetails(details) {
|
|
856
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
|
|
857
|
+
return Object.entries(details).map(([field3, messages]) => {
|
|
858
|
+
const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
|
|
859
|
+
return `
|
|
860
|
+
${clean(field3)}: ${clean(text)}`;
|
|
861
|
+
}).join("");
|
|
862
|
+
}
|
|
863
|
+
function violationDetails(violations) {
|
|
864
|
+
return records(violations).map((violation) => {
|
|
865
|
+
const code = clean(violation["code"]);
|
|
866
|
+
const message = clean(violation["message"]) || code || "invalid";
|
|
867
|
+
return `
|
|
868
|
+
${violationSubject(violation)}: ${message}${code === "" ? "" : ` [${code}]`}`;
|
|
869
|
+
}).join("");
|
|
870
|
+
}
|
|
871
|
+
function violationSubject(violation) {
|
|
872
|
+
const key = typeof violation["key"] === "string" ? clean(violation["key"]) : null;
|
|
873
|
+
const index = typeof violation["index"] === "number" ? `steps[${violation["index"]}]` : null;
|
|
874
|
+
const issue = typeof violation["issue"] === "string" ? clean(violation["issue"]) : null;
|
|
875
|
+
if (key !== null) {
|
|
876
|
+
const where = index ?? issue;
|
|
877
|
+
return where === null ? `step "${key}"` : `step "${key}" (${where})`;
|
|
878
|
+
}
|
|
879
|
+
return issue ?? index ?? "plan";
|
|
880
|
+
}
|
|
881
|
+
function relationsSection(relations) {
|
|
882
|
+
if (!Array.isArray(relations) || relations.length === 0) return "";
|
|
883
|
+
const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
|
|
884
|
+
`).join("");
|
|
885
|
+
return "\nRelations:\n" + lines;
|
|
886
|
+
}
|
|
887
|
+
function childrenSection(children) {
|
|
888
|
+
if (!Array.isArray(children) || children.length === 0) return "";
|
|
889
|
+
const lines = children.map((child) => {
|
|
890
|
+
const state = str(path(child, "state", "name")) || "-";
|
|
891
|
+
return ` ${str(child["identifier"])} [${state}] ${truncate(child["title"])}
|
|
892
|
+
`;
|
|
893
|
+
}).join("");
|
|
894
|
+
return "\nSub-issues:\n" + lines;
|
|
895
|
+
}
|
|
896
|
+
function commentsSection(comments2) {
|
|
897
|
+
if (!Array.isArray(comments2) || comments2.length === 0) return "";
|
|
898
|
+
const lines = comments2.map((comment2) => {
|
|
899
|
+
const author = clean(path(comment2, "author", "name") ?? "unknown");
|
|
900
|
+
return ` [${str(comment2["inserted_at"])}] ${author}: ${truncate(comment2["body"])}
|
|
901
|
+
`;
|
|
902
|
+
}).join("");
|
|
903
|
+
return "\nComments:\n" + lines;
|
|
904
|
+
}
|
|
905
|
+
function imagesSection(images) {
|
|
906
|
+
if (images.length === 0) return "";
|
|
907
|
+
const lines = images.map((image) => ` [${image["source"]}] ${clean(image["path"])}
|
|
908
|
+
`).join("");
|
|
909
|
+
return "\nImages (attachments downloaded locally \u2014 read them, they are part of the issue):\n" + lines;
|
|
910
|
+
}
|
|
911
|
+
function labelsLine(labels) {
|
|
912
|
+
if (!Array.isArray(labels) || labels.length === 0) return null;
|
|
913
|
+
return labels.map((label) => clean(label["name"])).join(", ");
|
|
914
|
+
}
|
|
915
|
+
function blockedLine(issue) {
|
|
916
|
+
if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
|
|
917
|
+
const notes = blockerNotes(issue["blockers"]);
|
|
918
|
+
const blockers = issue["blocked_by"].map((blocker) => {
|
|
919
|
+
const identifier = String(blocker);
|
|
920
|
+
const note = notes.get(identifier);
|
|
921
|
+
return note === void 0 ? identifier : `${identifier} (${note})`;
|
|
922
|
+
});
|
|
923
|
+
return `yes, by ${blockers.join(", ")}`;
|
|
924
|
+
}
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
function blockerNotes(blockers) {
|
|
928
|
+
const notes = /* @__PURE__ */ new Map();
|
|
929
|
+
if (!Array.isArray(blockers)) return notes;
|
|
930
|
+
for (const blocker of blockers) {
|
|
931
|
+
if (typeof blocker !== "object" || blocker === null) continue;
|
|
932
|
+
const record = blocker;
|
|
933
|
+
if (typeof record["identifier"] !== "string") continue;
|
|
934
|
+
const reasons = [];
|
|
935
|
+
if (record["state_type"] === "canceled") reasons.push("cancelled");
|
|
936
|
+
if (record["archived"] === true) reasons.push("archived");
|
|
937
|
+
if (reasons.length > 0) notes.set(record["identifier"], reasons.join(", "));
|
|
938
|
+
}
|
|
939
|
+
return notes;
|
|
940
|
+
}
|
|
941
|
+
function indent(text) {
|
|
942
|
+
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
943
|
+
}
|
|
944
|
+
function cleanMultiline(text) {
|
|
945
|
+
return text.split("\n").map((line) => clean(line)).join("\n");
|
|
946
|
+
}
|
|
947
|
+
function truncate(text) {
|
|
948
|
+
if (text === null || text === void 0) return "";
|
|
949
|
+
const cleaned = clean(text);
|
|
950
|
+
const pieces = graphemes(cleaned);
|
|
951
|
+
if (pieces.length > TITLE_WIDTH) {
|
|
952
|
+
return pieces.slice(0, TITLE_WIDTH - 1).join("") + "\u2026";
|
|
953
|
+
}
|
|
954
|
+
return cleaned;
|
|
955
|
+
}
|
|
956
|
+
function str(value) {
|
|
957
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
958
|
+
}
|
|
959
|
+
function path(record, ...keys) {
|
|
960
|
+
let current = record;
|
|
961
|
+
for (const key of keys) {
|
|
962
|
+
if (typeof current !== "object" || current === null) return null;
|
|
963
|
+
current = current[key];
|
|
964
|
+
}
|
|
965
|
+
return current ?? null;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// src/commands/issues.ts
|
|
969
|
+
import { openAsBlob, openSync, closeSync, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
970
|
+
import { basename, extname } from "path";
|
|
971
|
+
|
|
972
|
+
// src/core/attachments.ts
|
|
973
|
+
import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
974
|
+
import { dirname, join } from "path";
|
|
975
|
+
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;
|
|
976
|
+
var SOURCES = [
|
|
977
|
+
["description", "description"],
|
|
978
|
+
["design", "design"],
|
|
979
|
+
["acceptance_criteria", "acceptance criteria"],
|
|
980
|
+
["notes", "notes"]
|
|
981
|
+
];
|
|
982
|
+
var MAX_IMAGES_PER_SHOW = 20;
|
|
983
|
+
var MAX_ATTACHMENT_BYTES = 25e6;
|
|
984
|
+
function refs(issue) {
|
|
985
|
+
const texts = SOURCES.map(([field3, source]) => [issue[field3], source]);
|
|
986
|
+
const comments2 = Array.isArray(issue["comments"]) ? issue["comments"] : [];
|
|
987
|
+
for (const comment2 of comments2) texts.push([comment2["body"], "comment"]);
|
|
988
|
+
const found = [];
|
|
989
|
+
const seen = /* @__PURE__ */ new Set();
|
|
990
|
+
for (const [text, source] of texts) {
|
|
991
|
+
if (typeof text !== "string" || text === "") continue;
|
|
992
|
+
for (const match of text.matchAll(REF)) {
|
|
993
|
+
const ref = {
|
|
994
|
+
attachmentId: match[1].toLowerCase(),
|
|
995
|
+
file: match[2],
|
|
996
|
+
source
|
|
997
|
+
};
|
|
998
|
+
const key = `${ref.attachmentId}/${ref.file}`;
|
|
999
|
+
if (!seen.has(key)) {
|
|
1000
|
+
seen.add(key);
|
|
1001
|
+
found.push(ref);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return found;
|
|
1006
|
+
}
|
|
1007
|
+
async function fetchImages(client, issue, cacheDir) {
|
|
1008
|
+
const allRefs = refs(issue);
|
|
1009
|
+
const wanted = allRefs.slice(0, MAX_IMAGES_PER_SHOW);
|
|
1010
|
+
const dropped = allRefs.slice(MAX_IMAGES_PER_SHOW);
|
|
1011
|
+
const images = [];
|
|
1012
|
+
const failures = [];
|
|
1013
|
+
for (const ref of wanted) {
|
|
1014
|
+
const result = await download(client, ref, cacheDir);
|
|
1015
|
+
if (result.ok) {
|
|
1016
|
+
images.push({
|
|
1017
|
+
attachment_id: ref.attachmentId,
|
|
1018
|
+
file: ref.file,
|
|
1019
|
+
path: result.path,
|
|
1020
|
+
source: ref.source
|
|
1021
|
+
});
|
|
1022
|
+
} else {
|
|
1023
|
+
failures.push(`${ref.file}: ${result.message}`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (dropped.length > 0) {
|
|
1027
|
+
failures.push(
|
|
1028
|
+
`${dropped.length} further reference(s) skipped \u2014 the cap is ${MAX_IMAGES_PER_SHOW} images per show`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
return { images, failures };
|
|
1032
|
+
}
|
|
1033
|
+
async function download(client, ref, cacheDir) {
|
|
1034
|
+
const path2 = join(cacheDir, hostSegment(client.baseUrl), ref.attachmentId, ref.file);
|
|
1035
|
+
try {
|
|
1036
|
+
if (statSync(path2).size > 0) return { ok: true, path: path2 };
|
|
1037
|
+
} catch {
|
|
1038
|
+
}
|
|
1039
|
+
const bytes = await fetchBytes(client, ref);
|
|
1040
|
+
if (!bytes.ok) return bytes;
|
|
1041
|
+
return write(path2, bytes.bytes);
|
|
1042
|
+
}
|
|
1043
|
+
function hostSegment(baseUrl) {
|
|
1044
|
+
let host = "unknown-host";
|
|
1045
|
+
let port = null;
|
|
1046
|
+
try {
|
|
1047
|
+
const parsed = new URL(baseUrl);
|
|
1048
|
+
host = parsed.hostname.toLowerCase().replace(/[^a-z0-9.-]/g, "_");
|
|
1049
|
+
port = parsed.port === "" ? null : Number(parsed.port);
|
|
1050
|
+
} catch {
|
|
1051
|
+
}
|
|
1052
|
+
return port === null || port === 80 || port === 443 ? host : `${host}_${port}`;
|
|
1053
|
+
}
|
|
1054
|
+
async function fetchBytes(client, ref) {
|
|
1055
|
+
let bytes;
|
|
1056
|
+
try {
|
|
1057
|
+
bytes = await client.getBytes(`/api/v1/attachments/${ref.attachmentId}/${ref.file}`);
|
|
1058
|
+
} catch (error) {
|
|
1059
|
+
if (error instanceof HttpError) return { ok: false, message: `${error.message} (HTTP ${error.status})` };
|
|
1060
|
+
if (error instanceof TransportError) return { ok: false, message: error.message };
|
|
1061
|
+
throw error;
|
|
1062
|
+
}
|
|
1063
|
+
if (bytes.byteLength > MAX_ATTACHMENT_BYTES) {
|
|
1064
|
+
return { ok: false, message: `response is larger than the ${MAX_ATTACHMENT_BYTES}-byte attachment cap` };
|
|
1065
|
+
}
|
|
1066
|
+
if (!isImage(bytes)) return { ok: false, message: "response is not an image" };
|
|
1067
|
+
return { ok: true, bytes };
|
|
1068
|
+
}
|
|
1069
|
+
function isImage(bytes) {
|
|
1070
|
+
const startsWith = (prefix) => prefix.every((byte, index) => bytes[index] === byte);
|
|
1071
|
+
if (startsWith([137, 80, 78, 71, 13, 10, 26, 10])) return true;
|
|
1072
|
+
if (startsWith([255, 216, 255])) return true;
|
|
1073
|
+
if (startsWith([71, 73, 70, 56, 55, 97])) return true;
|
|
1074
|
+
if (startsWith([71, 73, 70, 56, 57, 97])) return true;
|
|
1075
|
+
if (startsWith([82, 73, 70, 70]) && bytes.length >= 12) {
|
|
1076
|
+
return bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80;
|
|
1077
|
+
}
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1080
|
+
var partialCounter = 0;
|
|
1081
|
+
function write(path2, bytes) {
|
|
1082
|
+
const partial = `${path2}.partial.${process.pid}.${++partialCounter}`;
|
|
1083
|
+
try {
|
|
1084
|
+
mkdirSync(dirname(path2), { recursive: true });
|
|
1085
|
+
chmodSync(dirname(path2), 448);
|
|
1086
|
+
writeFileSync(partial, bytes);
|
|
1087
|
+
chmodSync(partial, 384);
|
|
1088
|
+
renameSync(partial, path2);
|
|
1089
|
+
return { ok: true, path: path2 };
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
rmSync(partial, { force: true });
|
|
1092
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1093
|
+
return { ok: false, message: `cannot write ${path2}: ${detail}` };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// src/core/pagination.ts
|
|
1098
|
+
function notices(meta, requestedPageSize, records5) {
|
|
1099
|
+
return capNotice(meta, requestedPageSize) + truncationNotice(meta, records5);
|
|
1100
|
+
}
|
|
1101
|
+
function capNotice(meta, requestedPageSize) {
|
|
1102
|
+
const served = meta?.page_size;
|
|
1103
|
+
if (typeof served === "number" && typeof requestedPageSize === "number" && requestedPageSize > served) {
|
|
1104
|
+
return `harness: --limit ${requestedPageSize} was capped at ${served} by the server.
|
|
1105
|
+
`;
|
|
1106
|
+
}
|
|
1107
|
+
return "";
|
|
1108
|
+
}
|
|
1109
|
+
function truncationNotice(meta, records5) {
|
|
1110
|
+
const total = meta?.total;
|
|
1111
|
+
const page = meta?.page;
|
|
1112
|
+
const pages = meta?.total_pages;
|
|
1113
|
+
if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
|
|
1114
|
+
const shown = records5.length;
|
|
1115
|
+
if (shown < total) {
|
|
1116
|
+
return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
|
|
1117
|
+
`;
|
|
1118
|
+
}
|
|
1119
|
+
return "";
|
|
1120
|
+
}
|
|
1121
|
+
async function fetchEveryPage(fetchPage) {
|
|
1122
|
+
const first = await fetchPage(1);
|
|
1123
|
+
const totalPages = pageCount(first.meta);
|
|
1124
|
+
if (totalPages === null) return first.data;
|
|
1125
|
+
let records5 = first.data;
|
|
1126
|
+
for (let page = 2; page <= totalPages; page++) {
|
|
1127
|
+
const body = await fetchPage(page);
|
|
1128
|
+
records5 = dedupe([...records5, ...body.data]);
|
|
1129
|
+
}
|
|
1130
|
+
return records5;
|
|
1131
|
+
}
|
|
1132
|
+
function pageCount(meta) {
|
|
1133
|
+
const total = meta?.total_pages;
|
|
1134
|
+
return typeof total === "number" && total > 0 ? total : null;
|
|
1135
|
+
}
|
|
1136
|
+
function dedupe(records5) {
|
|
1137
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1138
|
+
return records5.filter((record) => {
|
|
1139
|
+
const id = record["id"];
|
|
1140
|
+
const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
|
|
1141
|
+
if (seen.has(key)) return false;
|
|
1142
|
+
seen.add(key);
|
|
1143
|
+
return true;
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
function stableKey(value) {
|
|
1147
|
+
return JSON.stringify(
|
|
1148
|
+
value,
|
|
1149
|
+
(_field, nested) => nested !== null && typeof nested === "object" && !Array.isArray(nested) ? Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a < b ? -1 : 1)) : nested
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// src/commands/issues.ts
|
|
1154
|
+
var PRIORITY_NAMES = { urgent: 1, high: 2, medium: 3, low: 4, none: 0 };
|
|
1155
|
+
var PRIORITY_RANK = { 1: 0, 2: 1, 3: 2, 4: 3, 0: 4 };
|
|
1156
|
+
var STATUS_STATE_TYPES = {
|
|
1157
|
+
open: "backlog,unstarted",
|
|
1158
|
+
in_progress: "started",
|
|
1159
|
+
closed: "completed,canceled"
|
|
1160
|
+
};
|
|
1161
|
+
var MAX_IDEMPOTENCY_KEY_BYTES = 200;
|
|
1162
|
+
var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
|
|
1163
|
+
var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
1164
|
+
var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1165
|
+
function isUuid(value) {
|
|
1166
|
+
return UUID_FORMAT.test(value);
|
|
1167
|
+
}
|
|
1168
|
+
function requireIdentifiers(identifiers) {
|
|
1169
|
+
const bad = identifiers.filter((identifier) => !IDENTIFIER_FORMAT.test(identifier));
|
|
1170
|
+
if (bad.length > 0) {
|
|
1171
|
+
throw new UsageError(`not an issue identifier: ${bad.join(", ")} (expected e.g. ALL-4223)`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
async function ready(ctx, opts) {
|
|
1175
|
+
if (opts.claim === true) return claimNext(ctx, opts);
|
|
1176
|
+
const params = { ...listParams(ctx, opts), ready: true };
|
|
1177
|
+
const { records: records5, notice } = await fetchIssues(ctx, params, opts);
|
|
1178
|
+
const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
|
|
1179
|
+
const sorted = markAgentSteps([...records5].sort((a, b) => rank(a) - rank(b)));
|
|
1180
|
+
return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
|
|
1181
|
+
}
|
|
1182
|
+
function markAgentSteps(issues) {
|
|
1183
|
+
return issues.map((issue) => ({ ...issue, agent_step: isAgentStep(issue) }));
|
|
1184
|
+
}
|
|
1185
|
+
function requireReadyClaimArgs(opts) {
|
|
1186
|
+
const paging = [
|
|
1187
|
+
opts.all === true ? "--all" : null,
|
|
1188
|
+
opts.limit !== void 0 ? "--limit" : null,
|
|
1189
|
+
opts.page !== void 0 ? "--page" : null
|
|
1190
|
+
].filter((flag) => flag !== null);
|
|
1191
|
+
if (paging.length === 0) return;
|
|
1192
|
+
const last = paging.pop();
|
|
1193
|
+
const flags = paging.length === 0 ? last : `${paging.join(", ")} or ${last}`;
|
|
1194
|
+
throw new UsageError(
|
|
1195
|
+
`--claim cannot be combined with ${flags}: the server picks from the team's whole ready queue, so there are no pages to choose from`
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
async function claimNext(ctx, opts) {
|
|
1199
|
+
requireReadyClaimArgs(opts);
|
|
1200
|
+
const team = requiredTeam(ctx, opts.team);
|
|
1201
|
+
const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
|
|
1202
|
+
const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
|
|
1203
|
+
let body;
|
|
1204
|
+
try {
|
|
1205
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/issues/claim-next`);
|
|
1206
|
+
} catch (error) {
|
|
1207
|
+
if (error instanceof MalformedResponseError) throw unreadable(`could not be read (${error.message})`);
|
|
1208
|
+
if (error instanceof TransportError) {
|
|
1209
|
+
throw new TransportError(
|
|
1210
|
+
`${error.message} \u2014 if the request reached the server, an issue may have been claimed; ${check} before claiming again`
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
throw error;
|
|
1214
|
+
}
|
|
1215
|
+
if (body.data === null) {
|
|
1216
|
+
return { json: null, human: `Nothing to claim: team ${team} has no unassigned ready issues.
|
|
1217
|
+
` };
|
|
1218
|
+
}
|
|
1219
|
+
const claimed = body.data;
|
|
1220
|
+
if (!isIssueRecord(claimed)) throw unreadable("carried neither an issue nor null");
|
|
1221
|
+
return {
|
|
1222
|
+
json: claimed,
|
|
1223
|
+
human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
|
|
1224
|
+
`
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
function isIssueRecord(value) {
|
|
1228
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1229
|
+
const identifier = value["identifier"];
|
|
1230
|
+
return typeof identifier === "string" && identifier !== "";
|
|
1231
|
+
}
|
|
1232
|
+
async function blocked(ctx, opts) {
|
|
1233
|
+
const params = { ...listParams(ctx, opts), blocked: true };
|
|
1234
|
+
const { records: records5, notice } = await fetchIssues(ctx, params, opts);
|
|
1235
|
+
return { json: records5, human: issuesTable(records5), notice };
|
|
1236
|
+
}
|
|
1237
|
+
async function list(ctx, opts) {
|
|
1238
|
+
const params = listParams(ctx, opts);
|
|
1239
|
+
const { records: records5, notice } = await fetchIssues(ctx, params, opts);
|
|
1240
|
+
return { json: records5, human: issuesTable(records5), notice };
|
|
1241
|
+
}
|
|
1242
|
+
async function search(ctx, queryWords, opts) {
|
|
1243
|
+
if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
|
|
1244
|
+
const deprecation = opts.page !== void 0 || opts.all === true ? "--page/--all are ignored: search returns one ranked list, capped at 20" : void 0;
|
|
1245
|
+
let params = { q: queryWords.join(" ") };
|
|
1246
|
+
if (opts.limit !== void 0) params = { ...params, limit: opts.limit };
|
|
1247
|
+
const body = await ctx.client.get("/api/v1/issues/search", params);
|
|
1248
|
+
const meta = body.meta ?? {};
|
|
1249
|
+
const degraded = meta["mode"] === "lexical" ? "semantic search unavailable \u2014 showing lexical matches only" : void 0;
|
|
1250
|
+
const notice = [deprecation, degraded].filter(Boolean).join("; ") || void 0;
|
|
1251
|
+
return { json: body.data, human: issuesTable(body.data), notice };
|
|
1252
|
+
}
|
|
1253
|
+
async function show(ctx, identifier, opts) {
|
|
1254
|
+
requireIdentifiers([identifier]);
|
|
1255
|
+
const body = await ctx.client.get(`/api/v1/issues/${identifier}`, {
|
|
1256
|
+
include: "relations,children,comments"
|
|
1257
|
+
});
|
|
1258
|
+
const issue = body.data;
|
|
1259
|
+
let images = [];
|
|
1260
|
+
let failures = [];
|
|
1261
|
+
if (opts.images !== false) {
|
|
1262
|
+
({ images, failures } = await fetchImages(ctx.client, issue, ctx.config.cacheDir));
|
|
1263
|
+
}
|
|
1264
|
+
const json2 = images.length === 0 ? issue : { ...issue, local_images: images };
|
|
1265
|
+
const human = issueDetail(issue, images);
|
|
1266
|
+
const notice = failures.map((failure) => `harness: image not downloaded: ${clean(failure)}
|
|
1267
|
+
`).join("");
|
|
1268
|
+
return notice === "" ? { json: json2, human } : { json: json2, human, notice };
|
|
1269
|
+
}
|
|
1270
|
+
var ACTIVITY_MAX_LIMIT = 100;
|
|
1271
|
+
var ACTIVITY_INCLUDE = "condition_events,field_events";
|
|
1272
|
+
var ACTIVITY_INCLUDE_WITHOUT_FIELDS = "condition_events";
|
|
1273
|
+
var ACTIVITY_WITHOUT_FIELDS_NOTICE = "harness: this server doesn't record field changes yet; showing activity without them.\n";
|
|
1274
|
+
async function activity(ctx, identifier, opts) {
|
|
1275
|
+
requireIdentifiers([identifier]);
|
|
1276
|
+
const first = await firstActivityPage(ctx, identifier, opts.limit);
|
|
1277
|
+
const include = first.include;
|
|
1278
|
+
let { page, notice } = first;
|
|
1279
|
+
let entries = page.entries;
|
|
1280
|
+
if (opts.all === true) {
|
|
1281
|
+
const pages = [page.entries];
|
|
1282
|
+
const seenCursors = /* @__PURE__ */ new Set();
|
|
1283
|
+
while (page.hasMore) {
|
|
1284
|
+
const cursor = page.nextCursor;
|
|
1285
|
+
if (seenCursors.has(cursor)) {
|
|
1286
|
+
throw new CommandError("Activity response repeated a cursor.");
|
|
1287
|
+
}
|
|
1288
|
+
seenCursors.add(cursor);
|
|
1289
|
+
page = await activityPage(ctx, identifier, include, opts.limit, cursor);
|
|
1290
|
+
pages.push(page.entries);
|
|
1291
|
+
}
|
|
1292
|
+
entries = pages.reverse().flat();
|
|
1293
|
+
}
|
|
1294
|
+
if (opts.all !== true && page.hasMore) {
|
|
1295
|
+
notice += `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
|
|
1296
|
+
`;
|
|
1297
|
+
}
|
|
1298
|
+
return { json: entries, human: activityEntries(entries), notice: notice === "" ? void 0 : notice };
|
|
1299
|
+
}
|
|
1300
|
+
async function firstActivityPage(ctx, identifier, limit) {
|
|
1301
|
+
try {
|
|
1302
|
+
const page2 = await activityPage(ctx, identifier, ACTIVITY_INCLUDE, limit);
|
|
1303
|
+
return { page: page2, include: ACTIVITY_INCLUDE, notice: "" };
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if (!refusesInclude(error)) throw error;
|
|
1306
|
+
}
|
|
1307
|
+
const page = await activityPage(ctx, identifier, ACTIVITY_INCLUDE_WITHOUT_FIELDS, limit);
|
|
1308
|
+
return { page, include: ACTIVITY_INCLUDE_WITHOUT_FIELDS, notice: ACTIVITY_WITHOUT_FIELDS_NOTICE };
|
|
1309
|
+
}
|
|
1310
|
+
function refusesInclude(error) {
|
|
1311
|
+
if (!(error instanceof HttpError) || error.status !== 400) return false;
|
|
1312
|
+
const message = error.body["message"];
|
|
1313
|
+
return typeof message === "string" && /\binclude\b/.test(message) && /\b(condition_events|field_events)\b|\baccepts only\b/.test(message);
|
|
1314
|
+
}
|
|
1315
|
+
async function activityPage(ctx, identifier, include, limit, before) {
|
|
1316
|
+
const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, {
|
|
1317
|
+
limit,
|
|
1318
|
+
before,
|
|
1319
|
+
include
|
|
1320
|
+
});
|
|
1321
|
+
if (!Array.isArray(body.data)) throw new CommandError("Activity response data is not an array.");
|
|
1322
|
+
if (body.data.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry))) {
|
|
1323
|
+
throw new CommandError("Activity response contains a non-object entry.");
|
|
1324
|
+
}
|
|
1325
|
+
const meta = body.meta;
|
|
1326
|
+
if (typeof meta !== "object" || meta === null) {
|
|
1327
|
+
throw new CommandError("Activity response has no pagination metadata.");
|
|
1328
|
+
}
|
|
1329
|
+
const hasMore = meta.has_more;
|
|
1330
|
+
const nextCursor = meta.next_cursor;
|
|
1331
|
+
if (typeof hasMore !== "boolean") {
|
|
1332
|
+
throw new CommandError("Activity response has an invalid has_more value.");
|
|
1333
|
+
}
|
|
1334
|
+
if (nextCursor !== null && typeof nextCursor !== "string") {
|
|
1335
|
+
throw new CommandError("Activity response has an invalid next_cursor value.");
|
|
1336
|
+
}
|
|
1337
|
+
if (hasMore) {
|
|
1338
|
+
if (nextCursor === null || nextCursor === "") {
|
|
1339
|
+
throw new CommandError("Activity response says older entries exist but provides no cursor.");
|
|
1340
|
+
}
|
|
1341
|
+
if (body.data.length === 0) {
|
|
1342
|
+
throw new CommandError("Activity response says older entries exist but contains no entries.");
|
|
1343
|
+
}
|
|
1344
|
+
return { entries: body.data, hasMore: true, nextCursor };
|
|
1345
|
+
}
|
|
1346
|
+
if (nextCursor !== null) {
|
|
1347
|
+
throw new CommandError("Activity response provides a cursor when no older entries exist.");
|
|
1348
|
+
}
|
|
1349
|
+
return { entries: body.data, hasMore: false, nextCursor: null };
|
|
1350
|
+
}
|
|
1351
|
+
async function stats2(ctx) {
|
|
1352
|
+
const body = await ctx.client.get("/api/v1/stats");
|
|
1353
|
+
return { json: body.data, human: stats(body.data) };
|
|
1354
|
+
}
|
|
1355
|
+
async function prime(ctx) {
|
|
1356
|
+
const statsBody = await ctx.client.get("/api/v1/stats");
|
|
1357
|
+
const ready2 = markAgentSteps(await mostImportantReady(ctx));
|
|
1358
|
+
const json2 = { stats: statsBody.data, ready: ready2 };
|
|
1359
|
+
const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + `
|
|
1360
|
+
Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
|
|
1361
|
+
harness close <id> --reason "Implemented in <commit-hash>" (response
|
|
1362
|
+
lists newly unblocked issues \u2014 surface them).
|
|
1363
|
+
Create tracked work BEFORE coding: harness create --title "\u2026" --type task.
|
|
1364
|
+
Dependencies: harness dep add <issue> <blocker> (blocker blocks issue).
|
|
1365
|
+
Before reporting done: close the issues you finished.
|
|
1366
|
+
`;
|
|
1367
|
+
return { json: json2, human };
|
|
1368
|
+
}
|
|
1369
|
+
async function mostImportantReady(ctx) {
|
|
1370
|
+
try {
|
|
1371
|
+
return (await ctx.client.get("/api/v1/issues/next", { limit: 10 })).data;
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
|
1374
|
+
return (await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 })).data;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
async function version(config) {
|
|
1378
|
+
let server;
|
|
1379
|
+
if (config === null || config.baseUrl === "") {
|
|
1380
|
+
server = "unconfigured";
|
|
1381
|
+
} else {
|
|
1382
|
+
server = await new Client(config).serverVersion().catch(() => "unreachable");
|
|
1383
|
+
}
|
|
1384
|
+
return { json: { cli: CLI_VERSION, server }, human: `harness ${CLI_VERSION} (server API ${server})
|
|
1385
|
+
` };
|
|
1386
|
+
}
|
|
1387
|
+
async function create(ctx, opts) {
|
|
1388
|
+
if (opts.title === void 0) {
|
|
1389
|
+
throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
|
|
1390
|
+
}
|
|
1391
|
+
validateType(opts.type);
|
|
1392
|
+
const team = requiredTeam(ctx, opts.team);
|
|
1393
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
1394
|
+
const priority = optionalPriority(opts.priority);
|
|
1395
|
+
const dueDate = optionalDueDate(opts.dueDate);
|
|
1396
|
+
const labels = requestedLabelNames(wantedLabels(opts));
|
|
1397
|
+
const labelIds = await prepareCreateLabelIds(ctx, team, labels);
|
|
1398
|
+
let body = { title: opts.title };
|
|
1399
|
+
body = putOpt(body, "description", opts.description);
|
|
1400
|
+
body = putOpt(body, "design", opts.design);
|
|
1401
|
+
body = putOpt(body, "notes", opts.notes);
|
|
1402
|
+
body = putOpt(body, "acceptance_criteria", opts.acceptance);
|
|
1403
|
+
body = putOpt(body, "parent", opts.parent);
|
|
1404
|
+
body = putPresent(body, "priority", priority);
|
|
1405
|
+
body = putDueDate(body, dueDate);
|
|
1406
|
+
body = putAssignee(body, opts.assignee);
|
|
1407
|
+
if (labelIds.length > 0) body["label_ids"] = labelIds;
|
|
1408
|
+
const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body, {
|
|
1409
|
+
idempotencyKey: opts.idempotencyKey
|
|
1410
|
+
});
|
|
1411
|
+
const issue = created.data;
|
|
1412
|
+
return {
|
|
1413
|
+
json: issue,
|
|
1414
|
+
human: `Created ${str2(issue["identifier"])}: ${clean(issue["title"])}
|
|
1415
|
+
`
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
function validateIdempotencyKey(key) {
|
|
1419
|
+
if (key === void 0) return;
|
|
1420
|
+
if (key.trim() === "") throw new UsageError("idempotency key must not be blank");
|
|
1421
|
+
if (Buffer.byteLength(key, "utf8") > MAX_IDEMPOTENCY_KEY_BYTES) {
|
|
1422
|
+
throw new UsageError(`idempotency key must not exceed ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
|
|
1423
|
+
}
|
|
1424
|
+
if (!IDEMPOTENCY_KEY_FORMAT.test(key)) {
|
|
1425
|
+
throw new UsageError("idempotency key must use printable ASCII and cannot start or end with a space");
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async function update(ctx, identifier, opts) {
|
|
1429
|
+
requireIdentifiers([identifier]);
|
|
1430
|
+
if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
|
|
1431
|
+
throw new UsageError("--notes replaces and --append-notes appends \u2014 pass one, not both");
|
|
1432
|
+
}
|
|
1433
|
+
if (opts.claim === true && opts.assignee !== void 0) {
|
|
1434
|
+
throw new UsageError(
|
|
1435
|
+
"--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)"
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
const priority = optionalPriority(opts.priority);
|
|
1439
|
+
const dueDate = optionalDueDate(opts.dueDate);
|
|
1440
|
+
const addLabels = requestedLabelInputs(opts.addLabel ?? []);
|
|
1441
|
+
const removeLabels = requestedLabelInputs(opts.removeLabel ?? []);
|
|
1442
|
+
const labelPlan = addLabels.length > 0 || removeLabels.length > 0 ? await prepareLabelUpdate(ctx, identifier, addLabels, removeLabels) : null;
|
|
1443
|
+
let issue = labelPlan?.issue ?? null;
|
|
1444
|
+
let wrote = false;
|
|
1445
|
+
if (opts.claim === true) {
|
|
1446
|
+
try {
|
|
1447
|
+
const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
|
|
1448
|
+
issue = body.data;
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
throw explainClaimRefusal(error, identifier);
|
|
1451
|
+
}
|
|
1452
|
+
wrote = true;
|
|
1453
|
+
}
|
|
1454
|
+
let patch = {};
|
|
1455
|
+
patch = putOpt(patch, "title", opts.title);
|
|
1456
|
+
patch = putOpt(patch, "description", opts.description);
|
|
1457
|
+
patch = putOpt(patch, "design", opts.design);
|
|
1458
|
+
patch = putOpt(patch, "notes", opts.notes);
|
|
1459
|
+
patch = putOpt(patch, "acceptance_criteria", opts.acceptance);
|
|
1460
|
+
patch = putOpt(patch, "parent", opts.parent);
|
|
1461
|
+
patch = putPresent(patch, "priority", priority);
|
|
1462
|
+
patch = putDueDate(patch, dueDate);
|
|
1463
|
+
patch = putAssignee(patch, opts.assignee);
|
|
1464
|
+
if (opts.appendNotes !== void 0) {
|
|
1465
|
+
const current = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
1466
|
+
const existing = current.data["notes"];
|
|
1467
|
+
const combined = existing === null || existing === void 0 || existing === "" ? opts.appendNotes : `${str2(existing)}
|
|
1468
|
+
|
|
1469
|
+
${opts.appendNotes}`;
|
|
1470
|
+
patch["notes"] = combined;
|
|
1471
|
+
}
|
|
1472
|
+
if (Object.keys(patch).length > 0) {
|
|
1473
|
+
const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
|
|
1474
|
+
issue = body.data;
|
|
1475
|
+
wrote = true;
|
|
1476
|
+
}
|
|
1477
|
+
if (opts.state !== void 0) {
|
|
1478
|
+
issue = await setState(ctx, identifier, opts.state);
|
|
1479
|
+
wrote = true;
|
|
1480
|
+
}
|
|
1481
|
+
if (labelPlan?.changed === true) {
|
|
1482
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/labels`, {
|
|
1483
|
+
label_ids: labelPlan.ids
|
|
1484
|
+
});
|
|
1485
|
+
issue = body.data;
|
|
1486
|
+
wrote = true;
|
|
1487
|
+
}
|
|
1488
|
+
if (issue === null) {
|
|
1489
|
+
throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
|
|
1490
|
+
}
|
|
1491
|
+
const human = wrote ? `Updated ${str2(issue["identifier"])}
|
|
1492
|
+
` : `Labels already up to date for ${str2(issue["identifier"])}
|
|
1493
|
+
`;
|
|
1494
|
+
return { json: issue, human };
|
|
1495
|
+
}
|
|
1496
|
+
var CLAIM_REFUSAL_HINTS = {
|
|
1497
|
+
// The edit holds a released step, so the plan needs releasing again before
|
|
1498
|
+
// the step is claimable.
|
|
1499
|
+
agent_step: () => "Operator runs it for its plan; to do it yourself, set its executor to none (harness plan edit <plan-id>) and release the plan again",
|
|
1500
|
+
waiting_on_condition: (identifier) => `harness show ${identifier} lists what it waits on; tick a gate with harness gate satisfy ${identifier} <gate>`
|
|
1501
|
+
};
|
|
1502
|
+
function explainClaimRefusal(error, identifier) {
|
|
1503
|
+
if (!(error instanceof HttpError) || error.status !== 409) return error;
|
|
1504
|
+
const reason = error.body["reason"];
|
|
1505
|
+
const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier) : void 0;
|
|
1506
|
+
if (hint === void 0) return error;
|
|
1507
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
1508
|
+
}
|
|
1509
|
+
function requireCloseArgs(identifiers, opts) {
|
|
1510
|
+
if (identifiers.length === 0) {
|
|
1511
|
+
throw new UsageError(
|
|
1512
|
+
"usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel | --duplicate-of <id>]"
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
requireIdentifiers(identifiers);
|
|
1516
|
+
if (opts.duplicateOf === void 0) return;
|
|
1517
|
+
requireIdentifiers([opts.duplicateOf]);
|
|
1518
|
+
if (opts.cancel === true) {
|
|
1519
|
+
throw new UsageError("--duplicate-of and --cancel are mutually exclusive");
|
|
1520
|
+
}
|
|
1521
|
+
if (identifiers.length !== 1) {
|
|
1522
|
+
throw new UsageError("--duplicate-of accepts exactly one source issue");
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
async function close(ctx, identifiers, opts) {
|
|
1526
|
+
requireCloseArgs(identifiers, opts);
|
|
1527
|
+
let body = {};
|
|
1528
|
+
body = putPresent(body, "reason", opts.reason);
|
|
1529
|
+
body = putPresent(body, "cancel", opts.cancel);
|
|
1530
|
+
body = putPresent(body, "duplicate_of", opts.duplicateOf);
|
|
1531
|
+
const results = [];
|
|
1532
|
+
for (const identifier of identifiers) {
|
|
1533
|
+
try {
|
|
1534
|
+
const response = await ctx.client.post(`/api/v1/issues/${identifier}/close`, body);
|
|
1535
|
+
results.push({ identifier, ok: true, response });
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
if (error instanceof HttpError || error instanceof TransportError) {
|
|
1538
|
+
results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
|
|
1539
|
+
} else {
|
|
1540
|
+
throw error;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
|
|
1545
|
+
const json2 = results.map(
|
|
1546
|
+
(result) => result.ok ? {
|
|
1547
|
+
identifier: result.identifier,
|
|
1548
|
+
closed: true,
|
|
1549
|
+
...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
|
|
1550
|
+
unblocked: result.response["unblocked"] ?? null,
|
|
1551
|
+
// Passed through as sent: older servers omit it, and so does this.
|
|
1552
|
+
..."still_blocking" in result.response ? { still_blocking: result.response["still_blocking"] } : {}
|
|
1553
|
+
} : { identifier: result.identifier, closed: false, error: errorText(result.error) }
|
|
1554
|
+
);
|
|
1555
|
+
const partial = results.some((result) => !result.ok);
|
|
1556
|
+
return partial ? { json: json2, human, partial: true } : { json: json2, human };
|
|
1557
|
+
}
|
|
1558
|
+
function closeLine(result, duplicateOf) {
|
|
1559
|
+
if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
|
|
1560
|
+
`;
|
|
1561
|
+
const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
|
|
1562
|
+
const unblocked = result.response["unblocked"];
|
|
1563
|
+
const line = Array.isArray(unblocked) && unblocked.length > 0 ? `${closure} \u2014 unblocked: ${unblocked.map((identifier) => clean(identifier)).join(", ")}
|
|
1564
|
+
` : `${closure}
|
|
1565
|
+
`;
|
|
1566
|
+
return line + stillBlockingLine(result.response["still_blocking"]);
|
|
1567
|
+
}
|
|
1568
|
+
function stillBlockingLine(stillBlocking) {
|
|
1569
|
+
if (!Array.isArray(stillBlocking) || stillBlocking.length === 0) return "";
|
|
1570
|
+
const identifiers = stillBlocking.map((identifier) => clean(identifier)).join(", ");
|
|
1571
|
+
return ` Still blocking: ${identifiers} (a cancelled issue still blocks its dependents)
|
|
1572
|
+
`;
|
|
1573
|
+
}
|
|
1574
|
+
async function archive(ctx, identifiers, opts) {
|
|
1575
|
+
if (identifiers.length === 0) {
|
|
1576
|
+
throw new UsageError("usage: harness archive <id> [<id>\u2026] [--restore]");
|
|
1577
|
+
}
|
|
1578
|
+
requireIdentifiers(identifiers);
|
|
1579
|
+
const archived = opts.restore !== true;
|
|
1580
|
+
const verb = archived ? "Archived" : "Restored";
|
|
1581
|
+
const results = [];
|
|
1582
|
+
for (const identifier of identifiers) {
|
|
1583
|
+
try {
|
|
1584
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/archive_state`, { archived });
|
|
1585
|
+
results.push({ identifier, ok: true, issue: body.data });
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
if (error instanceof HttpError || error instanceof TransportError) {
|
|
1588
|
+
results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
|
|
1589
|
+
} else {
|
|
1590
|
+
throw error;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const human = results.map(
|
|
1595
|
+
(result) => result.ok ? `${verb} ${result.identifier}
|
|
1596
|
+
` : `FAILED ${result.identifier}: ${errorText(result.error)}
|
|
1597
|
+
`
|
|
1598
|
+
).join("");
|
|
1599
|
+
const json2 = results.map(
|
|
1600
|
+
(result) => result.ok ? { identifier: result.identifier, archived } : { identifier: result.identifier, error: errorText(result.error) }
|
|
1601
|
+
);
|
|
1602
|
+
const partial = results.some((result) => !result.ok);
|
|
1603
|
+
return partial ? { json: json2, human, partial: true } : { json: json2, human };
|
|
1604
|
+
}
|
|
1605
|
+
async function comments(ctx, identifier) {
|
|
1606
|
+
requireIdentifiers([identifier]);
|
|
1607
|
+
const body = await ctx.client.get(`/api/v1/issues/${identifier}/comments`);
|
|
1608
|
+
if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
|
|
1609
|
+
const human = body.data.map((comment2) => {
|
|
1610
|
+
const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
|
|
1611
|
+
const id = clean(comment2["id"] ?? "unknown id");
|
|
1612
|
+
const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
|
|
1613
|
+
return `[${str2(comment2["inserted_at"])}] ${author} (${id}):
|
|
1614
|
+
${text}
|
|
1615
|
+
`;
|
|
1616
|
+
}).join("\n");
|
|
1617
|
+
return { json: body.data, human };
|
|
1618
|
+
}
|
|
1619
|
+
async function dep(ctx, verb, issue, blocker) {
|
|
1620
|
+
requireIdentifiers([issue, blocker]);
|
|
1621
|
+
if (verb === "add") {
|
|
1622
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
|
|
1623
|
+
type: "blocked_by",
|
|
1624
|
+
related_issue: blocker
|
|
1625
|
+
});
|
|
1626
|
+
return { json: response.data, human: `${blocker} now blocks ${issue}
|
|
1627
|
+
` };
|
|
1628
|
+
}
|
|
1629
|
+
if (verb === "rm") {
|
|
1630
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
1631
|
+
const relation = (body.data ?? []).find(
|
|
1632
|
+
(candidate) => candidate["direction"] === "blocked_by" && candidate["other_issue"] === blocker
|
|
1633
|
+
);
|
|
1634
|
+
if (relation === void 0) throw new CommandError(`${blocker} does not block this issue.`);
|
|
1635
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/relations/${str2(relation["id"])}`);
|
|
1636
|
+
return { json: { deleted: relation["id"] }, human: `${blocker} no longer blocks ${issue}
|
|
1637
|
+
` };
|
|
1638
|
+
}
|
|
1639
|
+
throw new UsageError("usage: harness dep add|rm <issue> <blocker> (the blocker blocks the issue)");
|
|
1640
|
+
}
|
|
1641
|
+
var RELATION_CREATE_TYPES = [
|
|
1642
|
+
"blocks",
|
|
1643
|
+
"blocked_by",
|
|
1644
|
+
"related",
|
|
1645
|
+
"duplicate",
|
|
1646
|
+
"duplicate_of",
|
|
1647
|
+
"duplicated_by"
|
|
1648
|
+
];
|
|
1649
|
+
var RELATION_UPDATE_TYPES = ["blocks", "blocked_by", "related", "duplicate_of"];
|
|
1650
|
+
function requireRelationType(type, allowed, operation) {
|
|
1651
|
+
if (!allowed.includes(type)) {
|
|
1652
|
+
throw new UsageError(`relation ${operation} type must be one of: ${allowed.join(", ")}`);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
async function findRelation(ctx, issue, idOrIssue) {
|
|
1656
|
+
const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
1657
|
+
const idMatch = response.data.find((candidate) => candidate["id"] === idOrIssue);
|
|
1658
|
+
if (idMatch !== void 0) return idMatch;
|
|
1659
|
+
const issueMatches = response.data.filter((candidate) => candidate["other_issue"] === idOrIssue);
|
|
1660
|
+
if (issueMatches.length > 1) {
|
|
1661
|
+
throw new CommandError(`multiple relations on ${issue} match ${idOrIssue}; use a relation id`);
|
|
1662
|
+
}
|
|
1663
|
+
if (issueMatches.length === 0) {
|
|
1664
|
+
throw new CommandError(`no relation on ${issue} matches ${idOrIssue} (by id or related issue)`);
|
|
1665
|
+
}
|
|
1666
|
+
return issueMatches[0];
|
|
1667
|
+
}
|
|
1668
|
+
async function resolveRelationId(ctx, issue, idOrIssue) {
|
|
1669
|
+
if (isUuid(idOrIssue)) return idOrIssue.toLowerCase();
|
|
1670
|
+
const relation = await findRelation(ctx, issue, idOrIssue);
|
|
1671
|
+
return String(relation["id"]);
|
|
1672
|
+
}
|
|
1673
|
+
async function relationList(ctx, issue) {
|
|
1674
|
+
requireIdentifiers([issue]);
|
|
1675
|
+
const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
|
|
1676
|
+
const human = response.data.length === 0 ? "No relations.\n" : table(
|
|
1677
|
+
["ID", "DIRECTION", "ISSUE"],
|
|
1678
|
+
response.data.map((relation) => [
|
|
1679
|
+
String(relation["id"] ?? ""),
|
|
1680
|
+
String(relation["direction"] ?? ""),
|
|
1681
|
+
String(relation["other_issue"] ?? "")
|
|
1682
|
+
])
|
|
1683
|
+
);
|
|
1684
|
+
return { json: response.data, human };
|
|
1685
|
+
}
|
|
1686
|
+
async function relationAdd(ctx, issue, relatedIssue, type) {
|
|
1687
|
+
requireIdentifiers([issue, relatedIssue]);
|
|
1688
|
+
requireRelationType(type, RELATION_CREATE_TYPES, "add");
|
|
1689
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
|
|
1690
|
+
type,
|
|
1691
|
+
related_issue: relatedIssue
|
|
1692
|
+
});
|
|
1693
|
+
return { json: response.data, human: `Related ${issue} to ${relatedIssue} as ${type}
|
|
1694
|
+
` };
|
|
1695
|
+
}
|
|
1696
|
+
async function relationUpdate(ctx, issue, idOrIssue, type) {
|
|
1697
|
+
requireIdentifiers([issue]);
|
|
1698
|
+
requireRelationType(type, RELATION_UPDATE_TYPES, "update");
|
|
1699
|
+
const id = await resolveRelationId(ctx, issue, idOrIssue);
|
|
1700
|
+
const response = await ctx.client.patch(`/api/v1/issues/${issue}/relations/${id}`, { type });
|
|
1701
|
+
return { json: response.data, human: `Updated relation ${id} on ${issue} to ${type}
|
|
1702
|
+
` };
|
|
1703
|
+
}
|
|
1704
|
+
async function relationRm(ctx, issue, idOrIssue) {
|
|
1705
|
+
requireIdentifiers([issue]);
|
|
1706
|
+
const id = await resolveRelationId(ctx, issue, idOrIssue);
|
|
1707
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/relations/${id}`);
|
|
1708
|
+
return { json: { deleted: id }, human: `Removed relation ${id} from ${issue}
|
|
1709
|
+
` };
|
|
1710
|
+
}
|
|
1711
|
+
async function comment(ctx, identifier, textWords, mentions) {
|
|
1712
|
+
requireIdentifiers([identifier]);
|
|
1713
|
+
if (textWords.length === 0) {
|
|
1714
|
+
throw new UsageError("usage: harness comment <id> <text> [--mention NAME]\u2026");
|
|
1715
|
+
}
|
|
1716
|
+
const body = { body: textWords.join(" ") };
|
|
1717
|
+
if (mentions.length > 0) body["mentions"] = mentions;
|
|
1718
|
+
const response = await ctx.client.post(`/api/v1/issues/${identifier}/comments`, body);
|
|
1719
|
+
return { json: response.data, human: `Commented on ${identifier}
|
|
1720
|
+
` };
|
|
1721
|
+
}
|
|
1722
|
+
async function editComment(ctx, identifier, commentId, textWords) {
|
|
1723
|
+
requireIdentifiers([identifier]);
|
|
1724
|
+
requireCommentId(commentId);
|
|
1725
|
+
const body = textWords.join(" ");
|
|
1726
|
+
if (body.trim() === "") {
|
|
1727
|
+
throw new UsageError("the comment text is empty \u2014 nothing to edit");
|
|
1728
|
+
}
|
|
1729
|
+
const response = await ctx.client.patch(
|
|
1730
|
+
`/api/v1/issues/${identifier}/comments/${commentId}`,
|
|
1731
|
+
{ body }
|
|
1732
|
+
);
|
|
1733
|
+
return {
|
|
1734
|
+
json: response.data,
|
|
1735
|
+
human: `Edited comment ${commentId} on ${identifier}
|
|
1736
|
+
`
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
async function deleteComment(ctx, identifier, commentId) {
|
|
1740
|
+
requireIdentifiers([identifier]);
|
|
1741
|
+
requireCommentId(commentId);
|
|
1742
|
+
const response = await ctx.client.delete(
|
|
1743
|
+
`/api/v1/issues/${identifier}/comments/${commentId}`
|
|
1744
|
+
);
|
|
1745
|
+
return {
|
|
1746
|
+
json: response.data,
|
|
1747
|
+
human: `Deleted comment ${commentId} from ${identifier}
|
|
1748
|
+
`
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
function requireCommentId(commentId) {
|
|
1752
|
+
if (!UUID_FORMAT.test(commentId)) {
|
|
1753
|
+
throw new UsageError(`not a comment id: ${commentId} (expected a UUID)`);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
var MAX_IMAGE_BYTES = 25e6;
|
|
1757
|
+
var MAX_VIDEO_BYTES = 1e8;
|
|
1758
|
+
async function attachImage(ctx, path2) {
|
|
1759
|
+
return uploadAttachment(ctx, path2, MAX_IMAGE_BYTES);
|
|
1760
|
+
}
|
|
1761
|
+
async function attachFile(ctx, path2) {
|
|
1762
|
+
const maxBytes = extname(path2).toLowerCase() === ".mp4" ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
|
|
1763
|
+
return uploadAttachment(ctx, path2, maxBytes);
|
|
1764
|
+
}
|
|
1765
|
+
async function uploadAttachment(ctx, path2, maxBytes) {
|
|
1766
|
+
attachableFile(path2, maxBytes);
|
|
1767
|
+
const file = maxBytes === MAX_VIDEO_BYTES ? new File([await openAsBlob(path2)], basename(path2)) : new File([readFileSync2(path2)], basename(path2));
|
|
1768
|
+
requireAttachmentSize(path2, file.size, maxBytes);
|
|
1769
|
+
const form = new FormData();
|
|
1770
|
+
form.set("file", file);
|
|
1771
|
+
const body = await ctx.client.postMultipart("/api/v1/uploads", form, file.size);
|
|
1772
|
+
const attachment = body.data;
|
|
1773
|
+
const markdown = attachment["markdown"];
|
|
1774
|
+
if (typeof markdown !== "string") throw new CommandError("the upload response carried no markdown reference");
|
|
1775
|
+
return { json: attachment, human: clean(markdown) + "\n" };
|
|
1776
|
+
}
|
|
1777
|
+
function attachableFile(path2, maxBytes) {
|
|
1778
|
+
let fileStats;
|
|
1779
|
+
try {
|
|
1780
|
+
fileStats = statSync2(path2);
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
|
|
1783
|
+
}
|
|
1784
|
+
if (!fileStats.isFile()) {
|
|
1785
|
+
const kind = fileStats.isDirectory() ? "directory" : "special file";
|
|
1786
|
+
throw new LocalError(`${path2} is a ${kind}, not a file.`);
|
|
1787
|
+
}
|
|
1788
|
+
requireAttachmentSize(path2, fileStats.size, maxBytes);
|
|
1789
|
+
try {
|
|
1790
|
+
closeSync(openSync(path2, "r"));
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
throw new LocalError(`cannot read ${path2}: ${posixDetail(error)}`);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
function requireAttachmentSize(path2, size, maxBytes) {
|
|
1796
|
+
if (size > maxBytes) {
|
|
1797
|
+
throw new LocalError(`${path2} is too large: ${size} bytes, and attachments are capped at ${maxBytes}.`);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
function posixDetail(error) {
|
|
1801
|
+
const code = error.code;
|
|
1802
|
+
if (code === "ENOENT") return "no such file or directory";
|
|
1803
|
+
if (code === "EACCES") return "permission denied";
|
|
1804
|
+
return error instanceof Error ? error.message : String(error);
|
|
1805
|
+
}
|
|
1806
|
+
async function setState(ctx, identifier, stateName2) {
|
|
1807
|
+
const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
1808
|
+
const teamKey = str2((showBody.data["team"] ?? {})["key"]);
|
|
1809
|
+
const statesBody = await ctx.client.get(
|
|
1810
|
+
`/api/v1/teams/${teamKey}/workflow_states`
|
|
1811
|
+
);
|
|
1812
|
+
const wanted = stateName2.toLowerCase();
|
|
1813
|
+
const state = statesBody.data.find(
|
|
1814
|
+
(candidate) => str2(candidate["name"]).toLowerCase() === wanted || str2(candidate["type"]) === wanted
|
|
1815
|
+
);
|
|
1816
|
+
if (state === void 0) {
|
|
1817
|
+
const names = statesBody.data.map((candidate) => str2(candidate["name"])).join(", ");
|
|
1818
|
+
throw new CommandError(`No workflow state matches "${stateName2}". States: ${names}`);
|
|
1819
|
+
}
|
|
1820
|
+
const body = await ctx.client.put(`/api/v1/issues/${identifier}/state`, { state_id: state["id"] });
|
|
1821
|
+
return body.data;
|
|
1822
|
+
}
|
|
1823
|
+
var ISSUE_TYPES = ["task", "bug", "feature", "epic"];
|
|
1824
|
+
function wantedLabels(opts) {
|
|
1825
|
+
const typeLabels = opts.type === void 0 ? [] : [`type/${opts.type}`];
|
|
1826
|
+
return [...typeLabels, ...opts.label ?? []];
|
|
1827
|
+
}
|
|
1828
|
+
function validateType(type) {
|
|
1829
|
+
if (type !== void 0 && !ISSUE_TYPES.includes(type)) {
|
|
1830
|
+
throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
function requestedLabelNames(names) {
|
|
1834
|
+
const unique = /* @__PURE__ */ new Map();
|
|
1835
|
+
for (const name of requestedLabelInputs(names)) {
|
|
1836
|
+
const normalized = normalizeLabelName(name);
|
|
1837
|
+
if (!unique.has(normalized)) unique.set(normalized, name);
|
|
1838
|
+
}
|
|
1839
|
+
return [...unique.values()];
|
|
1840
|
+
}
|
|
1841
|
+
function requestedLabelInputs(values) {
|
|
1842
|
+
return values.map((value) => {
|
|
1843
|
+
const trimmed = value.trim();
|
|
1844
|
+
if (trimmed === "") throw new UsageError("label names and ids cannot be empty");
|
|
1845
|
+
return trimmed;
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
function normalizeLabelName(value) {
|
|
1849
|
+
return str2(value).trim().toLowerCase();
|
|
1850
|
+
}
|
|
1851
|
+
async function prepareCreateLabelIds(ctx, teamKey, names) {
|
|
1852
|
+
if (names.length === 0) return [];
|
|
1853
|
+
const teams = await ctx.client.get("/api/v1/teams");
|
|
1854
|
+
const target = teams.data.find(
|
|
1855
|
+
(team) => str2(team["key"]).toLowerCase() === teamKey.toLowerCase()
|
|
1856
|
+
);
|
|
1857
|
+
if (target === void 0) throw new CommandError(`No team matches key "${teamKey}".`);
|
|
1858
|
+
const teamId = str2(target["id"]);
|
|
1859
|
+
if (teamId === "") throw new CommandError(`team ${teamKey} carried no id`);
|
|
1860
|
+
return prepareApplicableLabelIds(ctx, teamId, names);
|
|
1861
|
+
}
|
|
1862
|
+
async function prepareApplicableLabelIds(ctx, teamId, names) {
|
|
1863
|
+
let labels = (await ctx.client.get("/api/v1/labels")).data;
|
|
1864
|
+
const ids = [];
|
|
1865
|
+
for (const name of names) {
|
|
1866
|
+
const resolved = await resolveOrCreateApplicableLabel(ctx, labels, name, teamId);
|
|
1867
|
+
labels = resolved.catalog;
|
|
1868
|
+
ids.push(labelId(resolved.label));
|
|
1869
|
+
}
|
|
1870
|
+
return [...new Set(ids)];
|
|
1871
|
+
}
|
|
1872
|
+
function applicableLabel(labels, input, teamId) {
|
|
1873
|
+
const normalized = normalizeLabelName(input);
|
|
1874
|
+
const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
|
|
1875
|
+
const teamMatches = named.filter((label) => label["team_id"] === teamId);
|
|
1876
|
+
if (teamMatches.length > 1) {
|
|
1877
|
+
throw new CommandError(`more than one team label matches "${input}"; use a label id`);
|
|
1878
|
+
}
|
|
1879
|
+
if (teamMatches.length === 1) return teamMatches[0];
|
|
1880
|
+
const workspaceMatches = named.filter(
|
|
1881
|
+
(label) => label["team_id"] === null || label["team_id"] === void 0
|
|
1882
|
+
);
|
|
1883
|
+
if (workspaceMatches.length > 1) {
|
|
1884
|
+
throw new CommandError(`more than one workspace label matches "${input}"; use a label id`);
|
|
1885
|
+
}
|
|
1886
|
+
return workspaceMatches[0];
|
|
1887
|
+
}
|
|
1888
|
+
async function resolveOrCreateApplicableLabel(ctx, catalog, name, teamId) {
|
|
1889
|
+
const existing = applicableLabel(catalog, name, teamId);
|
|
1890
|
+
if (existing !== void 0) return { label: existing, catalog };
|
|
1891
|
+
try {
|
|
1892
|
+
const created = await ctx.client.post("/api/v1/labels", {
|
|
1893
|
+
name,
|
|
1894
|
+
color: "gray"
|
|
1895
|
+
});
|
|
1896
|
+
return { label: created.data, catalog: [...catalog, created.data] };
|
|
1897
|
+
} catch (error) {
|
|
1898
|
+
if (!(error instanceof HttpError) || error.status !== 422) throw error;
|
|
1899
|
+
const refreshed = (await ctx.client.get("/api/v1/labels")).data;
|
|
1900
|
+
const winner = applicableLabel(refreshed, name, teamId);
|
|
1901
|
+
if (winner === void 0) throw error;
|
|
1902
|
+
return { label: winner, catalog: refreshed };
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
function labelId(label) {
|
|
1906
|
+
const id = str2(label["id"]);
|
|
1907
|
+
if (id === "") throw new CommandError("the label response carried no id");
|
|
1908
|
+
return id;
|
|
1909
|
+
}
|
|
1910
|
+
async function prepareLabelUpdate(ctx, identifier, addInputs, removeInputs) {
|
|
1911
|
+
const issue = (await ctx.client.get(`/api/v1/issues/${identifier}`)).data;
|
|
1912
|
+
const attached = Array.isArray(issue["labels"]) ? issue["labels"] : [];
|
|
1913
|
+
const originalIds = attached.map(labelId);
|
|
1914
|
+
const removed = /* @__PURE__ */ new Set();
|
|
1915
|
+
for (const input of removeInputs) {
|
|
1916
|
+
const idInput = UUID_FORMAT.test(input);
|
|
1917
|
+
const exact = attached.find((label) => {
|
|
1918
|
+
const id = labelId(label);
|
|
1919
|
+
return idInput ? id.toLowerCase() === input.toLowerCase() : id === input;
|
|
1920
|
+
});
|
|
1921
|
+
if (exact !== void 0) {
|
|
1922
|
+
removed.add(labelId(exact));
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
const normalized = normalizeLabelName(input);
|
|
1926
|
+
const matches = attached.filter((label) => normalizeLabelName(label["name"]) === normalized);
|
|
1927
|
+
if (matches.length > 1) {
|
|
1928
|
+
throw new CommandError(`more than one attached label matches "${input}"; remove by label id`);
|
|
1929
|
+
}
|
|
1930
|
+
if (matches.length === 1) removed.add(labelId(matches[0]));
|
|
1931
|
+
}
|
|
1932
|
+
const ids = originalIds.filter((id) => !removed.has(id));
|
|
1933
|
+
if (addInputs.length > 0) {
|
|
1934
|
+
const team = issue["team"] ?? {};
|
|
1935
|
+
const teamId = str2(team["id"]);
|
|
1936
|
+
if (teamId === "") throw new CommandError(`issue ${identifier} carried no team id`);
|
|
1937
|
+
let catalog = (await ctx.client.get("/api/v1/labels")).data;
|
|
1938
|
+
for (const input of addInputs) {
|
|
1939
|
+
const idInput = UUID_FORMAT.test(input);
|
|
1940
|
+
let chosen = idInput ? catalog.find((label) => labelId(label).toLowerCase() === input.toLowerCase()) : void 0;
|
|
1941
|
+
if (chosen !== void 0) {
|
|
1942
|
+
const owner = chosen["team_id"];
|
|
1943
|
+
if (owner !== null && owner !== void 0 && owner !== teamId) {
|
|
1944
|
+
throw new CommandError(
|
|
1945
|
+
`label ${input} belongs to another team and cannot be added to ${identifier}`
|
|
1946
|
+
);
|
|
1947
|
+
}
|
|
1948
|
+
} else {
|
|
1949
|
+
if (idInput) throw new CommandError(`No label matches id "${input}".`);
|
|
1950
|
+
const resolved = await resolveOrCreateApplicableLabel(ctx, catalog, input, teamId);
|
|
1951
|
+
chosen = resolved.label;
|
|
1952
|
+
catalog = resolved.catalog;
|
|
1953
|
+
}
|
|
1954
|
+
const id = labelId(chosen);
|
|
1955
|
+
if (!ids.includes(id)) ids.push(id);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
return {
|
|
1959
|
+
issue,
|
|
1960
|
+
ids,
|
|
1961
|
+
changed: ids.length !== originalIds.length || ids.some((id, index) => id !== originalIds[index])
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
async function fetchIssues(ctx, params, opts) {
|
|
1965
|
+
if (opts.all === true && opts.page !== void 0) {
|
|
1966
|
+
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1967
|
+
}
|
|
1968
|
+
if (opts.all === true) {
|
|
1969
|
+
const records6 = await fetchEveryPage(
|
|
1970
|
+
(page) => ctx.client.get("/api/v1/issues", { ...params, page })
|
|
1971
|
+
);
|
|
1972
|
+
return { records: records6, notice: "" };
|
|
1973
|
+
}
|
|
1974
|
+
const body = await ctx.client.get("/api/v1/issues", params);
|
|
1975
|
+
const records5 = body.data;
|
|
1976
|
+
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
1977
|
+
return { records: records5, notice: notices(body.meta, requested, records5) };
|
|
1978
|
+
}
|
|
1979
|
+
function listParams(ctx, opts) {
|
|
1980
|
+
if (opts.type !== void 0 && opts.label !== void 0) {
|
|
1981
|
+
throw new UsageError("--type is a label filter itself \u2014 pass either --type or --label, not both");
|
|
1982
|
+
}
|
|
1983
|
+
validateType(opts.type);
|
|
1984
|
+
let params = withTeam({ page_size: opts.limit ?? 50, page: opts.page ?? 1 }, ctx, opts);
|
|
1985
|
+
params = putParam(params, "search", opts.search);
|
|
1986
|
+
params = putParam(params, "parent", opts.parent);
|
|
1987
|
+
params = putParam(params, "label", opts.label);
|
|
1988
|
+
params = putParam(params, "scope", opts.scope);
|
|
1989
|
+
params = putParam(params, "assignee", opts.assignee);
|
|
1990
|
+
params = putParam(params, "created_by", opts.createdBy);
|
|
1991
|
+
params = putParam(params, "involved", opts.involved);
|
|
1992
|
+
if (opts.type !== void 0) params = putParam(params, "label", `type/${opts.type}`);
|
|
1993
|
+
if (opts.status !== void 0) {
|
|
1994
|
+
const stateTypes = Object.hasOwn(STATUS_STATE_TYPES, opts.status) ? STATUS_STATE_TYPES[opts.status] : void 0;
|
|
1995
|
+
if (stateTypes === void 0) {
|
|
1996
|
+
throw new UsageError(`status must be one of: ${Object.keys(STATUS_STATE_TYPES).join(", ")}`);
|
|
1997
|
+
}
|
|
1998
|
+
params = putParam(params, "state_type", stateTypes);
|
|
1999
|
+
}
|
|
2000
|
+
const priority = optionalPriority(opts.priority);
|
|
2001
|
+
if (priority !== null) params = putParam(params, "priority", priority);
|
|
2002
|
+
return params;
|
|
2003
|
+
}
|
|
2004
|
+
function optionalPriority(value) {
|
|
2005
|
+
if (value === void 0) return null;
|
|
2006
|
+
const named = Object.hasOwn(PRIORITY_NAMES, value) ? PRIORITY_NAMES[value] : void 0;
|
|
2007
|
+
if (named !== void 0) return named;
|
|
2008
|
+
if (/^\d+$/.test(value)) {
|
|
2009
|
+
throw new UsageError(
|
|
2010
|
+
`Bare numeric priorities are refused: bd and harness disagree on what
|
|
2011
|
+
the numbers mean, so a habit-typed number would silently file the
|
|
2012
|
+
wrong priority.
|
|
2013
|
+
|
|
2014
|
+
bd: 0=critical 1=high 2=medium 3=normal 4=backlog
|
|
2015
|
+
harness: 0=none 1=urgent 2=high 3=medium 4=low
|
|
2016
|
+
|
|
2017
|
+
Use a name instead: --priority urgent|high|medium|low|none
|
|
2018
|
+
`
|
|
2019
|
+
);
|
|
2020
|
+
}
|
|
2021
|
+
throw new UsageError("priority must be one of: urgent, high, medium, low, none");
|
|
2022
|
+
}
|
|
2023
|
+
function optionalDueDate(value) {
|
|
2024
|
+
if (value === void 0) return null;
|
|
2025
|
+
const trimmed = value.trim();
|
|
2026
|
+
if (trimmed === "") return "clear";
|
|
2027
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
|
2028
|
+
if (match === null) throw new UsageError(`not a date: ${trimmed} (expected YYYY-MM-DD)`);
|
|
2029
|
+
const [, year, month, day] = match.map(Number);
|
|
2030
|
+
const candidate = /* @__PURE__ */ new Date(0);
|
|
2031
|
+
candidate.setUTCFullYear(year, month - 1, day);
|
|
2032
|
+
const real = candidate.getUTCFullYear() === year && candidate.getUTCMonth() === month - 1 && candidate.getUTCDate() === day;
|
|
2033
|
+
if (!real) throw new UsageError(`not a date: ${trimmed} (no such day on the calendar)`);
|
|
2034
|
+
return trimmed;
|
|
2035
|
+
}
|
|
2036
|
+
var TEAM_KEY_FORMAT = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
2037
|
+
function requiredTeam(ctx, team) {
|
|
2038
|
+
const resolved = team ?? ctx.config.team ?? void 0;
|
|
2039
|
+
if (resolved === void 0) throw new UsageError("No team given. Pass --team <key> or set HARNESS_TEAM.");
|
|
2040
|
+
if (!TEAM_KEY_FORMAT.test(resolved)) throw new UsageError(`not a team key: ${resolved} (expected e.g. ALL)`);
|
|
2041
|
+
return resolved;
|
|
2042
|
+
}
|
|
2043
|
+
function withTeam(params, ctx, opts) {
|
|
2044
|
+
const team = opts.team ?? ctx.config.team ?? void 0;
|
|
2045
|
+
return team === void 0 ? params : { ...params, team };
|
|
2046
|
+
}
|
|
2047
|
+
function putParam(params, key, value) {
|
|
2048
|
+
return value === void 0 ? params : { ...params, [key]: value };
|
|
2049
|
+
}
|
|
2050
|
+
function putOpt(body, field3, value) {
|
|
2051
|
+
return value === void 0 ? body : { ...body, [field3]: value };
|
|
2052
|
+
}
|
|
2053
|
+
function putPresent(body, key, value) {
|
|
2054
|
+
if (value === null || value === void 0 || value === false) return body;
|
|
2055
|
+
return { ...body, [key]: value };
|
|
2056
|
+
}
|
|
2057
|
+
function putDueDate(body, dueDate) {
|
|
2058
|
+
if (dueDate === null) return body;
|
|
2059
|
+
if (dueDate === "clear") return { ...body, due_date: null };
|
|
2060
|
+
return { ...body, due_date: dueDate };
|
|
2061
|
+
}
|
|
2062
|
+
function putAssignee(body, assignee) {
|
|
2063
|
+
if (assignee === void 0) return body;
|
|
2064
|
+
return { ...body, assignee: assignee.trim() === "" ? null : assignee };
|
|
2065
|
+
}
|
|
2066
|
+
function errorText(error) {
|
|
2067
|
+
if (error instanceof HttpError) return `${clean(error.message)} (HTTP ${error.status})`;
|
|
2068
|
+
return `cannot reach the API: ${clean(error.message)}`;
|
|
2069
|
+
}
|
|
2070
|
+
function str2(value) {
|
|
2071
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// src/commands/extended.ts
|
|
2075
|
+
var SAFE_ID = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
|
|
2076
|
+
function requireSafeId(kind, id) {
|
|
2077
|
+
if (!SAFE_ID.test(id)) throw new UsageError(`not a ${kind} id: ${id}`);
|
|
2078
|
+
}
|
|
2079
|
+
function str3(value) {
|
|
2080
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2081
|
+
}
|
|
2082
|
+
function field(row, ...keys) {
|
|
2083
|
+
let current = row;
|
|
2084
|
+
for (const key of keys) {
|
|
2085
|
+
if (typeof current !== "object" || current === null) return null;
|
|
2086
|
+
current = current[key];
|
|
2087
|
+
}
|
|
2088
|
+
return current ?? null;
|
|
2089
|
+
}
|
|
2090
|
+
function putOpt2(body, key, value) {
|
|
2091
|
+
return value === void 0 ? body : { ...body, [key]: value };
|
|
2092
|
+
}
|
|
2093
|
+
function putDate(body, key, raw) {
|
|
2094
|
+
const parsed = optionalDueDate(raw);
|
|
2095
|
+
if (parsed === null) return body;
|
|
2096
|
+
return { ...body, [key]: parsed === "clear" ? null : parsed };
|
|
2097
|
+
}
|
|
2098
|
+
async function paginatedRead(ctx, path2, params, opts) {
|
|
2099
|
+
if (opts.all === true && opts.page !== void 0) {
|
|
2100
|
+
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
2101
|
+
}
|
|
2102
|
+
if (opts.all === true) {
|
|
2103
|
+
const records5 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
|
|
2104
|
+
return { records: records5, notice: "" };
|
|
2105
|
+
}
|
|
2106
|
+
const body = await ctx.client.get(path2, params);
|
|
2107
|
+
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
2108
|
+
return { records: body.data, notice: notices(body.meta, requested, body.data) };
|
|
2109
|
+
}
|
|
2110
|
+
async function projectList(ctx, opts) {
|
|
2111
|
+
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
2112
|
+
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
2113
|
+
if (opts.state !== void 0) params = { ...params, state: opts.state };
|
|
2114
|
+
const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
|
|
2115
|
+
const human = records5.length === 0 ? "No projects.\n" : table(
|
|
2116
|
+
["ID", "STATE", "LEAD", "TARGET", "NAME"],
|
|
2117
|
+
records5.map((project) => [
|
|
2118
|
+
str3(project["id"]),
|
|
2119
|
+
str3(project["state"]) || "-",
|
|
2120
|
+
str3(field(project, "lead", "name")) || "-",
|
|
2121
|
+
str3(project["target_date"]) || "-",
|
|
2122
|
+
str3(project["name"])
|
|
2123
|
+
])
|
|
2124
|
+
);
|
|
2125
|
+
return { json: records5, human, notice };
|
|
2126
|
+
}
|
|
2127
|
+
async function projectShow(ctx, id) {
|
|
2128
|
+
requireSafeId("project", id);
|
|
2129
|
+
const body = await ctx.client.get(`/api/v1/projects/${id}`);
|
|
2130
|
+
const project = body.data;
|
|
2131
|
+
const fields = [
|
|
2132
|
+
["State", project["state"]],
|
|
2133
|
+
["Lead", field(project, "lead", "name")],
|
|
2134
|
+
["Start", project["start_date"]],
|
|
2135
|
+
["Target", project["target_date"]],
|
|
2136
|
+
["Updated", project["updated_at"]]
|
|
2137
|
+
];
|
|
2138
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
2139
|
+
`).join("");
|
|
2140
|
+
const sections = [
|
|
2141
|
+
["Summary", project["summary"]],
|
|
2142
|
+
["Description", project["description"]]
|
|
2143
|
+
];
|
|
2144
|
+
const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
|
|
2145
|
+
${title}:
|
|
2146
|
+
${indent2(String(text))}
|
|
2147
|
+
`).join("");
|
|
2148
|
+
const milestones = Array.isArray(project["milestones"]) ? project["milestones"] : [];
|
|
2149
|
+
const milestoneText = milestones.length === 0 ? "" : "\nMilestones:\n" + milestones.map((milestone) => ` ${(str3(milestone["target_date"]) || "-").padEnd(12)}${clean(milestone["name"])}
|
|
2150
|
+
`).join("");
|
|
2151
|
+
const human = `${str3(project["id"])} \xB7 ${clean(project["name"])}
|
|
2152
|
+
` + fieldLines + sectionText + milestoneText;
|
|
2153
|
+
return { json: project, human };
|
|
2154
|
+
}
|
|
2155
|
+
function projectBody(opts) {
|
|
2156
|
+
let body = {};
|
|
2157
|
+
body = putOpt2(body, "name", opts.name);
|
|
2158
|
+
body = putOpt2(body, "description", opts.description);
|
|
2159
|
+
body = putOpt2(body, "summary", opts.summary);
|
|
2160
|
+
body = putOpt2(body, "state", opts.state);
|
|
2161
|
+
body = putOpt2(body, "lead_id", opts.lead);
|
|
2162
|
+
body = putDate(body, "start_date", opts.startDate);
|
|
2163
|
+
body = putDate(body, "target_date", opts.targetDate);
|
|
2164
|
+
return body;
|
|
2165
|
+
}
|
|
2166
|
+
async function projectCreate(ctx, opts) {
|
|
2167
|
+
if (opts.name === void 0) throw new UsageError("usage: harness project create --name <name> \u2026");
|
|
2168
|
+
const body = await ctx.client.post("/api/v1/projects", projectBody(opts));
|
|
2169
|
+
const project = body.data;
|
|
2170
|
+
return { json: project, human: `Created project ${clean(project["name"])} (${str3(project["id"])})
|
|
2171
|
+
` };
|
|
2172
|
+
}
|
|
2173
|
+
async function projectUpdate(ctx, id, opts) {
|
|
2174
|
+
requireSafeId("project", id);
|
|
2175
|
+
const patch = projectBody(opts);
|
|
2176
|
+
if (Object.keys(patch).length === 0) {
|
|
2177
|
+
throw new UsageError("usage: harness project update <id> --name \u2026 | --state \u2026 | \u2026");
|
|
2178
|
+
}
|
|
2179
|
+
const body = await ctx.client.patch(`/api/v1/projects/${id}`, patch);
|
|
2180
|
+
return { json: body.data, human: `Updated project ${str3(body.data["id"])}
|
|
2181
|
+
` };
|
|
2182
|
+
}
|
|
2183
|
+
async function milestoneList(ctx, project) {
|
|
2184
|
+
if (project === void 0) throw new UsageError("usage: harness milestone list --project <id>");
|
|
2185
|
+
requireSafeId("project", project);
|
|
2186
|
+
const body = await ctx.client.get(`/api/v1/projects/${project}/milestones`);
|
|
2187
|
+
const human = body.data.length === 0 ? "No milestones.\n" : table(
|
|
2188
|
+
["ID", "TARGET", "NAME"],
|
|
2189
|
+
body.data.map((milestone) => [
|
|
2190
|
+
str3(milestone["id"]),
|
|
2191
|
+
str3(milestone["target_date"]) || "-",
|
|
2192
|
+
str3(milestone["name"])
|
|
2193
|
+
])
|
|
2194
|
+
);
|
|
2195
|
+
return { json: body.data, human };
|
|
2196
|
+
}
|
|
2197
|
+
async function milestoneCreate(ctx, opts) {
|
|
2198
|
+
if (opts.project === void 0 || opts.name === void 0) {
|
|
2199
|
+
throw new UsageError("usage: harness milestone create --project <id> --name <name> [--description TEXT] [--target-date YYYY-MM-DD]");
|
|
2200
|
+
}
|
|
2201
|
+
requireSafeId("project", opts.project);
|
|
2202
|
+
let body = { name: opts.name };
|
|
2203
|
+
body = putOpt2(body, "description", opts.description);
|
|
2204
|
+
body = putDate(body, "target_date", opts.targetDate);
|
|
2205
|
+
const response = await ctx.client.post(`/api/v1/projects/${opts.project}/milestones`, body);
|
|
2206
|
+
const milestone = response.data;
|
|
2207
|
+
return {
|
|
2208
|
+
json: milestone,
|
|
2209
|
+
human: `Created milestone ${clean(milestone["name"])} (${str3(milestone["id"])})
|
|
2210
|
+
`
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
async function docList(ctx, opts) {
|
|
2214
|
+
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
2215
|
+
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
2216
|
+
const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
|
|
2217
|
+
const human = records5.length === 0 ? "No documents.\n" : table(
|
|
2218
|
+
["ID", "UPDATED", "TITLE"],
|
|
2219
|
+
records5.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
|
|
2220
|
+
);
|
|
2221
|
+
return { json: records5, human, notice };
|
|
2222
|
+
}
|
|
2223
|
+
async function docShow(ctx, id) {
|
|
2224
|
+
requireSafeId("document", id);
|
|
2225
|
+
const body = await ctx.client.get(`/api/v1/documents/${id}`);
|
|
2226
|
+
const doc = body.data;
|
|
2227
|
+
const content = doc["content"];
|
|
2228
|
+
const human = `${str3(doc["id"])} \xB7 ${clean(doc["title"])}
|
|
2229
|
+
Updated: ${clean(doc["updated_at"])}
|
|
2230
|
+
` + (content === null || content === void 0 || content === "" ? "" : "\n" + cleanMultiline(String(content)) + "\n");
|
|
2231
|
+
return { json: doc, human };
|
|
2232
|
+
}
|
|
2233
|
+
async function docCreate(ctx, opts) {
|
|
2234
|
+
if (opts.title === void 0) throw new UsageError("usage: harness doc create --title <title> [--content TEXT] [--project ID]");
|
|
2235
|
+
let body = { title: opts.title };
|
|
2236
|
+
body = putOpt2(body, "content", opts.content);
|
|
2237
|
+
body = putOpt2(body, "project_id", opts.project);
|
|
2238
|
+
const response = await ctx.client.post("/api/v1/documents", body);
|
|
2239
|
+
const doc = response.data;
|
|
2240
|
+
return { json: doc, human: `Created document ${clean(doc["title"])} (${str3(doc["id"])})
|
|
2241
|
+
` };
|
|
2242
|
+
}
|
|
2243
|
+
async function docUpdate(ctx, id, opts) {
|
|
2244
|
+
requireSafeId("document", id);
|
|
2245
|
+
let patch = {};
|
|
2246
|
+
patch = putOpt2(patch, "title", opts.title);
|
|
2247
|
+
patch = putOpt2(patch, "content", opts.content);
|
|
2248
|
+
patch = putOpt2(patch, "project_id", opts.project);
|
|
2249
|
+
if (Object.keys(patch).length === 0) {
|
|
2250
|
+
throw new UsageError("usage: harness doc update <id> --title \u2026 | --content \u2026 | --project \u2026");
|
|
2251
|
+
}
|
|
2252
|
+
const response = await ctx.client.patch(`/api/v1/documents/${id}`, patch);
|
|
2253
|
+
return { json: response.data, human: `Updated document ${str3(response.data["id"])}
|
|
2254
|
+
` };
|
|
2255
|
+
}
|
|
2256
|
+
async function linkList(ctx, issue) {
|
|
2257
|
+
requireIdentifiers([issue]);
|
|
2258
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
|
|
2259
|
+
const human = body.data.length === 0 ? "No links.\n" : table(
|
|
2260
|
+
["ID", "URL", "TITLE"],
|
|
2261
|
+
body.data.map((link) => [str3(link["id"]), str3(link["url"]), str3(link["title"]) || "-"])
|
|
2262
|
+
);
|
|
2263
|
+
return { json: body.data, human };
|
|
2264
|
+
}
|
|
2265
|
+
async function linkAdd(ctx, issue, url, title) {
|
|
2266
|
+
requireIdentifiers([issue]);
|
|
2267
|
+
let body = { url };
|
|
2268
|
+
body = putOpt2(body, "title", title);
|
|
2269
|
+
const response = await ctx.client.post(`/api/v1/issues/${issue}/links`, body);
|
|
2270
|
+
return { json: response.data, human: `Linked ${clean(url)} to ${issue}
|
|
2271
|
+
` };
|
|
2272
|
+
}
|
|
2273
|
+
async function findLink(ctx, issue, idOrUrl, allowDuplicateUrl = false) {
|
|
2274
|
+
const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
|
|
2275
|
+
const idMatch = body.data.find((candidate) => candidate["id"] === idOrUrl);
|
|
2276
|
+
if (idMatch !== void 0) return idMatch;
|
|
2277
|
+
const urlMatches = body.data.filter((candidate) => candidate["url"] === idOrUrl);
|
|
2278
|
+
if (urlMatches.length > 1 && !allowDuplicateUrl) {
|
|
2279
|
+
throw new CommandError(`multiple links on ${issue} match ${idOrUrl}; use a link id`);
|
|
2280
|
+
}
|
|
2281
|
+
if (urlMatches.length === 0) {
|
|
2282
|
+
throw new CommandError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
|
|
2283
|
+
}
|
|
2284
|
+
return urlMatches[0];
|
|
2285
|
+
}
|
|
2286
|
+
async function linkUpdate(ctx, issue, idOrUrl, opts) {
|
|
2287
|
+
requireIdentifiers([issue]);
|
|
2288
|
+
let patch = {};
|
|
2289
|
+
patch = putOpt2(patch, "url", opts.url);
|
|
2290
|
+
patch = putOpt2(patch, "title", opts.title);
|
|
2291
|
+
if (Object.keys(patch).length === 0) {
|
|
2292
|
+
throw new UsageError("usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>");
|
|
2293
|
+
}
|
|
2294
|
+
const id = isUuid(idOrUrl) ? idOrUrl.toLowerCase() : str3((await findLink(ctx, issue, idOrUrl))["id"]);
|
|
2295
|
+
if (!isUuid(idOrUrl)) patch["expected_url"] = idOrUrl;
|
|
2296
|
+
const response = await ctx.client.patch(`/api/v1/issues/${issue}/links/${id}`, patch);
|
|
2297
|
+
return { json: response.data, human: `Updated link ${clean(response.data["url"])} on ${issue}
|
|
2298
|
+
` };
|
|
2299
|
+
}
|
|
2300
|
+
async function linkRm(ctx, issue, idOrUrl) {
|
|
2301
|
+
requireIdentifiers([issue]);
|
|
2302
|
+
const link = await findLink(ctx, issue, idOrUrl, true);
|
|
2303
|
+
await ctx.client.delete(`/api/v1/issues/${issue}/links/${str3(link["id"])}`);
|
|
2304
|
+
return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
|
|
2305
|
+
` };
|
|
2306
|
+
}
|
|
2307
|
+
async function labelList2(ctx) {
|
|
2308
|
+
const body = await ctx.client.get("/api/v1/labels");
|
|
2309
|
+
const human = body.data.length === 0 ? "No labels.\n" : table(
|
|
2310
|
+
["NAME", "COLOR", "ISSUES", "ID"],
|
|
2311
|
+
body.data.map((label) => [
|
|
2312
|
+
str3(label["name"]),
|
|
2313
|
+
str3(label["color"]) || "-",
|
|
2314
|
+
str3(label["issue_count"] ?? 0),
|
|
2315
|
+
str3(label["id"])
|
|
2316
|
+
])
|
|
2317
|
+
);
|
|
2318
|
+
return { json: body.data, human };
|
|
2319
|
+
}
|
|
2320
|
+
async function labelCreate(ctx, name, color) {
|
|
2321
|
+
if (name === void 0) throw new UsageError("usage: harness label create <name> [--color <color>]");
|
|
2322
|
+
let body = { name };
|
|
2323
|
+
body = putOpt2(body, "color", color);
|
|
2324
|
+
const response = await ctx.client.post("/api/v1/labels", body);
|
|
2325
|
+
return { json: response.data, human: `Created label ${clean(response.data["name"])}
|
|
2326
|
+
` };
|
|
2327
|
+
}
|
|
2328
|
+
var TEAM_CREATE_KEY = /^[A-Za-z][A-Za-z0-9]*$/;
|
|
2329
|
+
async function teamCreate(ctx, opts) {
|
|
2330
|
+
if (opts.key === void 0 || opts.name === void 0) {
|
|
2331
|
+
throw new UsageError("usage: harness team create --key <key> --name <name>");
|
|
2332
|
+
}
|
|
2333
|
+
if (!TEAM_CREATE_KEY.test(opts.key)) {
|
|
2334
|
+
throw new UsageError(`not a team key: ${opts.key} (expected e.g. ALL \u2014 letters and digits, letter first)`);
|
|
2335
|
+
}
|
|
2336
|
+
const created = await ctx.client.post("/api/v1/teams", { key: opts.key, name: opts.name });
|
|
2337
|
+
const team = created.data;
|
|
2338
|
+
const states = await ctx.client.get(`/api/v1/teams/${str3(team["key"])}/workflow_states`);
|
|
2339
|
+
const human = `Created team ${str3(team["key"])} \u2014 ${clean(team["name"])}
|
|
2340
|
+
` + table(
|
|
2341
|
+
["NAME", "TYPE"],
|
|
2342
|
+
states.data.map((state) => [str3(state["name"]), str3(state["type"])])
|
|
2343
|
+
);
|
|
2344
|
+
return { json: { ...team, workflow_states: states.data }, human };
|
|
2345
|
+
}
|
|
2346
|
+
async function teamList(ctx) {
|
|
2347
|
+
const body = await ctx.client.get("/api/v1/teams");
|
|
2348
|
+
const human = body.data.length === 0 ? "No teams.\n" : table(
|
|
2349
|
+
["KEY", "NAME", "ISSUES", "DEFAULT"],
|
|
2350
|
+
body.data.map((team) => [
|
|
2351
|
+
str3(team["key"]),
|
|
2352
|
+
str3(team["name"]),
|
|
2353
|
+
str3(team["issue_count"] ?? 0),
|
|
2354
|
+
team["is_default"] === true ? "yes" : ""
|
|
2355
|
+
])
|
|
2356
|
+
);
|
|
2357
|
+
return { json: body.data, human };
|
|
2358
|
+
}
|
|
2359
|
+
async function workflowStates(ctx, team) {
|
|
2360
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, team);
|
|
2361
|
+
const body = await ctx.client.get(`/api/v1/teams/${key}/workflow_states`);
|
|
2362
|
+
const human = body.data.length === 0 ? "No workflow states.\n" : table(
|
|
2363
|
+
["NAME", "TYPE", "COLOR", "ID"],
|
|
2364
|
+
body.data.map((state) => [
|
|
2365
|
+
str3(state["name"]),
|
|
2366
|
+
str3(state["type"]),
|
|
2367
|
+
str3(state["color"]) || "-",
|
|
2368
|
+
str3(state["id"])
|
|
2369
|
+
])
|
|
2370
|
+
);
|
|
2371
|
+
return { json: body.data, human };
|
|
2372
|
+
}
|
|
2373
|
+
async function userList(ctx) {
|
|
2374
|
+
const body = await ctx.client.get("/api/v1/users");
|
|
2375
|
+
const human = body.data.length === 0 ? "No users.\n" : table(
|
|
2376
|
+
["NAME", "EMAIL", "ID"],
|
|
2377
|
+
body.data.map((user) => [str3(user["name"]), str3(user["email"]), str3(user["id"])])
|
|
2378
|
+
);
|
|
2379
|
+
return { json: body.data, human };
|
|
2380
|
+
}
|
|
2381
|
+
async function teamRename(ctx, key, name) {
|
|
2382
|
+
if (key === void 0 || name === void 0) {
|
|
2383
|
+
throw new UsageError("usage: harness team rename <key> --name <name>");
|
|
2384
|
+
}
|
|
2385
|
+
const body = await ctx.client.patch(`/api/v1/teams/${encodeURIComponent(key)}`, { name });
|
|
2386
|
+
return { json: body.data, human: `Renamed ${str3(body.data["key"])} to ${clean(body.data["name"])}
|
|
2387
|
+
` };
|
|
2388
|
+
}
|
|
2389
|
+
async function teamSetDefault(ctx, key) {
|
|
2390
|
+
if (key === void 0) throw new UsageError("usage: harness team set-default <key>");
|
|
2391
|
+
const body = await ctx.client.put(`/api/v1/teams/${encodeURIComponent(key)}/default`, {});
|
|
2392
|
+
return {
|
|
2393
|
+
json: body.data,
|
|
2394
|
+
human: `${str3(body.data["key"])} is now the default filing target
|
|
2395
|
+
`
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
async function stateAdd(ctx, opts) {
|
|
2399
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
2400
|
+
if (opts.name === void 0 || opts.type === void 0) {
|
|
2401
|
+
throw new UsageError("usage: harness state add --name <name> --type <type> [--color <color>] [--team <key>]");
|
|
2402
|
+
}
|
|
2403
|
+
let payload = { name: opts.name, type: opts.type };
|
|
2404
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
2405
|
+
const body = await ctx.client.post(`/api/v1/teams/${key}/workflow_states`, payload);
|
|
2406
|
+
return { json: body.data, human: `Added ${clean(body.data["name"])} to ${key}
|
|
2407
|
+
` };
|
|
2408
|
+
}
|
|
2409
|
+
async function stateUpdate(ctx, id, opts) {
|
|
2410
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
2411
|
+
if (id === void 0) throw new UsageError("usage: harness state update <id> [--name <name>] [--type <type>] [--color <color>]");
|
|
2412
|
+
if (opts.name === void 0 && opts.type === void 0 && opts.color === void 0) {
|
|
2413
|
+
throw new UsageError("nothing to change \u2014 pass at least one of --name, --type, --color");
|
|
2414
|
+
}
|
|
2415
|
+
let payload = {};
|
|
2416
|
+
payload = putOpt2(payload, "name", opts.name);
|
|
2417
|
+
payload = putOpt2(payload, "type", opts.type);
|
|
2418
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
2419
|
+
const body = await ctx.client.patch(`/api/v1/teams/${key}/workflow_states/${encodeURIComponent(id)}`, payload);
|
|
2420
|
+
return { json: body.data, human: `Updated ${clean(body.data["name"])}
|
|
2421
|
+
` };
|
|
2422
|
+
}
|
|
2423
|
+
async function stateOrder(ctx, ids, opts) {
|
|
2424
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
2425
|
+
if (ids === void 0) {
|
|
2426
|
+
throw new UsageError("usage: harness state order <id,id,id> [--team <key>] \u2014 every state id, in the order you want");
|
|
2427
|
+
}
|
|
2428
|
+
const state_ids = ids.split(",").map((id) => id.trim()).filter((id) => id !== "");
|
|
2429
|
+
if (state_ids.length === 0) throw new UsageError("no state ids given");
|
|
2430
|
+
const body = await ctx.client.put(`/api/v1/teams/${key}/workflow_states/order`, { state_ids });
|
|
2431
|
+
const human = table(
|
|
2432
|
+
["NAME", "TYPE"],
|
|
2433
|
+
body.data.map((state) => [str3(state["name"]), str3(state["type"])])
|
|
2434
|
+
);
|
|
2435
|
+
return { json: body.data, human };
|
|
2436
|
+
}
|
|
2437
|
+
async function stateDelete(ctx, id, opts) {
|
|
2438
|
+
const key = requiredTeam({ client: ctx.client, config: ctx.config }, opts.team);
|
|
2439
|
+
if (id === void 0) throw new UsageError("usage: harness state delete <id> --yes [--team <key>]");
|
|
2440
|
+
requireYes("state delete", id, opts.yes);
|
|
2441
|
+
await ctx.client.delete(`/api/v1/teams/${key}/workflow_states/${encodeURIComponent(id)}`);
|
|
2442
|
+
return { json: { deleted: id }, human: `Deleted workflow state ${id}
|
|
2443
|
+
` };
|
|
2444
|
+
}
|
|
2445
|
+
async function labelUpdate(ctx, id, opts) {
|
|
2446
|
+
if (id === void 0) throw new UsageError("usage: harness label update <id> [--name <name>] [--color <color>]");
|
|
2447
|
+
if (opts.name === void 0 && opts.color === void 0) {
|
|
2448
|
+
throw new UsageError("nothing to change \u2014 pass --name, --color, or both");
|
|
2449
|
+
}
|
|
2450
|
+
let payload = {};
|
|
2451
|
+
payload = putOpt2(payload, "name", opts.name);
|
|
2452
|
+
payload = putOpt2(payload, "color", opts.color);
|
|
2453
|
+
const body = await ctx.client.patch(`/api/v1/labels/${encodeURIComponent(id)}`, payload);
|
|
2454
|
+
return { json: body.data, human: `Updated label ${clean(body.data["name"])}
|
|
2455
|
+
` };
|
|
2456
|
+
}
|
|
2457
|
+
async function labelDelete(ctx, id, opts) {
|
|
2458
|
+
if (id === void 0) throw new UsageError("usage: harness label delete <id> --yes");
|
|
2459
|
+
requireYes("label delete", id, opts.yes);
|
|
2460
|
+
await ctx.client.delete(`/api/v1/labels/${encodeURIComponent(id)}`);
|
|
2461
|
+
return { json: { deleted: id }, human: `Deleted label ${id}
|
|
2462
|
+
` };
|
|
2463
|
+
}
|
|
2464
|
+
async function memberList(ctx) {
|
|
2465
|
+
const body = await ctx.client.get("/api/v1/members");
|
|
2466
|
+
const human = body.data.length === 0 ? "No members.\n" : table(
|
|
2467
|
+
["NAME", "EMAIL", "RANK", "STANDING", "ID"],
|
|
2468
|
+
body.data.map((member) => [
|
|
2469
|
+
str3(field(member, "user", "name")),
|
|
2470
|
+
str3(field(member, "user", "email")),
|
|
2471
|
+
str3(member["role"]) || "-",
|
|
2472
|
+
str3(member["standing"]),
|
|
2473
|
+
str3(field(member, "user", "id"))
|
|
2474
|
+
])
|
|
2475
|
+
);
|
|
2476
|
+
return { json: body.data, human };
|
|
2477
|
+
}
|
|
2478
|
+
async function memberAdd(ctx, opts) {
|
|
2479
|
+
if (opts.email === void 0 || opts.role === void 0) {
|
|
2480
|
+
throw new UsageError("usage: harness member add --email <email> --role <admin|staff|collaborator>");
|
|
2481
|
+
}
|
|
2482
|
+
const body = await ctx.client.post("/api/v1/members", { email: opts.email, role: opts.role });
|
|
2483
|
+
return {
|
|
2484
|
+
json: body.data,
|
|
2485
|
+
human: `Added ${str3(field(body.data, "user", "email"))} as ${str3(body.data["role"])}
|
|
2486
|
+
`
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
async function memberSetRole(ctx, userId, opts) {
|
|
2490
|
+
if (userId === void 0 || opts.role === void 0) {
|
|
2491
|
+
throw new UsageError("usage: harness member set-role <user-id> --role <admin|staff|collaborator|none>");
|
|
2492
|
+
}
|
|
2493
|
+
const body = await ctx.client.put(`/api/v1/members/${encodeURIComponent(userId)}/role`, { role: opts.role });
|
|
2494
|
+
const rank = str3(body.data["role"]);
|
|
2495
|
+
const human = rank === "" ? `Revoked the rank for ${str3(field(body.data, "user", "email"))}
|
|
2496
|
+
` : `${str3(field(body.data, "user", "email"))} is now ${rank}
|
|
2497
|
+
`;
|
|
2498
|
+
return { json: body.data, human };
|
|
2499
|
+
}
|
|
2500
|
+
function requireYes(command, id, yes) {
|
|
2501
|
+
if (yes !== true) throw new UsageError(`${command} ${id} is destructive \u2014 pass --yes to confirm`);
|
|
2502
|
+
}
|
|
2503
|
+
async function upload(ctx, path2) {
|
|
2504
|
+
return attachFile(ctx, path2);
|
|
2505
|
+
}
|
|
2506
|
+
function indent2(text) {
|
|
2507
|
+
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// src/commands/operator-catalog.ts
|
|
2511
|
+
var SCHEMA_VIEWS = ["input", "result"];
|
|
2512
|
+
var MAX_QUERY_BYTES = 200;
|
|
2513
|
+
var MAX_CATALOG_PAGES = 200;
|
|
2514
|
+
var MAX_SCHEMA_PAGES = 1e3;
|
|
2515
|
+
var SCHEMA_ENCODING = "json_fragments_v1";
|
|
2516
|
+
var CAPABILITY_ID_FORMAT = /^[A-Za-z0-9_.:-]{1,200}$/;
|
|
2517
|
+
var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a person's approval in the app before they take effect.\n";
|
|
2518
|
+
var REFUSALS = {
|
|
2519
|
+
operator_disabled: "nothing here works until it is switched back on",
|
|
2520
|
+
operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
|
|
2521
|
+
actor_required: "this credential acts as no person: sign in with `harness login`, or use an API token minted for a user",
|
|
2522
|
+
run_bound_credential: "a run-bound credential can't read Operator's catalog",
|
|
2523
|
+
forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
|
|
2524
|
+
};
|
|
2525
|
+
async function toolsList(ctx, opts) {
|
|
2526
|
+
requireToolsOpts(opts);
|
|
2527
|
+
const { items, meta } = await capabilityPages(ctx, {}, opts);
|
|
2528
|
+
return {
|
|
2529
|
+
json: items,
|
|
2530
|
+
human: items.length === 0 ? "Operator can use no capabilities here.\n" : capabilitiesTable(items, false),
|
|
2531
|
+
notice: pagingNotice(items, meta, opts, "capabilities")
|
|
2532
|
+
};
|
|
2533
|
+
}
|
|
2534
|
+
async function toolsSearch(ctx, query, opts) {
|
|
2535
|
+
requireSearchQuery(query);
|
|
2536
|
+
requireToolsOpts(opts);
|
|
2537
|
+
const { items, meta } = await capabilityPages(ctx, { q: query }, opts);
|
|
2538
|
+
return {
|
|
2539
|
+
json: items,
|
|
2540
|
+
human: items.length === 0 ? `No capability Operator can use matches "${clean(query)}".
|
|
2541
|
+
` : capabilitiesTable(items, true),
|
|
2542
|
+
notice: pagingNotice(items, meta, opts, "matches")
|
|
2543
|
+
};
|
|
2544
|
+
}
|
|
2545
|
+
async function toolsDomains(ctx) {
|
|
2546
|
+
const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/capabilities/domains"));
|
|
2547
|
+
const domains = records2(body.data, "domains");
|
|
2548
|
+
return {
|
|
2549
|
+
json: domains,
|
|
2550
|
+
human: domains.length === 0 ? "Operator can use no capabilities here.\n" : table(
|
|
2551
|
+
["DOMAIN", "CAPABILITIES"],
|
|
2552
|
+
domains.map((domain) => [str4(domain["domain"]), str4(domain["count"])])
|
|
2553
|
+
)
|
|
2554
|
+
};
|
|
2555
|
+
}
|
|
2556
|
+
async function toolShow(ctx, id, opts) {
|
|
2557
|
+
requireCapabilityId(id);
|
|
2558
|
+
const view = requireSchemaView(opts.schema);
|
|
2559
|
+
const fragments = [];
|
|
2560
|
+
let capability = {};
|
|
2561
|
+
let fragmentCount = null;
|
|
2562
|
+
for (let page = 1; ; page++) {
|
|
2563
|
+
const body = await operatorRequest(
|
|
2564
|
+
() => ctx.client.get(`/api/v1/operator/capabilities/${id}`, { schema: view, page }),
|
|
2565
|
+
`Operator can't use a capability ${id} here: it doesn't exist, or it is disabled or switched off`
|
|
2566
|
+
);
|
|
2567
|
+
const data = body.data;
|
|
2568
|
+
if (!isRecord(data)) throw new CommandError("The capability response carried no capability.");
|
|
2569
|
+
if (data["id"] !== id || data["schema"] !== view || data["page"] !== page) {
|
|
2570
|
+
throw new CommandError(
|
|
2571
|
+
`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).`
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
if (data["schema_encoding"] !== SCHEMA_ENCODING) {
|
|
2575
|
+
throw new CommandError(
|
|
2576
|
+
`The capability's schema comes in an encoding this CLI does not read (${clean(data["schema_encoding"])}) \u2014 upgrade the CLI.`
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
const count = data["fragment_count"];
|
|
2580
|
+
if (typeof count !== "number" || !Number.isInteger(count) || count < 1) {
|
|
2581
|
+
throw new CommandError(`The capability's schema page ${page} carries no valid fragment count.`);
|
|
2582
|
+
}
|
|
2583
|
+
if (fragmentCount !== null && count !== fragmentCount) {
|
|
2584
|
+
throw new CommandError(
|
|
2585
|
+
`The capability's schema pages disagree on the fragment count (${fragmentCount}, then ${count} on page ${page}).`
|
|
2586
|
+
);
|
|
2587
|
+
}
|
|
2588
|
+
fragmentCount = count;
|
|
2589
|
+
const pageFragments = data["fragments"];
|
|
2590
|
+
if (!Array.isArray(pageFragments) || !pageFragments.every((fragment) => typeof fragment === "string")) {
|
|
2591
|
+
throw new CommandError(`The capability's schema page ${page} carried no fragments.`);
|
|
2592
|
+
}
|
|
2593
|
+
fragments.push(...pageFragments);
|
|
2594
|
+
capability = data;
|
|
2595
|
+
const next = data["next_page"];
|
|
2596
|
+
if (next === null || next === void 0) break;
|
|
2597
|
+
if (next !== page + 1 || next > MAX_SCHEMA_PAGES) {
|
|
2598
|
+
throw new CommandError(`The capability's schema page ${page} names an invalid next page (${clean(next)}).`);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
if (fragments.length !== fragmentCount) {
|
|
2602
|
+
throw new CommandError(
|
|
2603
|
+
`The capability's schema came back incomplete: ${fragments.length} of ${fragmentCount} fragments.`
|
|
2604
|
+
);
|
|
2605
|
+
}
|
|
2606
|
+
let schema;
|
|
2607
|
+
try {
|
|
2608
|
+
schema = JSON.parse(fragments.join(""));
|
|
2609
|
+
} catch {
|
|
2610
|
+
throw new CommandError("The capability's schema fragments do not join into valid JSON.");
|
|
2611
|
+
}
|
|
2612
|
+
const shown = { ...withoutSchemaPaging(capability), [`${view}_schema`]: schema };
|
|
2613
|
+
return { json: shown, human: capabilityDetail(shown, view, schema) };
|
|
2614
|
+
}
|
|
2615
|
+
async function skillsList(ctx) {
|
|
2616
|
+
const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/skills"));
|
|
2617
|
+
const skills = records2(body.data, "skills").map((skill) => ({
|
|
2618
|
+
id: skill["id"],
|
|
2619
|
+
title: skill["title"],
|
|
2620
|
+
invocation_name: skill["invocation_name"],
|
|
2621
|
+
content_sha256: skill["content_sha256"]
|
|
2622
|
+
}));
|
|
2623
|
+
return {
|
|
2624
|
+
json: skills,
|
|
2625
|
+
human: skills.length === 0 ? "No skills are assigned to Operator in this organization.\n" : table(
|
|
2626
|
+
["INVOCATION", "TITLE", "SHA-256", "ID"],
|
|
2627
|
+
skills.map((skill) => [
|
|
2628
|
+
str4(skill.invocation_name),
|
|
2629
|
+
truncate(skill.title),
|
|
2630
|
+
str4(skill.content_sha256),
|
|
2631
|
+
str4(skill.id)
|
|
2632
|
+
])
|
|
2633
|
+
)
|
|
2634
|
+
};
|
|
2635
|
+
}
|
|
2636
|
+
function requireCapabilityId(id) {
|
|
2637
|
+
if (!CAPABILITY_ID_FORMAT.test(id) || /^\.+$/.test(id)) {
|
|
2638
|
+
throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
|
|
2639
|
+
}
|
|
2640
|
+
if (id === "domains") {
|
|
2641
|
+
throw new UsageError("domains is not a capability id \u2014 `harness tools domains` lists the domains");
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
function requireSearchQuery(query) {
|
|
2645
|
+
if (query.trim() === "") throw new UsageError("the search query is empty \u2014 say what to search for");
|
|
2646
|
+
if (Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
|
|
2647
|
+
throw new UsageError(`the search query is over ${MAX_QUERY_BYTES} bytes \u2014 shorten it`);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
function requireToolsOpts(opts) {
|
|
2651
|
+
if (opts.domain !== void 0 && opts.domain.trim() === "") {
|
|
2652
|
+
throw new UsageError("the domain is empty \u2014 name one, or leave it out for every domain");
|
|
2653
|
+
}
|
|
2654
|
+
if (opts.cursor !== void 0 && opts.cursor.trim() === "") {
|
|
2655
|
+
throw new UsageError("the cursor is empty \u2014 pass a page's cursor, or leave it out for the first page");
|
|
2656
|
+
}
|
|
2657
|
+
}
|
|
2658
|
+
function requireSchemaView(view) {
|
|
2659
|
+
if (view === void 0) return "input";
|
|
2660
|
+
if (SCHEMA_VIEWS.includes(view)) return view;
|
|
2661
|
+
throw new UsageError(`--schema must be input or result, not ${clean(view)}`);
|
|
2662
|
+
}
|
|
2663
|
+
async function capabilityPages(ctx, base, opts) {
|
|
2664
|
+
const params = { ...base, domain: opts.domain };
|
|
2665
|
+
let page = await capabilityPage(ctx, { ...params, cursor: opts.cursor }, CALLER_CURSOR);
|
|
2666
|
+
const items = [...page.items];
|
|
2667
|
+
if (opts.all === true) {
|
|
2668
|
+
const seen = new Set(opts.cursor === void 0 ? [] : [opts.cursor]);
|
|
2669
|
+
let pages = 1;
|
|
2670
|
+
while (page.nextCursor !== null) {
|
|
2671
|
+
if (seen.has(page.nextCursor)) throw new CommandError("The capabilities response repeated a cursor.");
|
|
2672
|
+
if (pages >= MAX_CATALOG_PAGES) {
|
|
2673
|
+
throw new CommandError(`The capabilities listing ran past ${MAX_CATALOG_PAGES} pages; stopped there.`);
|
|
2674
|
+
}
|
|
2675
|
+
seen.add(page.nextCursor);
|
|
2676
|
+
page = await capabilityPage(ctx, { ...params, cursor: page.nextCursor }, WALK_CURSOR);
|
|
2677
|
+
pages++;
|
|
2678
|
+
items.push(...page.items);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
return { items: dedupeById(items), meta: page.meta };
|
|
2682
|
+
}
|
|
2683
|
+
var CALLER_CURSOR = "start again without the cursor";
|
|
2684
|
+
var WALK_CURSOR = "the listing changed or its cursor expired while following pages; run it again";
|
|
2685
|
+
async function capabilityPage(ctx, params, staleCursor) {
|
|
2686
|
+
const body = await operatorRequest(
|
|
2687
|
+
() => ctx.client.get("/api/v1/operator/capabilities", params),
|
|
2688
|
+
void 0,
|
|
2689
|
+
staleCursor
|
|
2690
|
+
);
|
|
2691
|
+
const items = records2(body.data, "capabilities");
|
|
2692
|
+
const meta = body.meta;
|
|
2693
|
+
if (!isRecord(meta)) throw new CommandError("The capabilities response has no pagination metadata.");
|
|
2694
|
+
const nextCursor = meta.next_cursor;
|
|
2695
|
+
if (nextCursor !== null && (typeof nextCursor !== "string" || nextCursor === "")) {
|
|
2696
|
+
throw new CommandError("The capabilities response has an invalid next_cursor.");
|
|
2697
|
+
}
|
|
2698
|
+
if (nextCursor !== null && items.length === 0) {
|
|
2699
|
+
throw new CommandError("The capabilities response says there are more pages but holds no capabilities.");
|
|
2700
|
+
}
|
|
2701
|
+
return { items, meta, nextCursor };
|
|
2702
|
+
}
|
|
2703
|
+
async function operatorRequest(send, notFound2 = "this Harness server has no Operator API yet", staleCursor = CALLER_CURSOR) {
|
|
2704
|
+
try {
|
|
2705
|
+
return await send();
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
throw explain(error, notFound2, staleCursor);
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
function explain(error, notFound2, staleCursor) {
|
|
2711
|
+
if (!(error instanceof HttpError)) return error;
|
|
2712
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
2713
|
+
let hint;
|
|
2714
|
+
if (error.status === 403) hint = Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : void 0;
|
|
2715
|
+
else if (error.status === 404) hint = notFound2;
|
|
2716
|
+
else if (error.status === 400 && code === "invalid_cursor") hint = staleCursor;
|
|
2717
|
+
else if (error.status === 400 && /\bdomain\b/.test(error.message)) {
|
|
2718
|
+
hint = "no such domain; listing without one shows each capability's domain";
|
|
2719
|
+
}
|
|
2720
|
+
if (hint === void 0) return error;
|
|
2721
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
2722
|
+
}
|
|
2723
|
+
function capabilitiesTable(items, search2) {
|
|
2724
|
+
const headers = ["ID", "KIND", "DOMAIN", "SUMMARY"];
|
|
2725
|
+
const rows = items.map((item) => [
|
|
2726
|
+
str4(item["id"]),
|
|
2727
|
+
kindName(item),
|
|
2728
|
+
str4(item["domain"]),
|
|
2729
|
+
truncate(item["summary"])
|
|
2730
|
+
]);
|
|
2731
|
+
const table2 = search2 ? table(
|
|
2732
|
+
["SCORE", ...headers],
|
|
2733
|
+
rows.map((row, index) => [str4(items[index]["score"]), ...row])
|
|
2734
|
+
) : table(headers, rows);
|
|
2735
|
+
return table2 + (items.some(needsApproval) ? "\n" + APPROVAL_NOTE : "");
|
|
2736
|
+
}
|
|
2737
|
+
function pagingNotice(items, meta, opts, noun) {
|
|
2738
|
+
if (opts.all === true || typeof meta.next_cursor !== "string") return void 0;
|
|
2739
|
+
const of = typeof meta.count === "number" ? ` of ${meta.count}` : "";
|
|
2740
|
+
return `harness: showing ${items.length}${of} ${noun} \u2014 pass --all for every page, or --cursor ${clean(meta.next_cursor)} for the next one.
|
|
2741
|
+
`;
|
|
2742
|
+
}
|
|
2743
|
+
function capabilityDetail(capability, view, schema) {
|
|
2744
|
+
const kind = kindName(capability);
|
|
2745
|
+
const line = (label, value) => ` ${(label + ":").padEnd(10)}${clean(value)}
|
|
2746
|
+
`;
|
|
2747
|
+
const description = cleanMultiline(str4(capability["description"]));
|
|
2748
|
+
return `${clean(capability["id"])}
|
|
2749
|
+
` + line("Domain", str4(capability["domain"])) + line("Kind", kind === "send" ? "send \u2014 delivered beyond the platform" : kind) + line("Mode", `${str4(capability["mode"])} (effect: ${str4(capability["effect"])})`) + line("Summary", str4(capability["summary"])) + line("Approval", approval(capability)) + (description === "" ? "" : `
|
|
2750
|
+
Description:
|
|
2751
|
+
${indent3(description)}
|
|
2752
|
+
`) + `
|
|
2753
|
+
${view === "input" ? "Input" : "Result"} schema:
|
|
2754
|
+
${indent3(cleanMultiline(JSON.stringify(schema, null, 2)))}
|
|
2755
|
+
`;
|
|
2756
|
+
}
|
|
2757
|
+
function needsApproval(capability) {
|
|
2758
|
+
return capability["kind"] === "write" || capability["kind"] === "send" || capability["mode"] === "proposal";
|
|
2759
|
+
}
|
|
2760
|
+
function approval(capability) {
|
|
2761
|
+
if (needsApproval(capability)) return "a person approves it in the app before it takes effect";
|
|
2762
|
+
if (capability["kind"] === "read") return "not needed for a read";
|
|
2763
|
+
const kind = kindName(capability);
|
|
2764
|
+
return kind === "unknown" ? "unknown: the server named no kind" : `unknown for a capability of kind ${kind}`;
|
|
2765
|
+
}
|
|
2766
|
+
function kindName(capability) {
|
|
2767
|
+
const kind = capability["kind"];
|
|
2768
|
+
return typeof kind === "string" && kind.trim() !== "" ? kind : "unknown";
|
|
2769
|
+
}
|
|
2770
|
+
var SCHEMA_PAGING_FIELDS = /* @__PURE__ */ new Set(["schema", "schema_encoding", "fragments", "fragment_count", "page", "next_page"]);
|
|
2771
|
+
function withoutSchemaPaging(capability) {
|
|
2772
|
+
return Object.fromEntries(Object.entries(capability).filter(([key]) => !SCHEMA_PAGING_FIELDS.has(key)));
|
|
2773
|
+
}
|
|
2774
|
+
function records2(value, what) {
|
|
2775
|
+
if (!Array.isArray(value) || !value.every(isRecord)) {
|
|
2776
|
+
throw new CommandError(`The ${what} response carried no list.`);
|
|
2777
|
+
}
|
|
2778
|
+
return value;
|
|
2779
|
+
}
|
|
2780
|
+
function dedupeById(items) {
|
|
2781
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2782
|
+
return items.filter((item) => {
|
|
2783
|
+
if (seen.has(item["id"])) return false;
|
|
2784
|
+
seen.add(item["id"]);
|
|
2785
|
+
return true;
|
|
2786
|
+
});
|
|
2787
|
+
}
|
|
2788
|
+
function indent3(text) {
|
|
2789
|
+
return text.split("\n").map((line) => " " + line).join("\n");
|
|
2790
|
+
}
|
|
2791
|
+
function isRecord(value) {
|
|
2792
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2793
|
+
}
|
|
2794
|
+
function str4(value) {
|
|
2795
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
// src/commands/operator-jobs.ts
|
|
2799
|
+
var JOB_STATES = [
|
|
2800
|
+
"draft",
|
|
2801
|
+
"planning",
|
|
2802
|
+
"ready",
|
|
2803
|
+
"running",
|
|
2804
|
+
"waiting_for_approval",
|
|
2805
|
+
"waiting_for_human",
|
|
2806
|
+
"paused",
|
|
2807
|
+
"failed",
|
|
2808
|
+
"needs_reconciliation",
|
|
2809
|
+
"completed",
|
|
2810
|
+
"cancelled"
|
|
2811
|
+
];
|
|
2812
|
+
var JOB_STATUS_ALIASES = ["open", "waiting", "done"];
|
|
2813
|
+
var JOBS_MAX_LIMIT = 50;
|
|
2814
|
+
var JOBS_MAX_PAGES = 100;
|
|
2815
|
+
var EVENTS_PAGE_LIMIT = 200;
|
|
2816
|
+
var WATCH_INTERVAL_MS = 2e3;
|
|
2817
|
+
var WATCH_WAITING_INTERVAL_MS = 1e4;
|
|
2818
|
+
var WATCH_RETRIES = 3;
|
|
2819
|
+
var RETRY_BASE_MS = 1e3;
|
|
2820
|
+
var RETRY_AFTER_CAP_MS = 3e5;
|
|
2821
|
+
var EXIT_WAITING_FOR_PERSON = 4;
|
|
2822
|
+
var EXIT_JOB_ENDED = 5;
|
|
2823
|
+
var EXIT_INTERRUPTED = 130;
|
|
2824
|
+
var INTERRUPTED = "interrupted";
|
|
2825
|
+
var READER_GONE = "reader-gone";
|
|
2826
|
+
var ENDED_STATES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
|
|
2827
|
+
var CHANGE_SET_FIELDS = [
|
|
2828
|
+
"manifest_id",
|
|
2829
|
+
"version",
|
|
2830
|
+
"digest",
|
|
2831
|
+
"status",
|
|
2832
|
+
"plan_version",
|
|
2833
|
+
"max_effect_count",
|
|
2834
|
+
"actions"
|
|
2835
|
+
];
|
|
2836
|
+
var CHANGE_SET_ACTION_FIELDS = [
|
|
2837
|
+
"id",
|
|
2838
|
+
"action_type",
|
|
2839
|
+
"action_version",
|
|
2840
|
+
"display_order",
|
|
2841
|
+
"effect_count",
|
|
2842
|
+
"required_capability_ids",
|
|
2843
|
+
"digest"
|
|
2844
|
+
];
|
|
2845
|
+
var WITHHELD_FIELDS = /* @__PURE__ */ new Set([
|
|
2846
|
+
"objective",
|
|
2847
|
+
"objective_text",
|
|
2848
|
+
"instruction",
|
|
2849
|
+
"instructions",
|
|
2850
|
+
"description",
|
|
2851
|
+
"result",
|
|
2852
|
+
"results",
|
|
2853
|
+
"outputs",
|
|
2854
|
+
"investigation",
|
|
2855
|
+
"read_material",
|
|
2856
|
+
"public_research",
|
|
2857
|
+
"source_review",
|
|
2858
|
+
"review_actions",
|
|
2859
|
+
"manifest_actions",
|
|
2860
|
+
"recipe",
|
|
2861
|
+
"lua",
|
|
2862
|
+
"lua_source"
|
|
2863
|
+
]);
|
|
2864
|
+
var CODE = /^[a-z0-9_.:-]{1,64}$/;
|
|
2865
|
+
var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
2866
|
+
function requireJobsArgs(opts) {
|
|
2867
|
+
if (opts.status !== void 0) normalizeStatus(opts.status);
|
|
2868
|
+
if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > JOBS_MAX_LIMIT)) {
|
|
2869
|
+
throw new UsageError(`--limit takes an integer from 1 to ${JOBS_MAX_LIMIT}`);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
function normalizeStatus(status) {
|
|
2873
|
+
const tokens = status.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
|
|
2874
|
+
const known = [...JOB_STATES, ...JOB_STATUS_ALIASES];
|
|
2875
|
+
const unknown = tokens.filter((token) => !known.includes(token));
|
|
2876
|
+
if (tokens.length === 0 || unknown.length > 0) {
|
|
2877
|
+
throw new UsageError(
|
|
2878
|
+
`${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`
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
return tokens.join(",");
|
|
2882
|
+
}
|
|
2883
|
+
async function jobsList(ctx, opts) {
|
|
2884
|
+
requireJobsArgs(opts);
|
|
2885
|
+
const params = {
|
|
2886
|
+
status: opts.status === void 0 ? void 0 : normalizeStatus(opts.status),
|
|
2887
|
+
mine: opts.mine === true ? true : void 0,
|
|
2888
|
+
// Reading everything: full pages, so the walk takes as few requests as it can.
|
|
2889
|
+
limit: opts.limit ?? (opts.all === true ? JOBS_MAX_LIMIT : void 0)
|
|
2890
|
+
};
|
|
2891
|
+
let page = await jobsPage(ctx, params, void 0);
|
|
2892
|
+
let jobs = page.jobs;
|
|
2893
|
+
if (opts.all !== true) {
|
|
2894
|
+
if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
|
|
2895
|
+
if (jobs.length === 0) {
|
|
2896
|
+
return {
|
|
2897
|
+
json: jobs,
|
|
2898
|
+
human: "No Operator jobs you can see on this page.\n",
|
|
2899
|
+
notice: "harness: older jobs may exist past this page \u2014 pass --all to read on.\n"
|
|
2900
|
+
};
|
|
2901
|
+
}
|
|
2902
|
+
return {
|
|
2903
|
+
json: jobs,
|
|
2904
|
+
human: jobsTable(jobs),
|
|
2905
|
+
notice: `harness: showing the newest ${plural(jobs.length, "Operator job")} \u2014 pass --all for older ones.
|
|
2906
|
+
`
|
|
2907
|
+
};
|
|
2908
|
+
}
|
|
2909
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2910
|
+
let pages = 1;
|
|
2911
|
+
while (page.hasMore && pages < JOBS_MAX_PAGES) {
|
|
2912
|
+
const cursor = page.nextCursor;
|
|
2913
|
+
if (seen.has(cursor)) throw new CommandError("The jobs response repeated a cursor.");
|
|
2914
|
+
seen.add(cursor);
|
|
2915
|
+
page = await jobsPage(ctx, params, cursor);
|
|
2916
|
+
pages += 1;
|
|
2917
|
+
jobs = [...jobs, ...page.jobs];
|
|
2918
|
+
}
|
|
2919
|
+
jobs = uniqueById(jobs);
|
|
2920
|
+
if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
|
|
2921
|
+
return {
|
|
2922
|
+
json: jobs,
|
|
2923
|
+
human: jobsTable(jobs),
|
|
2924
|
+
notice: `harness: stopped after ${JOBS_MAX_PAGES} pages (${plural(jobs.length, "job")}) without reaching the end \u2014 narrow the list with --status or --mine.
|
|
2925
|
+
`,
|
|
2926
|
+
exitCode: 1
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
async function jobsPage(ctx, params, before) {
|
|
2930
|
+
const body = await explained(
|
|
2931
|
+
() => ctx.client.get("/api/v1/operator/jobs", { ...params, before }),
|
|
2932
|
+
{ notFound: "this Harness server has no Operator API yet" }
|
|
2933
|
+
);
|
|
2934
|
+
const jobs = objects(body.data, "jobs").map(withheld);
|
|
2935
|
+
const meta = body.meta;
|
|
2936
|
+
if (typeof meta !== "object" || meta === null || typeof meta["has_more"] !== "boolean") {
|
|
2937
|
+
throw new CommandError("The jobs response has no paging metadata.");
|
|
2938
|
+
}
|
|
2939
|
+
if (meta["has_more"] !== true) return { jobs, hasMore: false, nextCursor: null };
|
|
2940
|
+
const cursor = meta["next_cursor"];
|
|
2941
|
+
if (typeof cursor !== "string" || cursor === "") {
|
|
2942
|
+
throw new CommandError("The jobs response says more exist but gives no cursor.");
|
|
2943
|
+
}
|
|
2944
|
+
return { jobs, hasMore: true, nextCursor: cursor };
|
|
2945
|
+
}
|
|
2946
|
+
function requireJobId(id) {
|
|
2947
|
+
if (!isUuid(id)) throw new UsageError(`not an Operator job id: ${id} (expected the job's UUID)`);
|
|
2948
|
+
}
|
|
2949
|
+
async function jobShow(ctx, id, opts = {}) {
|
|
2950
|
+
requireJobId(id);
|
|
2951
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}`), {
|
|
2952
|
+
notFound: missingJob(id)
|
|
2953
|
+
});
|
|
2954
|
+
if (!isRecord2(body.data)) throw new CommandError("The job response carried no job.");
|
|
2955
|
+
const job = withheld(body.data);
|
|
2956
|
+
if (opts.changes !== true && opts.people !== true) return { json: job, human: jobDetail(job) };
|
|
2957
|
+
const document = Object.fromEntries(
|
|
2958
|
+
["id", "state", "waiting_on", "url", "approval_url"].map((key) => [key, job[key] ?? null])
|
|
2959
|
+
);
|
|
2960
|
+
const sections = [];
|
|
2961
|
+
let notice;
|
|
2962
|
+
if (opts.changes === true) {
|
|
2963
|
+
const changeSet = await jobChangeSet(ctx, id);
|
|
2964
|
+
document["change_set"] = changeSet;
|
|
2965
|
+
sections.push(changesText(id, job, changeSet));
|
|
2966
|
+
notice = changeSetRace(job, changeSet);
|
|
2967
|
+
}
|
|
2968
|
+
if (opts.people === true) {
|
|
2969
|
+
const people = jobPeople(job);
|
|
2970
|
+
document["people"] = people;
|
|
2971
|
+
sections.push(peopleText(id, people));
|
|
2972
|
+
}
|
|
2973
|
+
return { json: document, human: sections.join("\n"), ...notice === void 0 ? {} : { notice } };
|
|
2974
|
+
}
|
|
2975
|
+
function changeSetRace(job, changeSet) {
|
|
2976
|
+
const pending = field2(job, "pending_approval", "manifest_id");
|
|
2977
|
+
if (typeof pending !== "string") return void 0;
|
|
2978
|
+
const read = changeSet === null ? null : changeSet["manifest_id"];
|
|
2979
|
+
if (read === pending) return void 0;
|
|
2980
|
+
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.
|
|
2981
|
+
`;
|
|
2982
|
+
}
|
|
2983
|
+
async function jobChangeSet(ctx, id) {
|
|
2984
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}/changes`), {
|
|
2985
|
+
notFound: missingJob(id)
|
|
2986
|
+
});
|
|
2987
|
+
if (body.data === null) return null;
|
|
2988
|
+
if (!isRecord2(body.data)) throw new CommandError("The change set response carried no change set.");
|
|
2989
|
+
return {
|
|
2990
|
+
...pick(body.data, CHANGE_SET_FIELDS),
|
|
2991
|
+
actions: records3(body.data["actions"]).map((action) => pick(action, CHANGE_SET_ACTION_FIELDS))
|
|
2992
|
+
};
|
|
2993
|
+
}
|
|
2994
|
+
function jobPeople(job) {
|
|
2995
|
+
const participants = records3(field2(job, "people", "participants"));
|
|
2996
|
+
const owner = isRecord2(job["owner"]) ? job["owner"] : null;
|
|
2997
|
+
const names = /* @__PURE__ */ new Map();
|
|
2998
|
+
for (const named of owner === null ? participants : [...participants, owner]) {
|
|
2999
|
+
if (typeof named["user_id"] === "string" && typeof named["name"] === "string" && named["name"] !== "") {
|
|
3000
|
+
if (!names.has(named["user_id"])) names.set(named["user_id"], named["name"]);
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
|
|
3004
|
+
const some = (people) => people.filter((entry) => entry !== null);
|
|
3005
|
+
const approvers = field2(job, "people", "approver_user_ids");
|
|
3006
|
+
return {
|
|
3007
|
+
requester: person(job["requester_user_id"]),
|
|
3008
|
+
execution_principal: person(job["execution_principal_user_id"]),
|
|
3009
|
+
owner: owner === null ? null : person(owner["user_id"]),
|
|
3010
|
+
approvers: some((Array.isArray(approvers) ? approvers : []).map(person)),
|
|
3011
|
+
collaborators: some(participants.filter((entry) => entry["role"] === "collaborator").map((entry) => person(entry["user_id"])))
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
async function jobWatch(ctx, id, opts) {
|
|
3015
|
+
requireJobId(id);
|
|
3016
|
+
if (opts.after !== void 0 && (!Number.isInteger(opts.after) || opts.after < 0)) {
|
|
3017
|
+
throw new UsageError("--after takes a non-negative event sequence");
|
|
3018
|
+
}
|
|
3019
|
+
const emit = opts.onLine ?? (() => {
|
|
3020
|
+
});
|
|
3021
|
+
const sleep = opts.sleep ?? abortableSleep;
|
|
3022
|
+
const signal = opts.signal;
|
|
3023
|
+
let after = opts.after ?? 0;
|
|
3024
|
+
const events = [];
|
|
3025
|
+
let job = null;
|
|
3026
|
+
let shown = null;
|
|
3027
|
+
let shownApprovalUrl = null;
|
|
3028
|
+
const document = (outcome, extra = {}) => ({
|
|
3029
|
+
id,
|
|
3030
|
+
outcome,
|
|
3031
|
+
...extra,
|
|
3032
|
+
job: job === null ? null : jobStateJson(job),
|
|
3033
|
+
...opts.collectEvents === true ? { events } : {},
|
|
3034
|
+
next_after: after
|
|
3035
|
+
});
|
|
3036
|
+
const stopped = (reason) => reason === READER_GONE ? (
|
|
3037
|
+
// Nobody is reading any more: end quietly.
|
|
3038
|
+
{ json: document("stopped"), human: "", exitCode: 0 }
|
|
3039
|
+
) : { json: document("interrupted"), human: "", notice: resumeLine(id, after), exitCode: EXIT_INTERRUPTED };
|
|
3040
|
+
for (; ; ) {
|
|
3041
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
3042
|
+
let page;
|
|
3043
|
+
try {
|
|
3044
|
+
page = await withRetries(() => eventsPage(ctx, id, after), sleep, signal);
|
|
3045
|
+
} catch (error) {
|
|
3046
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
3047
|
+
if (!transient(error)) throw error;
|
|
3048
|
+
const message = failureMessage(error);
|
|
3049
|
+
return {
|
|
3050
|
+
json: document("error", {
|
|
3051
|
+
error: { message, status: error instanceof HttpError ? error.status : null }
|
|
3052
|
+
}),
|
|
3053
|
+
human: "",
|
|
3054
|
+
notice: `harness: ${message}
|
|
3055
|
+
` + resumeLine(id, after),
|
|
3056
|
+
exitCode: error instanceof TransportError ? 3 : 1
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
for (const event of page.events) {
|
|
3060
|
+
if (opts.collectEvents === true) events.push(eventJson(event));
|
|
3061
|
+
emit(eventLine(event));
|
|
3062
|
+
}
|
|
3063
|
+
after = page.nextAfter;
|
|
3064
|
+
job = page.job;
|
|
3065
|
+
if (page.hasMore) continue;
|
|
3066
|
+
const state = str5(job["state"]);
|
|
3067
|
+
const waitingOn = typeof job["waiting_on"] === "string" ? job["waiting_on"] : null;
|
|
3068
|
+
if (shown === null || shown.state !== state || shown.waitingOn !== waitingOn) {
|
|
3069
|
+
emit(stateLine(shown?.state ?? null, state, waitingOn));
|
|
3070
|
+
shown = { state, waitingOn };
|
|
3071
|
+
}
|
|
3072
|
+
const approvalUrl = typeof job["approval_url"] === "string" ? job["approval_url"] : null;
|
|
3073
|
+
if (opts.throughWaits === true && approvalUrl !== null && approvalUrl !== shownApprovalUrl) {
|
|
3074
|
+
emit(`approve in the app: ${clean(approvalUrl)}
|
|
3075
|
+
`);
|
|
3076
|
+
}
|
|
3077
|
+
shownApprovalUrl = approvalUrl;
|
|
3078
|
+
let outcome = null;
|
|
3079
|
+
if (ENDED_STATES.has(state)) outcome = state;
|
|
3080
|
+
else if (waitingOn !== null && opts.throughWaits !== true) outcome = "waiting";
|
|
3081
|
+
if (outcome !== null) {
|
|
3082
|
+
return {
|
|
3083
|
+
json: document(outcome),
|
|
3084
|
+
human: outcomeText(id, outcome, job),
|
|
3085
|
+
exitCode: outcome === "completed" ? 0 : outcome === "waiting" ? EXIT_WAITING_FOR_PERSON : EXIT_JOB_ENDED
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
if (aborted(signal)) return stopped(signal?.reason);
|
|
3089
|
+
await sleep(waitingOn !== null || state === "paused" ? WATCH_WAITING_INTERVAL_MS : WATCH_INTERVAL_MS, signal);
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
function resumeLine(id, after) {
|
|
3093
|
+
return `harness: stopped watching job ${id}; resume with: harness watch ${id} --after ${after}
|
|
3094
|
+
`;
|
|
3095
|
+
}
|
|
3096
|
+
function watchStopSignal(stdout = process.stdout, signals = process) {
|
|
3097
|
+
const controller = new AbortController();
|
|
3098
|
+
const readerGone = () => controller.abort(READER_GONE);
|
|
3099
|
+
stdout.once("error", readerGone);
|
|
3100
|
+
stdout.once("close", readerGone);
|
|
3101
|
+
signals.once("SIGINT", () => controller.abort(INTERRUPTED));
|
|
3102
|
+
return controller.signal;
|
|
3103
|
+
}
|
|
3104
|
+
function aborted(signal) {
|
|
3105
|
+
return signal?.aborted === true;
|
|
3106
|
+
}
|
|
3107
|
+
function abortableSleep(ms, signal) {
|
|
3108
|
+
return new Promise((resolve) => {
|
|
3109
|
+
if (aborted(signal)) {
|
|
3110
|
+
resolve();
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
const done = () => {
|
|
3114
|
+
clearTimeout(timer);
|
|
3115
|
+
signal?.removeEventListener("abort", done);
|
|
3116
|
+
resolve();
|
|
3117
|
+
};
|
|
3118
|
+
const timer = setTimeout(done, ms);
|
|
3119
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
function transient(error) {
|
|
3123
|
+
if (error instanceof TransportError) return true;
|
|
3124
|
+
return error instanceof HttpError && !(error instanceof UpgradeRequiredError) && (error.status >= 500 || error.status === 429);
|
|
3125
|
+
}
|
|
3126
|
+
async function withRetries(send, sleep, signal) {
|
|
3127
|
+
for (let attempt = 0; ; attempt++) {
|
|
3128
|
+
try {
|
|
3129
|
+
return await send();
|
|
3130
|
+
} catch (error) {
|
|
3131
|
+
if (attempt >= WATCH_RETRIES || !transient(error) || aborted(signal)) throw error;
|
|
3132
|
+
await sleep(retryDelay(error, attempt), signal);
|
|
3133
|
+
if (aborted(signal)) throw error;
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
function retryDelay(error, attempt) {
|
|
3138
|
+
const backoff = RETRY_BASE_MS * 2 ** attempt;
|
|
3139
|
+
const asked = error instanceof HttpError ? retryAfterMs(error.retryAfter) : null;
|
|
3140
|
+
return asked === null ? backoff : Math.max(backoff, Math.min(asked, RETRY_AFTER_CAP_MS));
|
|
3141
|
+
}
|
|
3142
|
+
function retryAfterMs(header) {
|
|
3143
|
+
if (header === null) return null;
|
|
3144
|
+
const value = header.trim();
|
|
3145
|
+
if (/^\d+$/.test(value)) return Number.parseInt(value, 10) * 1e3;
|
|
3146
|
+
const at = Date.parse(value);
|
|
3147
|
+
return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
|
|
3148
|
+
}
|
|
3149
|
+
function failureMessage(error) {
|
|
3150
|
+
if (error instanceof TransportError) return `cannot reach the API: ${clean(error.message)}`;
|
|
3151
|
+
return `${clean(error.message)} (HTTP ${error.status})${errorDetails(error.body)}`;
|
|
3152
|
+
}
|
|
3153
|
+
async function eventsPage(ctx, id, after) {
|
|
3154
|
+
const body = await explained(
|
|
3155
|
+
() => ctx.client.get(`/api/v1/operator/jobs/${id}/events`, {
|
|
3156
|
+
after,
|
|
3157
|
+
limit: EVENTS_PAGE_LIMIT
|
|
3158
|
+
}),
|
|
3159
|
+
{ notFound: missingJob(id) }
|
|
3160
|
+
);
|
|
3161
|
+
const events = objects(body.data, "events");
|
|
3162
|
+
const meta = body.meta;
|
|
3163
|
+
if (typeof meta !== "object" || meta === null) throw new CommandError("The events response has no metadata.");
|
|
3164
|
+
const nextAfter = meta["next_after"];
|
|
3165
|
+
const hasMore = meta["has_more"];
|
|
3166
|
+
const job = meta["job"];
|
|
3167
|
+
if (typeof nextAfter !== "number" || !Number.isInteger(nextAfter) || nextAfter < after) {
|
|
3168
|
+
throw new CommandError("The events response has an invalid next_after.");
|
|
3169
|
+
}
|
|
3170
|
+
if (typeof hasMore !== "boolean") throw new CommandError("The events response has an invalid has_more.");
|
|
3171
|
+
if (!isRecord2(job) || typeof job["state"] !== "string") {
|
|
3172
|
+
throw new CommandError("The events response does not carry the job's state.");
|
|
3173
|
+
}
|
|
3174
|
+
if (hasMore && nextAfter === after) {
|
|
3175
|
+
throw new CommandError("The events response says more exist but did not move its cursor.");
|
|
3176
|
+
}
|
|
3177
|
+
return { events, nextAfter, hasMore, job };
|
|
3178
|
+
}
|
|
3179
|
+
var REFUSAL_HINTS = {
|
|
3180
|
+
operator_disabled: "nothing can be read until Operator is switched back on for this organization",
|
|
3181
|
+
operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
|
|
3182
|
+
actor_required: "the credential acts as no person: sign in with `harness login`, or use an API token a person created",
|
|
3183
|
+
forbidden: "the credential lacks the operator:read scope (`harness login` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
|
|
3184
|
+
run_bound_credential: "a run-bound credential cannot read Operator jobs; sign in with `harness login`"
|
|
3185
|
+
};
|
|
3186
|
+
function missingJob(id) {
|
|
3187
|
+
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)`;
|
|
3188
|
+
}
|
|
3189
|
+
async function explained(send, why) {
|
|
3190
|
+
try {
|
|
3191
|
+
return await send();
|
|
3192
|
+
} catch (error) {
|
|
3193
|
+
if (!(error instanceof HttpError)) throw error;
|
|
3194
|
+
const code = error.body["error"];
|
|
3195
|
+
let hint;
|
|
3196
|
+
if (error.status === 403 && typeof code === "string") hint = REFUSAL_HINTS[code];
|
|
3197
|
+
if (error.status === 404) hint = why.notFound;
|
|
3198
|
+
if (error.status === 400 && code === "invalid_cursor") hint = "run the list again from the start";
|
|
3199
|
+
if (hint === void 0) throw error;
|
|
3200
|
+
throw new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
function jobsTable(jobs) {
|
|
3204
|
+
if (jobs.length === 0) return "No Operator jobs.\n";
|
|
3205
|
+
const rows = jobs.map((job) => [
|
|
3206
|
+
str5(job["id"]),
|
|
3207
|
+
str5(job["state"]) || "-",
|
|
3208
|
+
str5(job["waiting_on"]) || "-",
|
|
3209
|
+
str5(job["objective_kind"]) || "-",
|
|
3210
|
+
personName(job["owner"]),
|
|
3211
|
+
str5(field2(job, "plan_step", "issue")) || "-",
|
|
3212
|
+
str5(job["updated_at"]) || "-"
|
|
3213
|
+
]);
|
|
3214
|
+
const table2 = table(["ID", "STATE", "WAITING ON", "KIND", "OWNER", "PLAN STEP", "UPDATED"], rows);
|
|
3215
|
+
const approvals = jobs.filter((job) => typeof job["approval_url"] === "string");
|
|
3216
|
+
if (approvals.length === 0) return table2;
|
|
3217
|
+
return table2 + "\nWaiting for approval. Approve in the app; the CLI cannot:\n" + approvals.map((job) => ` ${clean(job["id"])} ${clean(job["approval_url"])}
|
|
3218
|
+
`).join("");
|
|
3219
|
+
}
|
|
3220
|
+
function jobDetail(job) {
|
|
3221
|
+
const waitingOn = str5(job["waiting_on"]);
|
|
3222
|
+
const header = `Operator job ${clean(job["id"])} \xB7 ${clean(job["state"])}` + (waitingOn === "" ? "" : ` \xB7 waiting on ${clean(waitingOn)}`) + "\n";
|
|
3223
|
+
const people = records3(field2(job, "people", "participants"));
|
|
3224
|
+
const fields = [
|
|
3225
|
+
["Kind", str5(job["objective_kind"]) || "-"],
|
|
3226
|
+
["Role", str5(job["viewer_role"]) || null],
|
|
3227
|
+
["Owner", isRecord2(job["owner"]) ? personName(job["owner"]) : null],
|
|
3228
|
+
["Plan step", planStepLine(job["plan_step"])],
|
|
3229
|
+
["Plan", planLine(job["plan"])],
|
|
3230
|
+
["Progress", progressLine(job["progress"])],
|
|
3231
|
+
["Window", windowLine(job["window"])],
|
|
3232
|
+
["Spend", spendLine(job["spend"])],
|
|
3233
|
+
["Verification", str5(field2(job, "verification", "status")) || null],
|
|
3234
|
+
["Effects", effectsLine(job["effects"])],
|
|
3235
|
+
["Skill", skillLine(job["skill"])],
|
|
3236
|
+
["Grant", grantLine(job["grant"], people)],
|
|
3237
|
+
["Created", str5(job["inserted_at"]) || null],
|
|
3238
|
+
["Updated", str5(job["updated_at"]) || null],
|
|
3239
|
+
["Workbench", str5(job["url"]) || null]
|
|
3240
|
+
];
|
|
3241
|
+
return header + labelled(fields) + approvalSection(job["pending_approval"], people) + listSection("Blockers", records3(job["blockers"]), (item) => `${str5(item["kind"])}: ${str5(item["state"])}`) + listSection("Artifacts", records3(job["artifacts"]), (item) => `${str5(item["id"])} ${str5(item["url"])}`) + peopleSection(people) + listSection(
|
|
3242
|
+
"Procedures",
|
|
3243
|
+
records3(job["procedures"]),
|
|
3244
|
+
(procedure) => `${str5(procedure["name"])} v${str5(procedure["current_version"])} ${str5(procedure["id"])}`
|
|
3245
|
+
) + 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";
|
|
3246
|
+
}
|
|
3247
|
+
function labelled(fields, width = 15) {
|
|
3248
|
+
return fields.filter((entry) => entry[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(width)}${clean(value)}
|
|
3249
|
+
`).join("");
|
|
3250
|
+
}
|
|
3251
|
+
function planStepLine(step) {
|
|
3252
|
+
if (!isRecord2(step)) return null;
|
|
3253
|
+
return `${str5(step["issue"])} (plan ${str5(step["plan_id"])})`;
|
|
3254
|
+
}
|
|
3255
|
+
function planLine(plan) {
|
|
3256
|
+
if (!isRecord2(plan)) return "none yet";
|
|
3257
|
+
const count = plan["capability_count"];
|
|
3258
|
+
return `version ${str5(plan["version"])} \xB7 instructions v${str5(plan["instruction_version"])} \xB7 ${str5(count)} capabilit${count === 1 ? "y" : "ies"}`;
|
|
3259
|
+
}
|
|
3260
|
+
function progressLine(progress) {
|
|
3261
|
+
if (!isRecord2(progress)) return null;
|
|
3262
|
+
const parts = [str5(progress["stage"]), str5(progress["situation"])].filter((part) => part !== "");
|
|
3263
|
+
if (progress["needs_viewer"] === true) parts.push("needs you");
|
|
3264
|
+
return parts.length === 0 ? null : parts.join(" \xB7 ");
|
|
3265
|
+
}
|
|
3266
|
+
function windowLine(window) {
|
|
3267
|
+
if (!isRecord2(window)) return null;
|
|
3268
|
+
const left = duration(window["remaining_ms"]);
|
|
3269
|
+
const budget = duration(window["budget_ms"]);
|
|
3270
|
+
const sequence = window["sequence"];
|
|
3271
|
+
return `${left ?? "?"} left${budget === null ? "" : ` of ${budget}`} \xB7 ${str5(window["state"])}` + (typeof sequence === "number" ? ` (window ${sequence})` : "");
|
|
3272
|
+
}
|
|
3273
|
+
function spendLine(spend) {
|
|
3274
|
+
if (!isRecord2(spend)) return null;
|
|
3275
|
+
const spent = `${str5(spend["spent_cents"]) || "0"} cents spent`;
|
|
3276
|
+
if (typeof spend["plan_budget_cents"] !== "number") return spent;
|
|
3277
|
+
const remaining = spend["plan_remaining_cents"];
|
|
3278
|
+
return `${spent} \xB7 plan budget ${spend["plan_budget_cents"]} cents` + (typeof remaining === "number" ? `, ${remaining} cents left` : "");
|
|
3279
|
+
}
|
|
3280
|
+
function effectsLine(effects) {
|
|
3281
|
+
if (!isRecord2(effects)) return null;
|
|
3282
|
+
return ["staged", "succeeded", "failed", "unknown"].map((key) => `${str5(effects[key]) || "0"} ${key}`).join(" \xB7 ");
|
|
3283
|
+
}
|
|
3284
|
+
function skillLine(skill) {
|
|
3285
|
+
if (!isRecord2(skill)) return null;
|
|
3286
|
+
return `${str5(skill["title"])} (${str5(skill["invocation_name"])}) \xB7 sha256 ${str5(skill["content_sha256"])} \xB7 pinned ${str5(skill["pinned_at"])}`;
|
|
3287
|
+
}
|
|
3288
|
+
function grantLine(grant, people) {
|
|
3289
|
+
if (!isRecord2(grant)) return null;
|
|
3290
|
+
const approvedAt = str5(grant["approved_at"]);
|
|
3291
|
+
const expiresAt = str5(grant["expires_at"]);
|
|
3292
|
+
return `${str5(grant["status"])} v${str5(grant["version"])} \xB7 approved by ${nameOf(grant["approver_user_id"], people)}` + (approvedAt === "" ? "" : ` at ${approvedAt}`) + ` \xB7 ${expiresAt === "" ? "no expiry" : `expires ${expiresAt}`}`;
|
|
3293
|
+
}
|
|
3294
|
+
function approvalSection(pending, people) {
|
|
3295
|
+
if (!isRecord2(pending)) return "";
|
|
3296
|
+
const approvers = Array.isArray(pending["approver_user_ids"]) ? pending["approver_user_ids"].map((id) => nameOf(id, people)).join(", ") : "";
|
|
3297
|
+
const canApprove = pending["viewer_can_approve"] === true ? "you can approve" : "you cannot approve it";
|
|
3298
|
+
return "\nWaiting for approval. Approve it in the app; the CLI cannot:\n" + labelled([
|
|
3299
|
+
[
|
|
3300
|
+
"Change set",
|
|
3301
|
+
`${str5(pending["manifest_id"])} v${str5(pending["version"])} \xB7 ${str5(pending["action_count"])} actions \xB7 up to ${str5(pending["max_effect_count"])} effects \xB7 plan version ${str5(pending["plan_version"])}`
|
|
3302
|
+
],
|
|
3303
|
+
["Digest", str5(pending["digest"]) || null],
|
|
3304
|
+
["Approvers", approvers === "" ? canApprove : `${approvers} \xB7 ${canApprove}`],
|
|
3305
|
+
["Approve at", str5(pending["url"]) || null]
|
|
3306
|
+
]);
|
|
3307
|
+
}
|
|
3308
|
+
function peopleSection(people) {
|
|
3309
|
+
if (people.length === 0) return "";
|
|
3310
|
+
const rows = people.map((person) => [
|
|
3311
|
+
str5(person["name"]) || "(no name)",
|
|
3312
|
+
str5(person["role"]),
|
|
3313
|
+
str5(person["user_id"])
|
|
3314
|
+
]);
|
|
3315
|
+
const lines = table(["", "", ""], rows).split("\n").slice(1, -1);
|
|
3316
|
+
return "\nPeople:\n" + lines.map((line) => ` ${line}
|
|
3317
|
+
`).join("");
|
|
3318
|
+
}
|
|
3319
|
+
function changesText(id, job, changeSet) {
|
|
3320
|
+
if (changeSet === null) return `No pending change set: Operator has proposed nothing for job ${id} yet.
|
|
3321
|
+
`;
|
|
3322
|
+
const approvalUrl = str5(job["approval_url"]);
|
|
3323
|
+
const rows = records3(changeSet["actions"]).map((action) => [
|
|
3324
|
+
str5(action["display_order"]) || "-",
|
|
3325
|
+
str5(action["action_type"]),
|
|
3326
|
+
str5(action["action_version"]),
|
|
3327
|
+
str5(action["effect_count"]),
|
|
3328
|
+
(Array.isArray(action["required_capability_ids"]) ? action["required_capability_ids"].map(str5).join(", ") : "") || "-",
|
|
3329
|
+
str5(action["digest"]),
|
|
3330
|
+
str5(action["id"])
|
|
3331
|
+
]);
|
|
3332
|
+
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}
|
|
3333
|
+
`).join("");
|
|
3334
|
+
return `Change set of Operator job ${id} \xB7 ${clean(changeSet["status"])}
|
|
3335
|
+
` + labelled([
|
|
3336
|
+
[
|
|
3337
|
+
"Change set",
|
|
3338
|
+
`${str5(changeSet["manifest_id"])} v${str5(changeSet["version"])} \xB7 plan version ${str5(changeSet["plan_version"])} \xB7 up to ${str5(changeSet["max_effect_count"])} effects`
|
|
3339
|
+
],
|
|
3340
|
+
["Digest", str5(changeSet["digest"]) || null],
|
|
3341
|
+
approvalUrl === "" ? ["Workbench", str5(job["url"]) || null] : ["Approve at", approvalUrl]
|
|
3342
|
+
]) + 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");
|
|
3343
|
+
}
|
|
3344
|
+
function peopleText(id, people) {
|
|
3345
|
+
const one = (entry) => isRecord2(entry) ? str5(entry["name"]) || str5(entry["user_id"]) : "-";
|
|
3346
|
+
const many = (entries) => (Array.isArray(entries) ? entries.map(one).join(", ") : "") || "none";
|
|
3347
|
+
return `People on Operator job ${id}:
|
|
3348
|
+
` + labelled(
|
|
3349
|
+
[
|
|
3350
|
+
["Requester", one(people["requester"])],
|
|
3351
|
+
["Execution principal", one(people["execution_principal"])],
|
|
3352
|
+
["Owner", one(people["owner"])],
|
|
3353
|
+
["Approvers", many(people["approvers"])],
|
|
3354
|
+
["Collaborators", many(people["collaborators"])]
|
|
3355
|
+
],
|
|
3356
|
+
22
|
|
3357
|
+
);
|
|
3358
|
+
}
|
|
3359
|
+
function listSection(title, items, line) {
|
|
3360
|
+
if (items.length === 0) return "";
|
|
3361
|
+
return `
|
|
3362
|
+
${title}:
|
|
3363
|
+
` + items.map((item) => ` ${clean(line(item))}
|
|
3364
|
+
`).join("");
|
|
3365
|
+
}
|
|
3366
|
+
function controlsLine(viewerCan) {
|
|
3367
|
+
const controls = isRecord2(viewerCan) ? ["steer", "cancel", "continue", "pause", "save_procedure"].filter((key) => viewerCan[key] === true) : [];
|
|
3368
|
+
return `
|
|
3369
|
+
You can, in the workbench: ${controls.length === 0 ? "nothing right now" : controls.join(", ")}
|
|
3370
|
+
`;
|
|
3371
|
+
}
|
|
3372
|
+
function eventLine(event) {
|
|
3373
|
+
const actor = isRecord2(event["actor"]) ? event["actor"] : {};
|
|
3374
|
+
const actorKind = safeValue(actor["kind"]) || "?";
|
|
3375
|
+
const actorId = safeValue(actor["delegated_grant_id"] ?? actor["user_id"]);
|
|
3376
|
+
const data = isRecord2(event["data"]) ? Object.entries(event["data"]).map(([key, value]) => `${clean(key)}=${safeValue(value) || "\u2026"}`) : [];
|
|
3377
|
+
return `#${clean(event["sequence"])} ${clean(event["occurred_at"])} ${clean(event["kind"])} ${actorId === "" ? actorKind : `${actorKind} ${actorId}`}` + (data.length === 0 ? "" : ` ${data.join(" ")}`) + "\n";
|
|
3378
|
+
}
|
|
3379
|
+
function eventJson(event) {
|
|
3380
|
+
const actor = isRecord2(event["actor"]) ? event["actor"] : {};
|
|
3381
|
+
const data = isRecord2(event["data"]) ? event["data"] : {};
|
|
3382
|
+
return {
|
|
3383
|
+
sequence: typeof event["sequence"] === "number" ? event["sequence"] : null,
|
|
3384
|
+
kind: safeValue(event["kind"]) || null,
|
|
3385
|
+
occurred_at: typeof event["occurred_at"] === "string" && TIMESTAMP.test(event["occurred_at"]) ? event["occurred_at"] : null,
|
|
3386
|
+
actor: Object.fromEntries(
|
|
3387
|
+
["kind", "user_id", "delegated_grant_id"].filter((key) => key in actor && (actor[key] === null || safeValue(actor[key]) !== "")).map((key) => [key, actor[key]])
|
|
3388
|
+
),
|
|
3389
|
+
data: Object.fromEntries(Object.entries(data).filter(([key, value]) => CODE.test(key) && safeValue(value) !== ""))
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
function jobStateJson(job) {
|
|
3393
|
+
return Object.fromEntries(
|
|
3394
|
+
["state", "waiting_on", "updated_at", "url", "approval_url"].filter((key) => key in job).map((key) => [key, job[key]])
|
|
3395
|
+
);
|
|
3396
|
+
}
|
|
3397
|
+
function stateLine(previous, state, waitingOn) {
|
|
3398
|
+
const now = clean(state) + (waitingOn === null ? "" : `, waiting on ${clean(waitingOn)}`);
|
|
3399
|
+
return previous === null || previous === state ? `state: ${now}
|
|
3400
|
+
` : `state: ${clean(previous)} \u2192 ${now}
|
|
3401
|
+
`;
|
|
3402
|
+
}
|
|
3403
|
+
function outcomeText(id, outcome, job) {
|
|
3404
|
+
const url = clean(job["url"]);
|
|
3405
|
+
switch (outcome) {
|
|
3406
|
+
case "completed":
|
|
3407
|
+
return `Job ${id} completed.
|
|
3408
|
+
Workbench: ${url}
|
|
3409
|
+
`;
|
|
3410
|
+
case "failed":
|
|
3411
|
+
return `Job ${id} failed.
|
|
3412
|
+
Workbench: ${url}
|
|
3413
|
+
`;
|
|
3414
|
+
case "cancelled":
|
|
3415
|
+
return `Job ${id} was cancelled.
|
|
3416
|
+
Workbench: ${url}
|
|
3417
|
+
`;
|
|
3418
|
+
case "waiting": {
|
|
3419
|
+
const waitingOn = job["waiting_on"];
|
|
3420
|
+
if (waitingOn === "approval") {
|
|
3421
|
+
const link = clean(job["approval_url"]) || url;
|
|
3422
|
+
return `Job ${id} is waiting for approval. Approve it in the app; the CLI cannot:
|
|
3423
|
+
${link}
|
|
3424
|
+
`;
|
|
3425
|
+
}
|
|
3426
|
+
const what = waitingOn === "person" ? "a person" : clean(waitingOn);
|
|
3427
|
+
return `Job ${id} is waiting on ${what}; a person has to act in the workbench:
|
|
3428
|
+
${url}
|
|
3429
|
+
`;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
function withheld(job) {
|
|
3434
|
+
return Object.fromEntries(
|
|
3435
|
+
Object.entries(job).filter(([key]) => !WITHHELD_FIELDS.has(key) && !key.startsWith("knowledge"))
|
|
3436
|
+
);
|
|
3437
|
+
}
|
|
3438
|
+
function pick(record, keys) {
|
|
3439
|
+
return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
3440
|
+
}
|
|
3441
|
+
function objects(value, what) {
|
|
3442
|
+
if (!Array.isArray(value) || !value.every(isRecord2)) {
|
|
3443
|
+
throw new CommandError(`The ${what} response data is not a list of objects.`);
|
|
3444
|
+
}
|
|
3445
|
+
return value;
|
|
3446
|
+
}
|
|
3447
|
+
function plural(count, noun) {
|
|
3448
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
3449
|
+
}
|
|
3450
|
+
function uniqueById(jobs) {
|
|
3451
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3452
|
+
return jobs.filter((job) => {
|
|
3453
|
+
const id = str5(job["id"]);
|
|
3454
|
+
if (seen.has(id)) return false;
|
|
3455
|
+
seen.add(id);
|
|
3456
|
+
return true;
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
function personName(person) {
|
|
3460
|
+
if (!isRecord2(person)) return "-";
|
|
3461
|
+
return str5(person["name"]) || str5(person["user_id"]) || "-";
|
|
3462
|
+
}
|
|
3463
|
+
function nameOf(userId, people) {
|
|
3464
|
+
const id = str5(userId);
|
|
3465
|
+
const person = people.find((candidate) => candidate["user_id"] === id);
|
|
3466
|
+
return (person === void 0 ? "" : str5(person["name"])) || id || "?";
|
|
3467
|
+
}
|
|
3468
|
+
function duration(ms) {
|
|
3469
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return null;
|
|
3470
|
+
const seconds = Math.max(0, Math.floor(ms / 1e3));
|
|
3471
|
+
const hours = Math.floor(seconds / 3600);
|
|
3472
|
+
const minutes = Math.floor(seconds % 3600 / 60);
|
|
3473
|
+
const rest = seconds % 60;
|
|
3474
|
+
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
3475
|
+
if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
3476
|
+
return `${rest}s`;
|
|
3477
|
+
}
|
|
3478
|
+
function safeValue(value) {
|
|
3479
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
3480
|
+
if (typeof value === "boolean") return String(value);
|
|
3481
|
+
if (typeof value === "string" && CODE.test(value)) return value;
|
|
3482
|
+
return "";
|
|
3483
|
+
}
|
|
3484
|
+
function field2(record, ...keys) {
|
|
3485
|
+
let current = record;
|
|
3486
|
+
for (const key of keys) {
|
|
3487
|
+
if (!isRecord2(current)) return null;
|
|
3488
|
+
current = current[key];
|
|
3489
|
+
}
|
|
3490
|
+
return current ?? null;
|
|
3491
|
+
}
|
|
3492
|
+
function records3(value) {
|
|
3493
|
+
return Array.isArray(value) ? value.filter(isRecord2) : [];
|
|
3494
|
+
}
|
|
3495
|
+
function isRecord2(value) {
|
|
3496
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3497
|
+
}
|
|
3498
|
+
function str5(value) {
|
|
3499
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
// src/commands/operator-write.ts
|
|
3503
|
+
var ACCESS_HINTS = {
|
|
3504
|
+
...REFUSAL_HINTS,
|
|
3505
|
+
operator_disabled: "nothing can be changed until Operator is switched back on for this organization",
|
|
3506
|
+
actor_required: "the credential acts as no person: use a browser login, or an API token a person created",
|
|
3507
|
+
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",
|
|
3508
|
+
run_bound_credential: "a run-bound credential cannot change Operator jobs; use a browser login"
|
|
3509
|
+
};
|
|
3510
|
+
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";
|
|
3511
|
+
var NEVER_SENT = /* @__PURE__ */ new Set([
|
|
3512
|
+
"ENOTFOUND",
|
|
3513
|
+
"EAI_AGAIN",
|
|
3514
|
+
"EAI_FAIL",
|
|
3515
|
+
"ECONNREFUSED",
|
|
3516
|
+
"ENETUNREACH",
|
|
3517
|
+
"EHOSTUNREACH",
|
|
3518
|
+
"EHOSTDOWN",
|
|
3519
|
+
"EADDRNOTAVAIL",
|
|
3520
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
3521
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
3522
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
3523
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
3524
|
+
"UNABLE_TO_GET_ISSUER_CERT",
|
|
3525
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
3526
|
+
"CERT_HAS_EXPIRED",
|
|
3527
|
+
"CERT_NOT_YET_VALID",
|
|
3528
|
+
"CERT_REVOKED",
|
|
3529
|
+
"CERT_UNTRUSTED",
|
|
3530
|
+
"CERT_REJECTED",
|
|
3531
|
+
"HOSTNAME_MISMATCH",
|
|
3532
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
3533
|
+
"ERR_SSL_WRONG_VERSION_NUMBER"
|
|
3534
|
+
]);
|
|
3535
|
+
function neverSent(error) {
|
|
3536
|
+
return !(error instanceof MalformedResponseError) && error.code !== null && NEVER_SENT.has(error.code);
|
|
3537
|
+
}
|
|
3538
|
+
function outcomeUnknown(why, write2) {
|
|
3539
|
+
return new CommandError(
|
|
3540
|
+
`Outcome unknown: ${why}, so ${write2.mayHave}. It is not retried; check ${write2.check} before trying again.`
|
|
3541
|
+
);
|
|
3542
|
+
}
|
|
3543
|
+
async function sendOnce(ctx, path2, body, write2) {
|
|
3544
|
+
try {
|
|
3545
|
+
return await ctx.client.post(path2, body, { actor: false });
|
|
3546
|
+
} catch (error) {
|
|
3547
|
+
if (error instanceof TransportError) {
|
|
3548
|
+
if (neverSent(error)) {
|
|
3549
|
+
throw new TransportError(
|
|
3550
|
+
`${error.message} \u2014 the request never reached the server, so nothing changed`,
|
|
3551
|
+
error.code
|
|
3552
|
+
);
|
|
3553
|
+
}
|
|
3554
|
+
throw outcomeUnknown(
|
|
3555
|
+
error instanceof MalformedResponseError ? `the answer could not be read (${error.message})` : `the request failed (${error.message}) after it may have reached the server`,
|
|
3556
|
+
write2
|
|
3557
|
+
);
|
|
3558
|
+
}
|
|
3559
|
+
if (error instanceof HttpError && !(error instanceof UpgradeRequiredError) && error.status >= 500) {
|
|
3560
|
+
throw new HttpError(
|
|
3561
|
+
error.status,
|
|
3562
|
+
`${error.message} \u2014 outcome unknown: ${write2.mayHave}. It is not retried; check ${write2.check} before trying again`,
|
|
3563
|
+
error.body,
|
|
3564
|
+
error.retryAfter
|
|
3565
|
+
);
|
|
3566
|
+
}
|
|
3567
|
+
throw error;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
function explainRefusal(error, notFound2, hints) {
|
|
3571
|
+
if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
|
|
3572
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
3573
|
+
const own = Object.hasOwn(hints, code) ? hints[code] : void 0;
|
|
3574
|
+
let hint;
|
|
3575
|
+
if (error.status >= 500) hint = own;
|
|
3576
|
+
else if (error.status === 404) hint = notFound2;
|
|
3577
|
+
else if (own !== void 0) hint = own;
|
|
3578
|
+
else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
|
|
3579
|
+
else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
|
|
3580
|
+
if (hint === void 0) return error;
|
|
3581
|
+
const message = error.status >= 500 && typeof error.body["message"] === "string" ? error.body["message"] : error.message;
|
|
3582
|
+
return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
// src/commands/operator-continue.ts
|
|
3586
|
+
async function jobContinue(ctx, id) {
|
|
3587
|
+
requireJobId(id);
|
|
3588
|
+
const write2 = { mayHave: "the job may have been continued", check: `\`job ${id}\`` };
|
|
3589
|
+
let data;
|
|
3590
|
+
try {
|
|
3591
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/continue`, {}, write2)).data;
|
|
3592
|
+
} catch (error) {
|
|
3593
|
+
throw explainRefusal(error, notFound(id), refusals(id));
|
|
3594
|
+
}
|
|
3595
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
3596
|
+
throw outcomeUnknown("the answer carried no job", write2);
|
|
3597
|
+
}
|
|
3598
|
+
const job = withheld(data);
|
|
3599
|
+
return { json: job, human: `Continued Operator job ${id}.
|
|
3600
|
+
|
|
3601
|
+
` + jobDetail(job) };
|
|
3602
|
+
}
|
|
3603
|
+
function notFound(id) {
|
|
3604
|
+
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`;
|
|
3605
|
+
}
|
|
3606
|
+
function refusals(id) {
|
|
3607
|
+
return {
|
|
3608
|
+
principal_required: "only the person the job runs as (its execution principal) can continue it",
|
|
3609
|
+
invalid_state: `the job has nothing to continue in its current state; \`job ${id}\` shows where it stands`,
|
|
3610
|
+
reconciliation_required: "reconcile its partial effects in the app first, from the job's workbench link",
|
|
3611
|
+
principal_inactive: "the person the job runs as lost the rights to run it, so it can't continue until those are restored",
|
|
3612
|
+
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`
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// src/core/textinput.ts
|
|
3617
|
+
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
3618
|
+
import { isatty } from "tty";
|
|
3619
|
+
var STDIN_PATH = "-";
|
|
3620
|
+
var MAX_TEXT_BYTES = 1e6;
|
|
3621
|
+
var TEXT_LIMIT = { bytes: MAX_TEXT_BYTES, why: "that is not prose; check the path" };
|
|
3622
|
+
var DOCUMENT_LIMIT = { bytes: 8e6, why: "larger than the API accepts in one request" };
|
|
3623
|
+
function flagOf(name) {
|
|
3624
|
+
return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
|
|
3625
|
+
}
|
|
3626
|
+
function resolveTextSources(sources) {
|
|
3627
|
+
const resolved = {};
|
|
3628
|
+
const toRead = [];
|
|
3629
|
+
let stdinClaimedBy;
|
|
3630
|
+
for (const { name, inline, file } of sources) {
|
|
3631
|
+
if (inline !== void 0 && file !== void 0) {
|
|
3632
|
+
throw new UsageError(
|
|
3633
|
+
`--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
if (file === void 0) {
|
|
3637
|
+
if (inline !== void 0) resolved[name] = inline;
|
|
3638
|
+
continue;
|
|
3639
|
+
}
|
|
3640
|
+
if (file === STDIN_PATH) {
|
|
3641
|
+
if (isatty(0)) {
|
|
3642
|
+
throw new UsageError(
|
|
3643
|
+
`--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
|
|
3644
|
+
);
|
|
3645
|
+
}
|
|
3646
|
+
if (stdinClaimedBy !== void 0) {
|
|
3647
|
+
throw new UsageError(
|
|
3648
|
+
`--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
|
|
3649
|
+
);
|
|
3650
|
+
}
|
|
3651
|
+
stdinClaimedBy = name;
|
|
3652
|
+
}
|
|
3653
|
+
if (file !== STDIN_PATH) checkReadable(file, `--${flagOf(name)}-file`);
|
|
3654
|
+
toRead.push({ name, file });
|
|
3655
|
+
}
|
|
3656
|
+
for (const { name, file } of [...toRead].sort(byStdinLast)) {
|
|
3657
|
+
resolved[name] = stripOneTrailingNewline(readTextFile(file, `--${flagOf(name)}-file`));
|
|
3658
|
+
}
|
|
3659
|
+
return resolved;
|
|
3660
|
+
}
|
|
3661
|
+
function readFileOption(flag, path2) {
|
|
3662
|
+
if (path2 === STDIN_PATH) {
|
|
3663
|
+
if (isatty(0)) {
|
|
3664
|
+
throw new UsageError(`${flag} - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`);
|
|
3665
|
+
}
|
|
3666
|
+
} else {
|
|
3667
|
+
checkReadable(path2, flag, DOCUMENT_LIMIT);
|
|
3668
|
+
}
|
|
3669
|
+
return readTextFile(path2, flag, DOCUMENT_LIMIT);
|
|
3670
|
+
}
|
|
3671
|
+
function readTextFile(path2, option, limit = TEXT_LIMIT) {
|
|
3672
|
+
if (path2 === STDIN_PATH) {
|
|
3673
|
+
let piped;
|
|
3674
|
+
try {
|
|
3675
|
+
piped = readFileSync3(0);
|
|
3676
|
+
} catch (error) {
|
|
3677
|
+
throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
|
|
3678
|
+
}
|
|
3679
|
+
if (piped.length > limit.bytes) {
|
|
3680
|
+
throw new UsageError(oversize(`${option} -`, limit));
|
|
3681
|
+
}
|
|
3682
|
+
return decodeUtf8(piped, `${option} -`);
|
|
3683
|
+
}
|
|
3684
|
+
const flag = `${option} ${path2}`;
|
|
3685
|
+
let bytes;
|
|
3686
|
+
try {
|
|
3687
|
+
bytes = readFileSync3(path2);
|
|
3688
|
+
} catch (error) {
|
|
3689
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
3690
|
+
}
|
|
3691
|
+
if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
3692
|
+
return decodeUtf8(bytes, flag);
|
|
3693
|
+
}
|
|
3694
|
+
function decodeUtf8(bytes, flag) {
|
|
3695
|
+
try {
|
|
3696
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
3697
|
+
} catch {
|
|
3698
|
+
throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
function oversize(what, limit) {
|
|
3702
|
+
return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
|
|
3703
|
+
}
|
|
3704
|
+
function checkReadable(path2, option, limit = TEXT_LIMIT) {
|
|
3705
|
+
const flag = `${option} ${path2}`;
|
|
3706
|
+
let stats3;
|
|
3707
|
+
try {
|
|
3708
|
+
stats3 = statSync3(path2);
|
|
3709
|
+
} catch (error) {
|
|
3710
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
3711
|
+
}
|
|
3712
|
+
if (!stats3.isFile()) {
|
|
3713
|
+
throw new UsageError(
|
|
3714
|
+
`${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
|
|
3715
|
+
);
|
|
3716
|
+
}
|
|
3717
|
+
if (stats3.size > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
3718
|
+
}
|
|
3719
|
+
function byStdinLast(a, b) {
|
|
3720
|
+
return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
|
|
3721
|
+
}
|
|
3722
|
+
function stripOneTrailingNewline(text) {
|
|
3723
|
+
if (text.endsWith("\r\n")) return text.slice(0, -2);
|
|
3724
|
+
if (text.endsWith("\n")) return text.slice(0, -1);
|
|
3725
|
+
return text;
|
|
3726
|
+
}
|
|
3727
|
+
function describe(error) {
|
|
3728
|
+
const code = error?.code;
|
|
3729
|
+
if (code === "ENOENT") return "no such file";
|
|
3730
|
+
if (code === "EISDIR") return "is a directory";
|
|
3731
|
+
if (code === "EACCES") return "permission denied";
|
|
3732
|
+
return error instanceof Error ? error.message : String(error);
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
// src/commands/operator-controls.ts
|
|
3736
|
+
var STEER_MAX_CHARACTERS = 1e4;
|
|
3737
|
+
var DONE = { steer: "steered", cancel: "cancelled", pause: "paused" };
|
|
3738
|
+
function requireInstruction(instruction) {
|
|
3739
|
+
const trimmed = instruction.trim();
|
|
3740
|
+
if (trimmed === "") throw new UsageError("the instruction is empty \u2014 say what the job should change");
|
|
3741
|
+
const length = graphemeLength(trimmed);
|
|
3742
|
+
if (length > STEER_MAX_CHARACTERS) {
|
|
3743
|
+
throw new UsageError(
|
|
3744
|
+
`the instruction is ${length} characters once trimmed; a job takes at most ${STEER_MAX_CHARACTERS}`
|
|
3745
|
+
);
|
|
3746
|
+
}
|
|
3747
|
+
return trimmed;
|
|
3748
|
+
}
|
|
3749
|
+
function instructionFrom(words, file) {
|
|
3750
|
+
if (file === void 0) return requireInstruction(words.join(" "));
|
|
3751
|
+
if (words.length > 0) {
|
|
3752
|
+
throw new UsageError(
|
|
3753
|
+
"instruction words and --instruction-file both given \u2014 pass one; there is no precedence between them"
|
|
3754
|
+
);
|
|
3755
|
+
}
|
|
3756
|
+
return requireInstruction(resolveTextSources([{ name: "instruction", inline: void 0, file }])["instruction"] ?? "");
|
|
3757
|
+
}
|
|
3758
|
+
async function jobSteer(ctx, id, instruction) {
|
|
3759
|
+
requireJobId(id);
|
|
3760
|
+
const job = await control(ctx, id, "steer", { instruction: requireInstruction(instruction) });
|
|
3761
|
+
return {
|
|
3762
|
+
json: job,
|
|
3763
|
+
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.
|
|
3764
|
+
|
|
3765
|
+
` + jobDetail(job)
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
async function jobCancel(ctx, id) {
|
|
3769
|
+
requireJobId(id);
|
|
3770
|
+
const job = await control(ctx, id, "cancel", {});
|
|
3771
|
+
const effects = effectsLine(job["effects"]);
|
|
3772
|
+
return {
|
|
3773
|
+
json: job,
|
|
3774
|
+
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).
|
|
3775
|
+
`) + "\n" + jobDetail(job)
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
async function jobPause(ctx, id) {
|
|
3779
|
+
requireJobId(id);
|
|
3780
|
+
const job = await control(ctx, id, "pause", {});
|
|
3781
|
+
return {
|
|
3782
|
+
json: job,
|
|
3783
|
+
human: `Paused Operator job ${id}. Its active-time budget is kept; continue it to run again.
|
|
3784
|
+
|
|
3785
|
+
` + jobDetail(job)
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
async function control(ctx, id, verb, body) {
|
|
3789
|
+
const write2 = { mayHave: `the job may have been ${DONE[verb]}`, check: `\`job ${id}\`` };
|
|
3790
|
+
let data;
|
|
3791
|
+
try {
|
|
3792
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/${verb}`, body, write2)).data;
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
throw explained2(error, id, verb);
|
|
3795
|
+
}
|
|
3796
|
+
if (!isRecord3(data)) throw outcomeUnknown("the answer carried no job", write2);
|
|
3797
|
+
return withheld(data);
|
|
3798
|
+
}
|
|
3799
|
+
function explained2(error, id, verb) {
|
|
3800
|
+
if (!(error instanceof HttpError) || error.status >= 500) return error;
|
|
3801
|
+
const hint = refusalHint(error, id, verb);
|
|
3802
|
+
if (hint === void 0) return error;
|
|
3803
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
3804
|
+
}
|
|
3805
|
+
function refusalHint(error, id, verb) {
|
|
3806
|
+
const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
|
|
3807
|
+
const job = `\`job ${id}\``;
|
|
3808
|
+
switch (error.status) {
|
|
3809
|
+
case 400:
|
|
3810
|
+
return code === "bad_request" ? BAD_REQUEST_HINT : void 0;
|
|
3811
|
+
case 403:
|
|
3812
|
+
if (code !== "principal_required") return ACCESS_HINTS[code];
|
|
3813
|
+
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`;
|
|
3814
|
+
case 404:
|
|
3815
|
+
return missingJob(id);
|
|
3816
|
+
case 409:
|
|
3817
|
+
return conflictHint(code, verb, job);
|
|
3818
|
+
case 422:
|
|
3819
|
+
if (code === "invalid_instruction") {
|
|
3820
|
+
return `pass the instruction as text of 1 to ${STEER_MAX_CHARACTERS} characters once trimmed`;
|
|
3821
|
+
}
|
|
3822
|
+
return code === "validation_error" && verb === "steer" ? "the job's room refused the instruction, so nothing was posted" : void 0;
|
|
3823
|
+
default:
|
|
3824
|
+
return void 0;
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
function conflictHint(code, verb, job) {
|
|
3828
|
+
switch (code) {
|
|
3829
|
+
case "invalid_state":
|
|
3830
|
+
return {
|
|
3831
|
+
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`,
|
|
3832
|
+
cancel: `the job has already ended \u2014 ${job} shows how`,
|
|
3833
|
+
pause: `only a running job can be paused \u2014 ${job} shows where it stands`
|
|
3834
|
+
}[verb];
|
|
3835
|
+
case "reconciliation_required":
|
|
3836
|
+
return `its partial effects need reconciling first: reconcile them in the app (${job} has the workbench link)`;
|
|
3837
|
+
case "deliveries_unsettled":
|
|
3838
|
+
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`;
|
|
3839
|
+
case "principal_inactive":
|
|
3840
|
+
return "nothing changed: the job's execution principal needs their admin or member role back first";
|
|
3841
|
+
case "cancellation_scope_changed":
|
|
3842
|
+
return `the job was not cancelled; read it with ${job} and decide again`;
|
|
3843
|
+
default:
|
|
3844
|
+
return void 0;
|
|
3845
|
+
}
|
|
3846
|
+
}
|
|
3847
|
+
function isRecord3(value) {
|
|
3848
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
// src/commands/operator-procedures.ts
|
|
3852
|
+
var PROCEDURE_FIELDS = [
|
|
3853
|
+
"id",
|
|
3854
|
+
"name",
|
|
3855
|
+
"job_id",
|
|
3856
|
+
"job_url",
|
|
3857
|
+
"current_version",
|
|
3858
|
+
"parameter_schema",
|
|
3859
|
+
"versions",
|
|
3860
|
+
"inserted_at",
|
|
3861
|
+
"updated_at"
|
|
3862
|
+
];
|
|
3863
|
+
var PROCEDURE_VERSION_FIELDS = [
|
|
3864
|
+
"version",
|
|
3865
|
+
"recipe_digest",
|
|
3866
|
+
"capability_ids",
|
|
3867
|
+
"action_roster",
|
|
3868
|
+
"inserted_at"
|
|
3869
|
+
];
|
|
3870
|
+
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";
|
|
3871
|
+
function requireProcedureId(id) {
|
|
3872
|
+
if (!isUuid(id)) throw new UsageError(`not an Operator procedure id: ${id} (expected the procedure's UUID)`);
|
|
3873
|
+
}
|
|
3874
|
+
function missingProcedure(id) {
|
|
3875
|
+
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)`;
|
|
3876
|
+
}
|
|
3877
|
+
function requireProcedureName(name) {
|
|
3878
|
+
if (name === void 0 || name.trim() === "") {
|
|
3879
|
+
throw new UsageError('a procedure needs a name: procedure save <job-id> --name "\u2026"');
|
|
3880
|
+
}
|
|
3881
|
+
return name;
|
|
3882
|
+
}
|
|
3883
|
+
async function procedureSave(ctx, jobId, name) {
|
|
3884
|
+
requireJobId(jobId);
|
|
3885
|
+
const named = requireProcedureName(name);
|
|
3886
|
+
const write2 = {
|
|
3887
|
+
mayHave: "a procedure may have been saved",
|
|
3888
|
+
check: `the job's procedures with \`job ${jobId}\``
|
|
3889
|
+
};
|
|
3890
|
+
let data;
|
|
3891
|
+
try {
|
|
3892
|
+
data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named }, write2)).data;
|
|
3893
|
+
} catch (error) {
|
|
3894
|
+
throw explainRefusal(error, missingJob(jobId), {
|
|
3895
|
+
owner_required: "only the job's owner can save it as a procedure; ask them to save it",
|
|
3896
|
+
not_saveable: "only a job that completed with its work verified, and has work to repeat, can be saved",
|
|
3897
|
+
validation_error: "the name must be plain text, without control characters"
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
|
|
3901
|
+
throw outcomeUnknown("the answer named no procedure", write2);
|
|
3902
|
+
}
|
|
3903
|
+
const saved = `Saved job ${jobId} as procedure ${clean(data["id"])}`;
|
|
3904
|
+
if (!Array.isArray(data["versions"])) {
|
|
3905
|
+
return {
|
|
3906
|
+
json: pick2(data, ["id", "url"]),
|
|
3907
|
+
human: `${saved}, but it can't be read back as you.
|
|
3908
|
+
` + labelled([["Workbench", str6(data["url"]) || null]])
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
const procedure = procedureJson(data);
|
|
3912
|
+
return { json: procedure, human: `${saved}.
|
|
3913
|
+
|
|
3914
|
+
` + procedureDetail(procedure) };
|
|
3915
|
+
}
|
|
3916
|
+
async function procedureShow(ctx, id) {
|
|
3917
|
+
requireProcedureId(id);
|
|
3918
|
+
const body = await explained(() => ctx.client.get(`/api/v1/operator/procedures/${id}`), {
|
|
3919
|
+
notFound: missingProcedure(id)
|
|
3920
|
+
});
|
|
3921
|
+
if (!isRecord4(body.data)) throw new CommandError("The procedure response carried no procedure.");
|
|
3922
|
+
const procedure = procedureJson(body.data);
|
|
3923
|
+
return { json: procedure, human: procedureDetail(procedure) };
|
|
3924
|
+
}
|
|
3925
|
+
function prepareProcedureRun(id, opts) {
|
|
3926
|
+
requireProcedureId(id);
|
|
3927
|
+
const body = {};
|
|
3928
|
+
const version2 = runVersion(opts.version);
|
|
3929
|
+
if (version2 !== void 0) body["version"] = version2;
|
|
3930
|
+
const given = [
|
|
3931
|
+
opts.param !== void 0 && opts.param.length > 0 ? "--param" : null,
|
|
3932
|
+
opts.paramsFile !== void 0 ? "--params-file" : null,
|
|
3933
|
+
opts.parameters !== void 0 ? "parameters" : null
|
|
3934
|
+
].filter((source) => source !== null);
|
|
3935
|
+
if (given.length > 1) {
|
|
3936
|
+
throw new UsageError(`${given.join(" and ")} both given \u2014 pass the parameters one way; there is no precedence`);
|
|
3937
|
+
}
|
|
3938
|
+
if (opts.parameters !== void 0 && !isRecord4(opts.parameters)) {
|
|
3939
|
+
throw new UsageError("parameters must be one object of names and values");
|
|
3940
|
+
}
|
|
3941
|
+
const fromFlags = opts.param !== void 0 && opts.param.length > 0 ? parseParams(opts.param) : void 0;
|
|
3942
|
+
if (opts.group !== void 0) body["group_id"] = bareId(opts.group, "--group");
|
|
3943
|
+
if (opts.source !== void 0) body["source_resource_id"] = bareId(opts.source, "--source");
|
|
3944
|
+
const parameters = fromFlags ?? opts.parameters ?? (opts.paramsFile === void 0 ? void 0 : readParamsFile(opts.paramsFile));
|
|
3945
|
+
if (parameters !== void 0) body["parameters"] = parameters;
|
|
3946
|
+
return { id, body };
|
|
3947
|
+
}
|
|
3948
|
+
async function startProcedureRun(ctx, request) {
|
|
3949
|
+
const { id } = request;
|
|
3950
|
+
const write2 = { mayHave: "a job may have been started", check: "`jobs` for the new job" };
|
|
3951
|
+
let data;
|
|
3952
|
+
try {
|
|
3953
|
+
data = (await sendOnce(ctx, `/api/v1/operator/procedures/${id}/runs`, request.body, write2)).data;
|
|
3954
|
+
} catch (error) {
|
|
3955
|
+
throw explainRefusal(error, missingProcedure(id), {
|
|
3956
|
+
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`,
|
|
3957
|
+
concurrent_change: `read the procedure again (\`procedure show ${id}\`) before running it again`,
|
|
3958
|
+
invalid_rerun: `check the parameters against the procedure's parameter schema (\`procedure show ${id}\`), and that your current rights cover the run`,
|
|
3959
|
+
// The server refuses with it before the run starts.
|
|
3960
|
+
service_unavailable: "nothing was started; try again later"
|
|
3961
|
+
});
|
|
3962
|
+
}
|
|
3963
|
+
if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
|
|
3964
|
+
throw outcomeUnknown("the answer named no job", write2);
|
|
3965
|
+
}
|
|
3966
|
+
const started = `Started Operator job ${clean(data["id"])} from procedure ${id}`;
|
|
3967
|
+
if (typeof data["state"] !== "string") {
|
|
3968
|
+
return {
|
|
3969
|
+
json: pick2(data, ["id", "url"]),
|
|
3970
|
+
human: `${started}, but it can't be read back as you.
|
|
3971
|
+
` + labelled([["Workbench", str6(data["url"]) || null]]) + "\n" + RUN_NOTE
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3974
|
+
const job = withheld(data);
|
|
3975
|
+
const waitingOn = str6(job["waiting_on"]);
|
|
3976
|
+
return {
|
|
3977
|
+
json: job,
|
|
3978
|
+
human: `${started}.
|
|
3979
|
+
` + labelled([
|
|
3980
|
+
["State", str6(job["state"]) + (waitingOn === "" ? "" : ` \xB7 waiting on ${waitingOn}`)],
|
|
3981
|
+
["Workbench", str6(job["url"]) || null],
|
|
3982
|
+
["Approve at", str6(job["approval_url"]) || null]
|
|
3983
|
+
]) + "\n" + RUN_NOTE
|
|
3984
|
+
};
|
|
3985
|
+
}
|
|
3986
|
+
async function procedureRun(ctx, id, opts) {
|
|
3987
|
+
return startProcedureRun(ctx, prepareProcedureRun(id, opts));
|
|
3988
|
+
}
|
|
3989
|
+
function runVersion(version2) {
|
|
3990
|
+
if (version2 === void 0) return void 0;
|
|
3991
|
+
const text = String(version2).trim();
|
|
3992
|
+
const parsed = typeof version2 === "number" ? version2 : /^\d+$/.test(text) ? Number(text) : Number.NaN;
|
|
3993
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
3994
|
+
throw new UsageError(`--version takes a positive integer, the version expected to be current (got ${text})`);
|
|
3995
|
+
}
|
|
3996
|
+
return parsed;
|
|
3997
|
+
}
|
|
3998
|
+
function parseParams(entries) {
|
|
3999
|
+
const parsed = [];
|
|
4000
|
+
const names = /* @__PURE__ */ new Set();
|
|
4001
|
+
for (const entry of entries) {
|
|
4002
|
+
const equals = entry.indexOf("=");
|
|
4003
|
+
if (equals === -1) throw new UsageError(`--param ${entry}: expected name=value, or name:=<json> for a JSON value`);
|
|
4004
|
+
const typed = equals > 0 && entry[equals - 1] === ":";
|
|
4005
|
+
const name = entry.slice(0, typed ? equals - 1 : equals);
|
|
4006
|
+
const raw = entry.slice(equals + 1);
|
|
4007
|
+
if (name.trim() === "") throw new UsageError(`--param ${entry}: the parameter has no name`);
|
|
4008
|
+
if (name !== name.trim()) {
|
|
4009
|
+
throw new UsageError(`--param ${entry}: the name starts or ends with whitespace \u2014 write it without`);
|
|
4010
|
+
}
|
|
4011
|
+
if (names.has(name)) throw new UsageError(`--param ${name} given twice \u2014 pass each parameter once`);
|
|
4012
|
+
names.add(name);
|
|
4013
|
+
parsed.push([name, typed ? jsonValue(raw, `--param ${entry}`) : raw]);
|
|
4014
|
+
}
|
|
4015
|
+
return Object.fromEntries(parsed);
|
|
4016
|
+
}
|
|
4017
|
+
function jsonValue(text, what) {
|
|
4018
|
+
try {
|
|
4019
|
+
return JSON.parse(text);
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
throw new UsageError(`${what}: not valid JSON after := (${firstLine(error)}); quote a string as name=value`);
|
|
4022
|
+
}
|
|
4023
|
+
}
|
|
4024
|
+
function readParamsFile(path2) {
|
|
4025
|
+
const text = readFileOption("--params-file", path2);
|
|
4026
|
+
let parsed;
|
|
4027
|
+
try {
|
|
4028
|
+
parsed = JSON.parse(text);
|
|
4029
|
+
} catch (error) {
|
|
4030
|
+
throw new UsageError(`--params-file ${path2}: not valid JSON (${firstLine(error)})`);
|
|
4031
|
+
}
|
|
4032
|
+
if (!isRecord4(parsed)) {
|
|
4033
|
+
throw new UsageError(`--params-file ${path2}: the parameters are one JSON object of names and values`);
|
|
4034
|
+
}
|
|
4035
|
+
return parsed;
|
|
4036
|
+
}
|
|
4037
|
+
function bareId(value, flag) {
|
|
4038
|
+
if (value.trim() === "") throw new UsageError(`${flag} is empty`);
|
|
4039
|
+
if (value !== value.trim()) throw new UsageError(`${flag} starts or ends with whitespace \u2014 pass the id alone`);
|
|
4040
|
+
return value;
|
|
4041
|
+
}
|
|
4042
|
+
function procedureJson(data) {
|
|
4043
|
+
const procedure = pick2(data, PROCEDURE_FIELDS);
|
|
4044
|
+
if ("versions" in procedure) {
|
|
4045
|
+
procedure["versions"] = records4(procedure["versions"]).map((version2) => pick2(version2, PROCEDURE_VERSION_FIELDS));
|
|
4046
|
+
}
|
|
4047
|
+
return procedure;
|
|
4048
|
+
}
|
|
4049
|
+
function procedureDetail(procedure) {
|
|
4050
|
+
const current = procedure["current_version"];
|
|
4051
|
+
const versions = records4(procedure["versions"]).map(
|
|
4052
|
+
(version2) => ` ${clean(`v${str6(version2["version"])}${version2["version"] === current ? " (current)" : ""} ${str6(version2["inserted_at"])} recipe ${str6(version2["recipe_digest"])}`)}
|
|
4053
|
+
${"Capabilities:".padEnd(15)}${clean(list2(version2["capability_ids"]))}
|
|
4054
|
+
${"Actions:".padEnd(15)}${clean(list2(version2["action_roster"]))}
|
|
4055
|
+
`
|
|
4056
|
+
);
|
|
4057
|
+
return `Procedure ${clean(procedure["name"])} \xB7 v${clean(current)} \xB7 ${clean(procedure["id"])}
|
|
4058
|
+
` + labelled([
|
|
4059
|
+
["Saved from", `job ${str6(procedure["job_id"])}`],
|
|
4060
|
+
["Workbench", str6(procedure["job_url"]) || null],
|
|
4061
|
+
["Created", str6(procedure["inserted_at"]) || null],
|
|
4062
|
+
["Updated", str6(procedure["updated_at"]) || null]
|
|
4063
|
+
]) + (versions.length === 0 ? "\nVersions: none\n" : "\nVersions:\n" + versions.join("")) + parametersSection(procedure["parameter_schema"]) + "\n" + RUN_NOTE;
|
|
4064
|
+
}
|
|
4065
|
+
function parametersSection(schema) {
|
|
4066
|
+
if (!isRecord4(schema) || Object.keys(schema).length === 0) return "\nParameters a run takes: none\n";
|
|
4067
|
+
const lines = JSON.stringify(schema, null, 2).split("\n").map((line) => ` ${clean(line)}
|
|
4068
|
+
`).join("");
|
|
4069
|
+
const scoped = isRecord4(schema["scoped_inputs"]) ? schema["scoped_inputs"]["required"] : void 0;
|
|
4070
|
+
const scopes = Array.isArray(scoped) ? scoped.map(str6) : [];
|
|
4071
|
+
let needs = "";
|
|
4072
|
+
if (scopes.includes("contact_reconciliation")) {
|
|
4073
|
+
needs = "Its runs also need --group <contact-group-id> and --source <uploaded-file-id> (contact_reconciliation).\n";
|
|
4074
|
+
} else if (scopes.length > 0) {
|
|
4075
|
+
needs = `Its runs also need inputs resolved afresh: ${clean(scopes.join(", "))}.
|
|
4076
|
+
`;
|
|
4077
|
+
}
|
|
4078
|
+
return "\nParameters a run takes (JSON schema):\n" + lines + needs;
|
|
4079
|
+
}
|
|
4080
|
+
function list2(value) {
|
|
4081
|
+
return (Array.isArray(value) ? value.map(str6).join(", ") : "") || "none";
|
|
4082
|
+
}
|
|
4083
|
+
function pick2(record, keys) {
|
|
4084
|
+
return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
4085
|
+
}
|
|
4086
|
+
function records4(value) {
|
|
4087
|
+
return Array.isArray(value) ? value.filter(isRecord4) : [];
|
|
4088
|
+
}
|
|
4089
|
+
function isRecord4(value) {
|
|
4090
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4091
|
+
}
|
|
4092
|
+
function str6(value) {
|
|
4093
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
4094
|
+
}
|
|
4095
|
+
function firstLine(error) {
|
|
4096
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
// src/commands/plans.ts
|
|
4100
|
+
import { extname as extname2 } from "path";
|
|
4101
|
+
import { parseDocument } from "yaml";
|
|
4102
|
+
var PLAN_EXECUTORS = ["none", "operator"];
|
|
4103
|
+
var CONDITION_KINDS = ["not_before", "gate", "event"];
|
|
4104
|
+
var GATE_OWNER_ROLES = ["gp", "compliance_officer", "valuer", "accountant", "auditor", "counsel"];
|
|
4105
|
+
function parsePlanDocument(text, source) {
|
|
4106
|
+
const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
|
|
4107
|
+
if (!isRecord5(parsed)) {
|
|
4108
|
+
throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
|
|
4109
|
+
}
|
|
4110
|
+
return parsed;
|
|
4111
|
+
}
|
|
4112
|
+
function parseJson(text, source) {
|
|
4113
|
+
try {
|
|
4114
|
+
return JSON.parse(text);
|
|
4115
|
+
} catch (error) {
|
|
4116
|
+
throw new UsageError(`${source}: not valid JSON (${firstLine2(error)})`);
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
function parseYamlDocument(text, source) {
|
|
4120
|
+
const document = parseDocument(text, { resolveKnownTags: false });
|
|
4121
|
+
const problem = document.errors[0] ?? document.warnings[0];
|
|
4122
|
+
if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine2(problem)})`);
|
|
4123
|
+
let value;
|
|
4124
|
+
try {
|
|
4125
|
+
value = document.toJS();
|
|
4126
|
+
} catch (error) {
|
|
4127
|
+
throw new UsageError(`${source}: not valid YAML (${firstLine2(error)})`);
|
|
4128
|
+
}
|
|
4129
|
+
if (!jsonSafe(value)) {
|
|
4130
|
+
throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
|
|
4131
|
+
}
|
|
4132
|
+
return value;
|
|
4133
|
+
}
|
|
4134
|
+
function jsonSafe(value) {
|
|
4135
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
4136
|
+
if (Array.isArray(value)) return value.every(jsonSafe);
|
|
4137
|
+
if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
|
|
4138
|
+
return true;
|
|
4139
|
+
}
|
|
4140
|
+
function firstLine2(error) {
|
|
4141
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
4142
|
+
}
|
|
4143
|
+
function readPlanFile(path2, flag = "--file") {
|
|
4144
|
+
return parsePlanDocument(readFileOption(flag, path2), path2);
|
|
4145
|
+
}
|
|
4146
|
+
async function planCreate(ctx, plan, opts) {
|
|
4147
|
+
requireDocument(plan, "a plan is one object: the root's fields and its steps");
|
|
4148
|
+
const team = requiredTeam(ctx, opts.team);
|
|
4149
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
4150
|
+
let body;
|
|
4151
|
+
try {
|
|
4152
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/plans`, plan, { idempotencyKey: opts.idempotencyKey });
|
|
4153
|
+
} catch (error) {
|
|
4154
|
+
throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
|
|
4155
|
+
}
|
|
4156
|
+
const created = body.data;
|
|
4157
|
+
const id = str7(created["id"]);
|
|
4158
|
+
return {
|
|
4159
|
+
json: created,
|
|
4160
|
+
human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
|
|
4161
|
+
|
|
4162
|
+
` + planDetail(created) + `
|
|
4163
|
+
Nothing runs until it is released: harness plan release ${clean(id)}
|
|
4164
|
+
`
|
|
4165
|
+
};
|
|
4166
|
+
}
|
|
4167
|
+
async function planShow(ctx, id) {
|
|
4168
|
+
requirePlanId(id);
|
|
4169
|
+
const plan = await planRequest(id, () => ctx.client.get(`/api/v1/plans/${id}`));
|
|
4170
|
+
return { json: plan, human: planDetail(plan) };
|
|
4171
|
+
}
|
|
4172
|
+
async function planEdit(ctx, id, edits) {
|
|
4173
|
+
requirePlanId(id);
|
|
4174
|
+
requireDocument(edits, "a plan edit is one object: steps to change and budgets");
|
|
4175
|
+
const plan = await planRequest(id, () => ctx.client.patch(`/api/v1/plans/${id}`, edits));
|
|
4176
|
+
const held = Array.isArray(plan["held_steps"]) && plan["held_steps"].length > 0;
|
|
4177
|
+
return {
|
|
4178
|
+
json: plan,
|
|
4179
|
+
human: `Updated plan ${clean(id)}
|
|
4180
|
+
|
|
4181
|
+
` + planDetail(plan) + (held ? `
|
|
4182
|
+
Release it again to run the held steps: harness plan release ${clean(id)}
|
|
4183
|
+
` : "")
|
|
4184
|
+
};
|
|
4185
|
+
}
|
|
4186
|
+
async function planRelease(ctx, id, opts) {
|
|
4187
|
+
requirePlanId(id);
|
|
4188
|
+
const body = {};
|
|
4189
|
+
if (opts.collaborators !== void 0 && opts.collaborators.length > 0) body["collaborators"] = opts.collaborators;
|
|
4190
|
+
if (opts.retryBudget !== void 0) body["retry_budget"] = opts.retryBudget;
|
|
4191
|
+
if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
|
|
4192
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
|
|
4193
|
+
const revision = plan["revision"] ?? {};
|
|
4194
|
+
const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord5(step) && step["ready"] === true).length : 0;
|
|
4195
|
+
return {
|
|
4196
|
+
json: plan,
|
|
4197
|
+
human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
|
|
4198
|
+
`
|
|
4199
|
+
};
|
|
4200
|
+
}
|
|
4201
|
+
async function planHold(ctx, id) {
|
|
4202
|
+
requirePlanId(id);
|
|
4203
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/hold`));
|
|
4204
|
+
return {
|
|
4205
|
+
json: plan,
|
|
4206
|
+
human: `Held plan ${clean(id)}: none of its steps is ready until it is released again
|
|
4207
|
+
`
|
|
4208
|
+
};
|
|
4209
|
+
}
|
|
4210
|
+
async function gateSet(ctx, issue, gate, satisfied) {
|
|
4211
|
+
requireIdentifiers([issue]);
|
|
4212
|
+
const wanted = gate.trim();
|
|
4213
|
+
if (wanted === "") throw new UsageError("name the gate by its key, its label or its condition id");
|
|
4214
|
+
const expert = isExpertToken(ctx.config.token);
|
|
4215
|
+
const list3 = await readConditions(ctx, issue, expert);
|
|
4216
|
+
const target = findGate(list3, issue, wanted, expert);
|
|
4217
|
+
const id = str7(target["id"]);
|
|
4218
|
+
let body;
|
|
4219
|
+
try {
|
|
4220
|
+
body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, { satisfied });
|
|
4221
|
+
} catch (error) {
|
|
4222
|
+
throw await explainNotGateOwner(ctx, error, target, issue, satisfied);
|
|
4223
|
+
}
|
|
4224
|
+
const others = list3.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
|
|
4225
|
+
let tail = "";
|
|
4226
|
+
if (satisfied && expert) {
|
|
4227
|
+
tail = others > 0 ? ` \u2014 ${others} other gate${others === 1 ? "" : "s"} of yours on it ${others === 1 ? "is" : "are"} open` : "";
|
|
4228
|
+
} else if (satisfied) {
|
|
4229
|
+
tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
|
|
4230
|
+
}
|
|
4231
|
+
return {
|
|
4232
|
+
json: body.data,
|
|
4233
|
+
human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(target["label"])}" on ${issue}${tail}
|
|
4234
|
+
`
|
|
4235
|
+
};
|
|
4236
|
+
}
|
|
4237
|
+
async function conditionList(ctx, issue) {
|
|
4238
|
+
requireIdentifiers([issue]);
|
|
4239
|
+
const expert = isExpertToken(ctx.config.token);
|
|
4240
|
+
const list3 = await readConditions(ctx, issue, expert);
|
|
4241
|
+
let human;
|
|
4242
|
+
if (list3.length === 0) {
|
|
4243
|
+
human = expert ? `You own no gate on ${issue}.
|
|
4244
|
+
` : `No wait conditions on ${issue}.
|
|
4245
|
+
`;
|
|
4246
|
+
} else {
|
|
4247
|
+
human = (expert ? `Your gates on ${issue}:
|
|
4248
|
+
` : `Wait conditions on ${issue}:
|
|
4249
|
+
`) + list3.map((condition) => ` ${conditionLine(condition, { expertView: expert })}
|
|
4250
|
+
`).join("");
|
|
4251
|
+
}
|
|
4252
|
+
return { json: list3, human };
|
|
4253
|
+
}
|
|
4254
|
+
async function readConditions(ctx, issue, expert) {
|
|
4255
|
+
let conditions;
|
|
4256
|
+
try {
|
|
4257
|
+
conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
|
|
4258
|
+
} catch (error) {
|
|
4259
|
+
throw explainMissing(
|
|
4260
|
+
error,
|
|
4261
|
+
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`
|
|
4262
|
+
);
|
|
4263
|
+
}
|
|
4264
|
+
if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
|
|
4265
|
+
return conditions.filter(isRecord5);
|
|
4266
|
+
}
|
|
4267
|
+
function requirePlanId(id) {
|
|
4268
|
+
if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
|
|
4269
|
+
}
|
|
4270
|
+
function requireDocument(value, what) {
|
|
4271
|
+
if (!isRecord5(value)) throw new UsageError(what);
|
|
4272
|
+
}
|
|
4273
|
+
async function planRequest(id, send) {
|
|
4274
|
+
try {
|
|
4275
|
+
return (await send()).data;
|
|
4276
|
+
} catch (error) {
|
|
4277
|
+
throw explainMissing(error, `no plan ${id} in this organization, or this Harness server has no plan API yet`);
|
|
4278
|
+
}
|
|
4279
|
+
}
|
|
4280
|
+
function explainMissing(error, why) {
|
|
4281
|
+
if (!(error instanceof HttpError) || error.status !== 404) return error;
|
|
4282
|
+
return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
|
|
4283
|
+
}
|
|
4284
|
+
async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
|
|
4285
|
+
if (!(error instanceof HttpError) || error.status !== 403 || error.body["reason"] !== "not_gate_owner") return error;
|
|
4286
|
+
let current;
|
|
4287
|
+
let currentOwner = error.body["owner"];
|
|
4288
|
+
if (currentOwner !== null && !isRecord5(currentOwner)) {
|
|
4289
|
+
try {
|
|
4290
|
+
const fresh = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
|
|
4291
|
+
if (Array.isArray(fresh)) current = fresh.filter(isRecord5).find((condition) => condition["id"] === gate["id"]);
|
|
4292
|
+
} catch {
|
|
4293
|
+
}
|
|
4294
|
+
currentOwner = current?.["owner"];
|
|
4295
|
+
}
|
|
4296
|
+
const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
|
|
4297
|
+
const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
|
|
4298
|
+
const named = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
|
|
4299
|
+
const message = satisfied ? `Cannot satisfy ${named}: only ${subject} can tick it` : `Cannot un-satisfy ${named}: only ${subject} or Harness staff can untick it`;
|
|
4300
|
+
return new HttpError(error.status, message, error.body, error.retryAfter);
|
|
4301
|
+
}
|
|
4302
|
+
var gateLabel = (condition) => str7(condition["label"]).trim();
|
|
4303
|
+
var gateKey = (condition) => str7(condition["key"]);
|
|
4304
|
+
function exactMatches(conditions, wanted) {
|
|
4305
|
+
const id = isUuid(wanted) ? wanted.toLowerCase() : null;
|
|
4306
|
+
return conditions.filter((condition) => {
|
|
4307
|
+
if (id !== null && str7(condition["id"]).toLowerCase() === id) return true;
|
|
4308
|
+
if (condition["kind"] !== "gate") return false;
|
|
4309
|
+
const key = gateKey(condition);
|
|
4310
|
+
return key !== "" && key === (id ?? wanted) || gateLabel(condition) === wanted;
|
|
4311
|
+
});
|
|
4312
|
+
}
|
|
4313
|
+
function ambiguous(conditions, issue, wanted, matches) {
|
|
4314
|
+
const listed = matches.map((condition) => {
|
|
4315
|
+
const what = condition["kind"] === "gate" ? `gate ${gateName({ label: gateLabel(condition), key: gateKey(condition) })}` : `${clean(condition["kind"])} condition`;
|
|
4316
|
+
return `${clean(condition["id"])} (${what})`;
|
|
4317
|
+
}).join(", ");
|
|
4318
|
+
const unnameable = matches.map((condition) => str7(condition["id"])).filter((id) => exactMatches(conditions, id).length !== 1).map((id) => clean(id));
|
|
4319
|
+
const caveat = unnameable.length === 0 ? "" : ` (${unnameable.join(", ")} ${unnameable.length === 1 ? "is" : "are"} also another gate's key or label, so ${unnameable.length === 1 ? "it" : "they"} cannot be named by id)`;
|
|
4320
|
+
return new CommandError(
|
|
4321
|
+
`several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean${caveat}`
|
|
4322
|
+
);
|
|
4323
|
+
}
|
|
4324
|
+
function findGate(conditions, issue, wanted, expert = false) {
|
|
4325
|
+
const exact = exactMatches(conditions, wanted);
|
|
4326
|
+
if (exact.length > 1) throw ambiguous(conditions, issue, wanted, exact);
|
|
4327
|
+
if (exact.length === 1) {
|
|
4328
|
+
const only = exact[0];
|
|
4329
|
+
if (only["kind"] !== "gate") {
|
|
4330
|
+
throw new CommandError(
|
|
4331
|
+
`condition ${wanted} on ${issue} is a ${clean(only["kind"])} condition \u2014 only a gate is satisfied by hand`
|
|
4332
|
+
);
|
|
4333
|
+
}
|
|
4334
|
+
return only;
|
|
4335
|
+
}
|
|
4336
|
+
const gates = conditions.filter((condition) => condition["kind"] === "gate");
|
|
4337
|
+
const folded = wanted.toLowerCase();
|
|
4338
|
+
const loose = gates.filter(
|
|
4339
|
+
(condition) => gateLabel(condition).toLowerCase() === folded || gateKey(condition) !== "" && gateKey(condition) === folded
|
|
4340
|
+
);
|
|
4341
|
+
if (loose.length > 1) throw ambiguous(conditions, issue, wanted, loose);
|
|
4342
|
+
if (loose.length === 1) return loose[0];
|
|
4343
|
+
if (isUuid(wanted)) throw new CommandError(`No condition ${wanted} on ${issue}${expert ? " that you own" : ""}.`);
|
|
4344
|
+
const none = expert ? `No gate of yours on ${issue}` : `No gate on ${issue}`;
|
|
4345
|
+
if (gates.length === 0) {
|
|
4346
|
+
throw new CommandError(`${none} matches "${wanted}" \u2014 ${expert ? "you own no gate on it" : "it has no gates"}`);
|
|
4347
|
+
}
|
|
4348
|
+
const names = gates.map((condition) => gateName({ label: gateLabel(condition), key: gateKey(condition) })).join(", ");
|
|
4349
|
+
throw new CommandError(`${none} matches "${wanted}". ${expert ? "Your" : "Its"} gates: ${names}`);
|
|
4350
|
+
}
|
|
4351
|
+
function isRecord5(value) {
|
|
4352
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4353
|
+
}
|
|
4354
|
+
function str7(value) {
|
|
4355
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
// src/commands/expert.ts
|
|
4359
|
+
async function assignments(ctx) {
|
|
4360
|
+
const data = (await ctx.client.get(EXPERT_ASSIGNMENTS_PATH)).data;
|
|
4361
|
+
if (!isRecord6(data) || !Array.isArray(data["assignments"])) {
|
|
4362
|
+
throw new CommandError("The assignments response carried no list.");
|
|
4363
|
+
}
|
|
4364
|
+
const steps = data["assignments"].filter(isRecord6);
|
|
4365
|
+
const expert = isRecord6(data["expert"]) ? data["expert"] : {};
|
|
4366
|
+
const plan = isRecord6(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
|
|
4367
|
+
const header = expertSummary({ role: expert["role"], plan, assigned_steps: steps.length });
|
|
4368
|
+
return { json: data, human: `${header}
|
|
4369
|
+
${expertAssignments(steps)}` };
|
|
4370
|
+
}
|
|
4371
|
+
function isRecord6(value) {
|
|
4372
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4375
|
+
// src/core/credentials.ts
|
|
4376
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
4377
|
+
import { homedir } from "os";
|
|
4378
|
+
import { dirname as dirname2, isAbsolute, join as join2 } from "path";
|
|
4379
|
+
function defaultCredentialsPath(env = process.env) {
|
|
4380
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
4381
|
+
const base = xdg !== void 0 && xdg !== "" && isAbsolute(xdg) ? xdg : join2(homedir(), ".config");
|
|
4382
|
+
return join2(base, "harness", "credentials.json");
|
|
4383
|
+
}
|
|
4384
|
+
function readCredentials(path2) {
|
|
4385
|
+
try {
|
|
4386
|
+
const parsed = JSON.parse(readFileSync4(path2, "utf8"));
|
|
4387
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
4388
|
+
const { url, token } = parsed;
|
|
4389
|
+
if (typeof url === "string" && typeof token === "string") return { url, token };
|
|
4390
|
+
}
|
|
4391
|
+
} catch {
|
|
4392
|
+
}
|
|
4393
|
+
return null;
|
|
4394
|
+
}
|
|
4395
|
+
var uniqueCounter = 0;
|
|
4396
|
+
function writeCredentials(path2, url, token) {
|
|
4397
|
+
mkdirSync2(dirname2(path2), { recursive: true });
|
|
4398
|
+
const tempPath = `${path2}.${process.pid}.${++uniqueCounter}.tmp`;
|
|
4399
|
+
try {
|
|
4400
|
+
writeFileSync2(tempPath, "", { mode: 384 });
|
|
4401
|
+
chmodSync2(tempPath, 384);
|
|
4402
|
+
writeFileSync2(tempPath, JSON.stringify({ url, token }));
|
|
4403
|
+
renameSync2(tempPath, path2);
|
|
4404
|
+
} finally {
|
|
4405
|
+
rmSync2(tempPath, { force: true });
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
function deleteCredentials(path2) {
|
|
4409
|
+
rmSync2(path2, { force: true });
|
|
4410
|
+
}
|
|
4411
|
+
|
|
4412
|
+
// src/core/repo-config.ts
|
|
4413
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
4414
|
+
import { homedir as homedir2 } from "os";
|
|
4415
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
|
|
4416
|
+
var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
|
|
4417
|
+
function findRepoConfig(startDir = process.cwd()) {
|
|
4418
|
+
let dir = startDir;
|
|
4419
|
+
const { root } = parse(startDir);
|
|
4420
|
+
for (; ; ) {
|
|
4421
|
+
const candidate = join3(dir, CONFIG_RELATIVE_PATH);
|
|
4422
|
+
if (existsSync(candidate)) {
|
|
4423
|
+
return readRepoConfig(candidate);
|
|
4424
|
+
}
|
|
4425
|
+
if (dir === root) return { config: null, warnings: [] };
|
|
4426
|
+
dir = dirname3(dir);
|
|
4427
|
+
}
|
|
4428
|
+
}
|
|
4429
|
+
function readRepoConfig(path2) {
|
|
4430
|
+
let parsed;
|
|
4431
|
+
try {
|
|
4432
|
+
parsed = JSON.parse(readFileSync5(path2, "utf8"));
|
|
4433
|
+
} catch (error) {
|
|
4434
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
4435
|
+
return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
|
|
4436
|
+
}
|
|
4437
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
4438
|
+
return { config: null, warnings: [`harness: ignoring ${path2} \u2014 expected a JSON object`] };
|
|
4439
|
+
}
|
|
4440
|
+
const record = parsed;
|
|
4441
|
+
const config = { path: path2 };
|
|
4442
|
+
const warnings = [];
|
|
4443
|
+
for (const field3 of ["team", "url", "org"]) {
|
|
4444
|
+
const value = record[field3];
|
|
4445
|
+
if (value === void 0) continue;
|
|
4446
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
4447
|
+
config[field3] = value.trim();
|
|
4448
|
+
} else {
|
|
4449
|
+
warnings.push(`harness: ignoring "${field3}" in ${path2} \u2014 expected a non-empty string`);
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4452
|
+
return { config, warnings };
|
|
4453
|
+
}
|
|
4454
|
+
function trustedHostsPath(env = process.env) {
|
|
4455
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
4456
|
+
const base = xdg !== void 0 && xdg !== "" && isAbsolute2(xdg) ? xdg : join3(homedir2(), ".config");
|
|
4457
|
+
return join3(base, "harness", "trusted-hosts.json");
|
|
4458
|
+
}
|
|
4459
|
+
function readTrustedHosts(env) {
|
|
4460
|
+
try {
|
|
4461
|
+
const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
|
|
4462
|
+
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
|
|
4463
|
+
return parsed["hosts"].filter(
|
|
4464
|
+
(host) => typeof host === "string"
|
|
4465
|
+
);
|
|
4466
|
+
}
|
|
4467
|
+
} catch {
|
|
4468
|
+
}
|
|
4469
|
+
return [];
|
|
4470
|
+
}
|
|
4471
|
+
function isHostTrusted(baseUrl, env) {
|
|
4472
|
+
return readTrustedHosts(env).includes(baseUrl);
|
|
4473
|
+
}
|
|
4474
|
+
function trustHost(baseUrl, env) {
|
|
4475
|
+
const hosts = readTrustedHosts(env);
|
|
4476
|
+
if (hosts.includes(baseUrl)) return;
|
|
4477
|
+
const path2 = trustedHostsPath(env);
|
|
4478
|
+
mkdirSync3(dirname3(path2), { recursive: true });
|
|
4479
|
+
writeFileSync3(path2, JSON.stringify({ hosts: [...hosts, baseUrl] }, null, 2) + "\n");
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// src/core/config.ts
|
|
4483
|
+
import { homedir as homedir3, tmpdir } from "os";
|
|
4484
|
+
import { isAbsolute as isAbsolute3, join as join4 } from "path";
|
|
4485
|
+
var DEFAULT_BASE_URL = "https://harness.infra.one";
|
|
4486
|
+
function resolveConfig(env = process.env, credentialsPath, repo) {
|
|
4487
|
+
let warnings = [];
|
|
4488
|
+
if (repo === void 0) {
|
|
4489
|
+
const lookup = findRepoConfig();
|
|
4490
|
+
repo = lookup.config;
|
|
4491
|
+
warnings = lookup.warnings;
|
|
4492
|
+
}
|
|
4493
|
+
const path2 = credentialsPath ?? defaultCredentialsPath(env);
|
|
4494
|
+
const stored = readCredentials(path2);
|
|
4495
|
+
const envUrl = blankToNull(env["HARNESS_API_URL"]);
|
|
4496
|
+
const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
|
|
4497
|
+
const repoUrl = repo?.url?.replace(/\/+$/, "") ?? null;
|
|
4498
|
+
let rawUrl;
|
|
4499
|
+
let hostSource;
|
|
4500
|
+
if (envUrl !== null) {
|
|
4501
|
+
rawUrl = envUrl;
|
|
4502
|
+
hostSource = "env";
|
|
4503
|
+
} else if (envToken !== null) {
|
|
4504
|
+
if (repoUrl !== null && normalize(repoUrl) !== normalize(DEFAULT_BASE_URL)) {
|
|
4505
|
+
return {
|
|
4506
|
+
kind: "error",
|
|
4507
|
+
message: `this repo's config (${repo?.path}) targets ${repoUrl}, but HARNESS_API_TOKEN only travels to the host the environment names \u2014 set HARNESS_API_URL explicitly to pair them`,
|
|
4508
|
+
warnings
|
|
4509
|
+
};
|
|
4510
|
+
}
|
|
4511
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
4512
|
+
hostSource = "default";
|
|
4513
|
+
} else if (repoUrl !== null) {
|
|
4514
|
+
rawUrl = repoUrl;
|
|
4515
|
+
hostSource = "repo-config";
|
|
4516
|
+
} else if (stored !== null) {
|
|
4517
|
+
rawUrl = stored.url;
|
|
4518
|
+
hostSource = "stored-login";
|
|
4519
|
+
} else {
|
|
4520
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
4521
|
+
hostSource = "default";
|
|
4522
|
+
}
|
|
4523
|
+
const validated = validateBaseUrl(rawUrl);
|
|
4524
|
+
if (!validated.ok) return { kind: "error", message: validated.message, warnings };
|
|
4525
|
+
const baseUrl = rawUrl.replace(/\/+$/, "");
|
|
4526
|
+
let token;
|
|
4527
|
+
if (envToken !== null) {
|
|
4528
|
+
token = envToken;
|
|
4529
|
+
} else if (stored !== null && normalize(stored.url) !== null && normalize(stored.url) === normalize(baseUrl)) {
|
|
4530
|
+
token = stored.token;
|
|
4531
|
+
} else {
|
|
4532
|
+
const result = { kind: "needs-login", baseUrl, hostSource, warnings };
|
|
4533
|
+
if (hostSource === "repo-config" && repo?.path !== void 0) result.repoConfigPath = repo.path;
|
|
4534
|
+
return result;
|
|
4535
|
+
}
|
|
4536
|
+
return {
|
|
4537
|
+
kind: "ok",
|
|
4538
|
+
config: {
|
|
4539
|
+
baseUrl,
|
|
4540
|
+
token,
|
|
4541
|
+
actor: blankToNull(env["HARNESS_ACTOR"]),
|
|
4542
|
+
// The committed team is the fallback: the environment (and, above it,
|
|
4543
|
+
// the --team flag at the command layer) stays authoritative.
|
|
4544
|
+
team: blankToNull(env["HARNESS_TEAM"]) ?? repo?.team ?? null,
|
|
4545
|
+
cacheDir: resolveCacheDir(env),
|
|
4546
|
+
hostSource
|
|
4547
|
+
},
|
|
4548
|
+
warnings
|
|
4549
|
+
};
|
|
4550
|
+
}
|
|
4551
|
+
function resolveCacheDir(env = process.env) {
|
|
4552
|
+
return absolute(env["HARNESS_CACHE_DIR"]) ?? join4(resolveCacheHome(env), "harness", "attachments");
|
|
4553
|
+
}
|
|
4554
|
+
function absolute(value) {
|
|
4555
|
+
return value !== void 0 && value !== "" && isAbsolute3(value) ? value : null;
|
|
4556
|
+
}
|
|
4557
|
+
function resolveCacheHome(env = process.env) {
|
|
4558
|
+
return absolute(env["XDG_CACHE_HOME"]) ?? join4(homedir3() || tmpdir(), ".cache");
|
|
4559
|
+
}
|
|
4560
|
+
function normalize(url) {
|
|
4561
|
+
let parsed;
|
|
4562
|
+
try {
|
|
4563
|
+
parsed = new URL(url);
|
|
4564
|
+
} catch {
|
|
4565
|
+
return null;
|
|
4566
|
+
}
|
|
4567
|
+
const port = parsed.port !== "" ? parsed.port : parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "";
|
|
4568
|
+
return JSON.stringify([parsed.protocol, parsed.hostname, port, parsed.pathname.replace(/\/+$/, "")]);
|
|
4569
|
+
}
|
|
4570
|
+
function validateBaseUrl(baseUrl) {
|
|
4571
|
+
let parsed;
|
|
4572
|
+
try {
|
|
4573
|
+
parsed = new URL(baseUrl);
|
|
4574
|
+
} catch {
|
|
4575
|
+
return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
|
|
4576
|
+
}
|
|
4577
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
4578
|
+
return { ok: false, message: "HARNESS_API_URL must not embed credentials (user:pass@) \u2014 use HARNESS_API_TOKEN" };
|
|
4579
|
+
}
|
|
4580
|
+
if (parsed.search !== "" || parsed.hash !== "") {
|
|
4581
|
+
return { ok: false, message: `HARNESS_API_URL must not carry a query or fragment, got: ${baseUrl}` };
|
|
4582
|
+
}
|
|
4583
|
+
if (parsed.protocol === "https:" && parsed.hostname !== "") return { ok: true };
|
|
4584
|
+
if (parsed.protocol === "http:") {
|
|
4585
|
+
if (["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname)) return { ok: true };
|
|
4586
|
+
return {
|
|
4587
|
+
ok: false,
|
|
4588
|
+
message: `refusing plain-http HARNESS_API_URL (${baseUrl}) \u2014 the API token would travel in cleartext. Use https, or http against localhost only.`
|
|
4589
|
+
};
|
|
4590
|
+
}
|
|
4591
|
+
return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
|
|
4592
|
+
}
|
|
4593
|
+
function loginHelp(baseUrl) {
|
|
4594
|
+
return `Not signed in to ${baseUrl}.
|
|
4595
|
+
|
|
4596
|
+
Interactively, in a terminal (not from inside an agent \u2014 the browser
|
|
4597
|
+
tab needs someone to see it):
|
|
4598
|
+
|
|
4599
|
+
harness login
|
|
4600
|
+
|
|
4601
|
+
For agents and CI, mint an API token (admin \u2192 API tokens) with the
|
|
4602
|
+
harness:read scope \u2014 plus harness:write only where the workflow
|
|
4603
|
+
creates or closes \u2014 set an expiry, and export it:
|
|
4604
|
+
|
|
4605
|
+
export HARNESS_API_TOKEN=aone_...
|
|
4606
|
+
|
|
4607
|
+
An outside expert uses the pxc_ credential minted in their expert view:
|
|
4608
|
+
export it the same way, or paste it into \`harness login --no-browser\`.
|
|
4609
|
+
`;
|
|
4610
|
+
}
|
|
4611
|
+
function blankToNull(value) {
|
|
4612
|
+
if (value === void 0 || value.trim() === "") return null;
|
|
4613
|
+
return value;
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
export {
|
|
4617
|
+
HttpError,
|
|
4618
|
+
UpgradeRequiredError,
|
|
4619
|
+
TransportError,
|
|
4620
|
+
UsageError,
|
|
4621
|
+
LocalError,
|
|
4622
|
+
CommandError,
|
|
4623
|
+
CLI_VERSION,
|
|
4624
|
+
CLI_PACKAGE,
|
|
4625
|
+
EXPERT_ASSIGNMENTS_PATH,
|
|
4626
|
+
isExpertToken,
|
|
4627
|
+
EXPERT_LAPSED,
|
|
4628
|
+
explainExpertRefusal,
|
|
4629
|
+
Client,
|
|
4630
|
+
clean,
|
|
4631
|
+
json,
|
|
4632
|
+
expertSummary,
|
|
4633
|
+
errorDetails,
|
|
4634
|
+
MAX_IDEMPOTENCY_KEY_BYTES,
|
|
4635
|
+
IDEMPOTENCY_KEY_FORMAT,
|
|
4636
|
+
requireIdentifiers,
|
|
4637
|
+
ready,
|
|
4638
|
+
requireReadyClaimArgs,
|
|
4639
|
+
blocked,
|
|
4640
|
+
list,
|
|
4641
|
+
search,
|
|
4642
|
+
show,
|
|
4643
|
+
ACTIVITY_MAX_LIMIT,
|
|
4644
|
+
activity,
|
|
4645
|
+
stats2 as stats,
|
|
4646
|
+
prime,
|
|
4647
|
+
version,
|
|
4648
|
+
create,
|
|
4649
|
+
validateIdempotencyKey,
|
|
4650
|
+
update,
|
|
4651
|
+
requireCloseArgs,
|
|
4652
|
+
close,
|
|
4653
|
+
archive,
|
|
4654
|
+
comments,
|
|
4655
|
+
dep,
|
|
4656
|
+
RELATION_CREATE_TYPES,
|
|
4657
|
+
RELATION_UPDATE_TYPES,
|
|
4658
|
+
relationList,
|
|
4659
|
+
relationAdd,
|
|
4660
|
+
relationUpdate,
|
|
4661
|
+
relationRm,
|
|
4662
|
+
comment,
|
|
4663
|
+
editComment,
|
|
4664
|
+
deleteComment,
|
|
4665
|
+
requireCommentId,
|
|
4666
|
+
attachImage,
|
|
4667
|
+
requiredTeam,
|
|
4668
|
+
projectList,
|
|
4669
|
+
projectShow,
|
|
4670
|
+
projectCreate,
|
|
4671
|
+
projectUpdate,
|
|
4672
|
+
milestoneList,
|
|
4673
|
+
milestoneCreate,
|
|
4674
|
+
docList,
|
|
4675
|
+
docShow,
|
|
4676
|
+
docCreate,
|
|
4677
|
+
docUpdate,
|
|
4678
|
+
linkList,
|
|
4679
|
+
linkAdd,
|
|
4680
|
+
linkUpdate,
|
|
4681
|
+
linkRm,
|
|
4682
|
+
labelList2 as labelList,
|
|
4683
|
+
labelCreate,
|
|
4684
|
+
teamCreate,
|
|
4685
|
+
teamList,
|
|
4686
|
+
workflowStates,
|
|
4687
|
+
userList,
|
|
4688
|
+
teamRename,
|
|
4689
|
+
teamSetDefault,
|
|
4690
|
+
stateAdd,
|
|
4691
|
+
stateUpdate,
|
|
4692
|
+
stateOrder,
|
|
4693
|
+
stateDelete,
|
|
4694
|
+
labelUpdate,
|
|
4695
|
+
labelDelete,
|
|
4696
|
+
memberList,
|
|
4697
|
+
memberAdd,
|
|
4698
|
+
memberSetRole,
|
|
4699
|
+
upload,
|
|
4700
|
+
toolsList,
|
|
4701
|
+
toolsSearch,
|
|
4702
|
+
toolsDomains,
|
|
4703
|
+
toolShow,
|
|
4704
|
+
skillsList,
|
|
4705
|
+
requireCapabilityId,
|
|
4706
|
+
requireSearchQuery,
|
|
4707
|
+
requireToolsOpts,
|
|
4708
|
+
requireSchemaView,
|
|
4709
|
+
JOB_STATES,
|
|
4710
|
+
JOB_STATUS_ALIASES,
|
|
4711
|
+
JOBS_MAX_LIMIT,
|
|
4712
|
+
JOBS_MAX_PAGES,
|
|
4713
|
+
EXIT_WAITING_FOR_PERSON,
|
|
4714
|
+
EXIT_JOB_ENDED,
|
|
4715
|
+
EXIT_INTERRUPTED,
|
|
4716
|
+
requireJobsArgs,
|
|
4717
|
+
jobsList,
|
|
4718
|
+
requireJobId,
|
|
4719
|
+
jobShow,
|
|
4720
|
+
jobWatch,
|
|
4721
|
+
watchStopSignal,
|
|
4722
|
+
jobContinue,
|
|
4723
|
+
resolveTextSources,
|
|
4724
|
+
STEER_MAX_CHARACTERS,
|
|
4725
|
+
instructionFrom,
|
|
4726
|
+
jobSteer,
|
|
4727
|
+
jobCancel,
|
|
4728
|
+
jobPause,
|
|
4729
|
+
requireProcedureId,
|
|
4730
|
+
requireProcedureName,
|
|
4731
|
+
procedureSave,
|
|
4732
|
+
procedureShow,
|
|
4733
|
+
prepareProcedureRun,
|
|
4734
|
+
startProcedureRun,
|
|
4735
|
+
procedureRun,
|
|
4736
|
+
PLAN_EXECUTORS,
|
|
4737
|
+
CONDITION_KINDS,
|
|
4738
|
+
GATE_OWNER_ROLES,
|
|
4739
|
+
readPlanFile,
|
|
4740
|
+
planCreate,
|
|
4741
|
+
planShow,
|
|
4742
|
+
planEdit,
|
|
4743
|
+
planRelease,
|
|
4744
|
+
planHold,
|
|
4745
|
+
gateSet,
|
|
4746
|
+
conditionList,
|
|
4747
|
+
requirePlanId,
|
|
4748
|
+
assignments,
|
|
4749
|
+
defaultCredentialsPath,
|
|
4750
|
+
readCredentials,
|
|
4751
|
+
writeCredentials,
|
|
4752
|
+
deleteCredentials,
|
|
4753
|
+
findRepoConfig,
|
|
4754
|
+
isHostTrusted,
|
|
4755
|
+
trustHost,
|
|
4756
|
+
DEFAULT_BASE_URL,
|
|
4757
|
+
resolveConfig,
|
|
4758
|
+
resolveCacheHome,
|
|
4759
|
+
validateBaseUrl,
|
|
4760
|
+
loginHelp
|
|
4761
|
+
};
|