@allocator-one/harness-cli 1.16.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/dist/{chunk-FQWV5MIC.js → chunk-5THYIKLW.js} +26 -4
- package/dist/chunk-A4WZAC7L.js +563 -0
- package/dist/chunk-BHBEIT5I.js +1348 -0
- package/dist/{chunk-GVTZKAXC.js → chunk-CEEQW6ZV.js} +299 -1592
- package/dist/chunk-W7BVOQQG.js +1448 -0
- package/dist/chunk-WSDFLMO6.js +717 -0
- package/dist/cli.js +1652 -1146
- package/dist/infraone-A3KDADRM.js +249 -0
- package/dist/infraone-V3IKKVBG.js +593 -0
- package/dist/infraone.js +372 -0
- package/dist/{login-page-KGEJFFXR.js → login-page-MFHNVTUV.js} +1 -1
- package/dist/{server-F6YRKNC2.js → server-TRXBTJSA.js} +11 -9
- package/package.json +4 -3
- package/skills/harness-code/SKILL.md +37 -0
- package/spec/openapi.json +3411 -0
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
CLI_PACKAGE,
|
|
4
|
+
CLI_VERSION,
|
|
5
|
+
invokedName
|
|
6
|
+
} from "./chunk-5THYIKLW.js";
|
|
7
|
+
|
|
8
|
+
// src/core/errors.ts
|
|
9
|
+
var HttpError = class extends Error {
|
|
10
|
+
constructor(status, message, body = {}, retryAfter = null) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.body = body;
|
|
14
|
+
this.retryAfter = retryAfter;
|
|
15
|
+
this.name = "HttpError";
|
|
16
|
+
}
|
|
17
|
+
status;
|
|
18
|
+
body;
|
|
19
|
+
retryAfter;
|
|
20
|
+
};
|
|
21
|
+
var UpgradeRequiredError = class extends HttpError {
|
|
22
|
+
constructor(message, body = {}) {
|
|
23
|
+
super(426, message, body);
|
|
24
|
+
this.name = "UpgradeRequiredError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
var TransportError = class extends Error {
|
|
28
|
+
constructor(message, code = null) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.name = "TransportError";
|
|
32
|
+
}
|
|
33
|
+
code;
|
|
34
|
+
};
|
|
35
|
+
var MalformedResponseError = class extends TransportError {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "MalformedResponseError";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var UsageError = class extends Error {
|
|
42
|
+
constructor(message) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "UsageError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
var LocalError = class extends Error {
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = "LocalError";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
var CommandError = class extends Error {
|
|
54
|
+
constructor(message) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "CommandError";
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// src/core/expert.ts
|
|
61
|
+
var EXPERT_TOKEN_PREFIX = "pxc_";
|
|
62
|
+
var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
|
|
63
|
+
function isExpertToken(token) {
|
|
64
|
+
return token.startsWith(EXPERT_TOKEN_PREFIX);
|
|
65
|
+
}
|
|
66
|
+
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)";
|
|
67
|
+
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";
|
|
68
|
+
var NOT_AN_EXPERT = "this credential is a member's login or an API token, not an outside expert's pxc_ credential";
|
|
69
|
+
function explainExpertRefusal(error, token) {
|
|
70
|
+
if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
|
|
71
|
+
const code = error.body["error"];
|
|
72
|
+
let hint;
|
|
73
|
+
if (error.status === 403 && code === "expert_bound_credential") hint = EXPERT_REACH;
|
|
74
|
+
else if (error.status === 403 && code === "expert_credential_required") hint = NOT_AN_EXPERT;
|
|
75
|
+
else if (error.status === 401 && isExpertToken(token)) hint = EXPERT_LAPSED;
|
|
76
|
+
if (hint === void 0) return error;
|
|
77
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/core/client.ts
|
|
81
|
+
var RECEIVE_TIMEOUT_MS = 3e4;
|
|
82
|
+
var UPLOAD_RECEIVE_TIMEOUT_MS = 6e4;
|
|
83
|
+
var UPLOAD_FLOOR_BYTES_PER_MS = 100;
|
|
84
|
+
function uploadTimeoutMs(bodyBytes) {
|
|
85
|
+
return UPLOAD_RECEIVE_TIMEOUT_MS + Math.ceil(bodyBytes / UPLOAD_FLOOR_BYTES_PER_MS);
|
|
86
|
+
}
|
|
87
|
+
var DEFAULT_MESSAGES = {
|
|
88
|
+
401: "Missing or invalid token (check HARNESS_API_TOKEN)",
|
|
89
|
+
403: "Token not permitted (scope or admin gate)",
|
|
90
|
+
404: "Not found",
|
|
91
|
+
409: "Conflict",
|
|
92
|
+
422: "Validation error",
|
|
93
|
+
426: `CLI too old for this API \u2014 upgrade: npm i -g ${CLI_PACKAGE}@latest`,
|
|
94
|
+
429: "Rate limited \u2014 retry later"
|
|
95
|
+
};
|
|
96
|
+
var API_VERSION_HEADER = "x-harness-api-version";
|
|
97
|
+
var STATS_PATH = "/api/v1/stats";
|
|
98
|
+
var SESSION_PATH = "/api/v1/session";
|
|
99
|
+
function isLoginCredential(token) {
|
|
100
|
+
return !token.startsWith("aone_") && !isExpertToken(token);
|
|
101
|
+
}
|
|
102
|
+
var Client = class {
|
|
103
|
+
constructor(config) {
|
|
104
|
+
this.config = config;
|
|
105
|
+
this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
|
|
106
|
+
}
|
|
107
|
+
config;
|
|
108
|
+
fetchImpl;
|
|
109
|
+
get cliVersion() {
|
|
110
|
+
return CLI_VERSION;
|
|
111
|
+
}
|
|
112
|
+
get baseUrl() {
|
|
113
|
+
return this.config.baseUrl;
|
|
114
|
+
}
|
|
115
|
+
async get(path2, params) {
|
|
116
|
+
return this.json(await this.raw("GET", path2, { params }));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `actor: false` keeps a configured actor out of the body, for endpoints
|
|
120
|
+
* whose body is closed: they refuse a field they do not name, and record
|
|
121
|
+
* the credential's own person instead.
|
|
122
|
+
*/
|
|
123
|
+
async post(path2, body = {}, options = {}) {
|
|
124
|
+
return this.json(
|
|
125
|
+
await this.raw("POST", path2, {
|
|
126
|
+
json: options.actor === false ? body : this.withActor(body),
|
|
127
|
+
idempotencyKey: options.idempotencyKey
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
async patch(path2, body) {
|
|
132
|
+
return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
|
|
133
|
+
}
|
|
134
|
+
async put(path2, body) {
|
|
135
|
+
return this.json(await this.raw("PUT", path2, { json: this.withActor(body) }));
|
|
136
|
+
}
|
|
137
|
+
async delete(path2) {
|
|
138
|
+
return this.json(await this.raw("DELETE", path2, {}));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Posts multipart/form-data — the one endpoint that does not take JSON.
|
|
142
|
+
* Shares auth, the version header, and error mapping with the JSON verbs,
|
|
143
|
+
* but never the actor: there is no JSON body to carry an `actor` field,
|
|
144
|
+
* and the server's file contract is the form itself.
|
|
145
|
+
*/
|
|
146
|
+
async postMultipart(path2, form, bodyBytes = 0) {
|
|
147
|
+
return this.json(await this.raw("POST", path2, { form, timeoutMs: uploadTimeoutMs(bodyBytes) }));
|
|
148
|
+
}
|
|
149
|
+
/** Binary download (attachments); non-2xx still maps through the JSON error envelope. */
|
|
150
|
+
async getBytes(path2) {
|
|
151
|
+
const response = await this.raw("GET", path2, {});
|
|
152
|
+
if (!response.ok) throw await this.toHttpError(response);
|
|
153
|
+
try {
|
|
154
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A GET that also hands back the server's API version header, so a probe
|
|
161
|
+
* that verifies a credential reports the version without a second
|
|
162
|
+
* request. Errors map as for `get`.
|
|
163
|
+
*/
|
|
164
|
+
async getVersioned(path2) {
|
|
165
|
+
const response = await this.raw("GET", path2, {});
|
|
166
|
+
const apiVersion = response.headers.get(API_VERSION_HEADER);
|
|
167
|
+
return { envelope: await this.json(response), apiVersion };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The server's API version, for `version`'s server side. The header rides
|
|
171
|
+
* on every response of a route the server has — the versioning plug runs
|
|
172
|
+
* before auth — so even a 401 answers. Each credential probes a route it
|
|
173
|
+
* reaches, so the probe is never a refusal in the server's log: a pxc_
|
|
174
|
+
* credential its assignments, a login credential of either kind (the
|
|
175
|
+
* Operator-only one reaches no tracker read) GET /api/v1/session, and an
|
|
176
|
+
* aone_ token the stats. A server older than GET /api/v1/session answers
|
|
177
|
+
* that route's 404 without the header, so the stats answer then.
|
|
178
|
+
*/
|
|
179
|
+
async serverVersion() {
|
|
180
|
+
const token = this.config.token;
|
|
181
|
+
const path2 = isExpertToken(token) ? EXPERT_ASSIGNMENTS_PATH : isLoginCredential(token) ? SESSION_PATH : STATS_PATH;
|
|
182
|
+
const version = (await this.raw("GET", path2, {})).headers.get(API_VERSION_HEADER);
|
|
183
|
+
if (version !== null || path2 !== SESSION_PATH) return version ?? "unknown";
|
|
184
|
+
return (await this.raw("GET", STATS_PATH, {})).headers.get(API_VERSION_HEADER) ?? "unknown";
|
|
185
|
+
}
|
|
186
|
+
// Only an explicitly configured actor is sent, and never over one the
|
|
187
|
+
// caller already put in the body.
|
|
188
|
+
withActor(body) {
|
|
189
|
+
if (this.config.actor === null || "actor" in body) return body;
|
|
190
|
+
return { ...body, actor: this.config.actor };
|
|
191
|
+
}
|
|
192
|
+
async raw(method, path2, options) {
|
|
193
|
+
const url = new URL(this.config.baseUrl + path2);
|
|
194
|
+
for (const [key, value] of Object.entries(options.params ?? {})) {
|
|
195
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
196
|
+
}
|
|
197
|
+
const headers = {
|
|
198
|
+
authorization: `Bearer ${this.config.token}`,
|
|
199
|
+
"x-harness-cli-version": CLI_VERSION
|
|
200
|
+
};
|
|
201
|
+
if (options.idempotencyKey !== void 0) {
|
|
202
|
+
headers["idempotency-key"] = options.idempotencyKey;
|
|
203
|
+
}
|
|
204
|
+
let body;
|
|
205
|
+
if (options.json !== void 0) {
|
|
206
|
+
headers["content-type"] = "application/json";
|
|
207
|
+
body = JSON.stringify(options.json);
|
|
208
|
+
} else if (options.form !== void 0) {
|
|
209
|
+
body = options.form;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
return await this.fetchImpl(url, {
|
|
213
|
+
method,
|
|
214
|
+
headers,
|
|
215
|
+
body,
|
|
216
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? RECEIVE_TIMEOUT_MS),
|
|
217
|
+
redirect: "manual"
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async json(response) {
|
|
224
|
+
if (!response.ok) throw await this.toHttpError(response);
|
|
225
|
+
let text;
|
|
226
|
+
try {
|
|
227
|
+
text = await response.text();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new TransportError(transportMessage(error), transportCode(error));
|
|
230
|
+
}
|
|
231
|
+
if (text === "") return { data: void 0 };
|
|
232
|
+
let parsed;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(text);
|
|
235
|
+
} catch {
|
|
236
|
+
throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
|
|
237
|
+
}
|
|
238
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
239
|
+
throw new MalformedResponseError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
|
|
240
|
+
}
|
|
241
|
+
return parsed;
|
|
242
|
+
}
|
|
243
|
+
async toHttpError(response) {
|
|
244
|
+
let parsed = {};
|
|
245
|
+
try {
|
|
246
|
+
const candidate = JSON.parse(await response.text());
|
|
247
|
+
if (typeof candidate === "object" && candidate !== null) {
|
|
248
|
+
parsed = candidate;
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
const fromBody = typeof parsed["message"] === "string" ? parsed["message"] : null;
|
|
253
|
+
const message = fromBody ?? DEFAULT_MESSAGES[response.status] ?? `HTTP ${response.status}`;
|
|
254
|
+
if (response.status === 426) return new UpgradeRequiredError(message, parsed);
|
|
255
|
+
return new HttpError(response.status, message, parsed, response.headers.get("retry-after"));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function transportCode(error) {
|
|
259
|
+
const cause = error instanceof Error ? error.cause : void 0;
|
|
260
|
+
if (typeof cause !== "object" || cause === null) return null;
|
|
261
|
+
const code = cause.code;
|
|
262
|
+
if (typeof code === "string") return code;
|
|
263
|
+
const errors = cause.errors;
|
|
264
|
+
if (!Array.isArray(errors)) return null;
|
|
265
|
+
const codes = new Set(errors.map((inner) => inner?.code));
|
|
266
|
+
const [only] = codes;
|
|
267
|
+
return codes.size === 1 && typeof only === "string" ? only : null;
|
|
268
|
+
}
|
|
269
|
+
function transportMessage(error) {
|
|
270
|
+
if (error instanceof Error) {
|
|
271
|
+
if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
|
|
272
|
+
const cause = error.cause;
|
|
273
|
+
if (cause instanceof Error && cause.message !== "") return cause.message;
|
|
274
|
+
return error.message;
|
|
275
|
+
}
|
|
276
|
+
return String(error);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/core/output.ts
|
|
280
|
+
var TITLE_WIDTH = 70;
|
|
281
|
+
var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
282
|
+
function graphemes(text) {
|
|
283
|
+
return [...segmenter.segment(text)].map((piece) => piece.segment);
|
|
284
|
+
}
|
|
285
|
+
function graphemeLength(text) {
|
|
286
|
+
return graphemes(text).length;
|
|
287
|
+
}
|
|
288
|
+
function padGraphemes(text, width) {
|
|
289
|
+
const shortfall = width - graphemeLength(text);
|
|
290
|
+
return shortfall > 0 ? text + " ".repeat(shortfall) : text;
|
|
291
|
+
}
|
|
292
|
+
function clean(value) {
|
|
293
|
+
if (value === null || value === void 0) return "";
|
|
294
|
+
return String(value).replace(/[\x00-\x1F\x7F\u0080-\u009F]/gu, "");
|
|
295
|
+
}
|
|
296
|
+
function json(term) {
|
|
297
|
+
return JSON.stringify(term) + "\n";
|
|
298
|
+
}
|
|
299
|
+
var OPERATOR_TEAM_KEY = /^OPERATOR\d*$/;
|
|
300
|
+
var OPERATOR_TEAM_NAME = "Operator";
|
|
301
|
+
var AGENT_MARK = "[agent]";
|
|
302
|
+
var AGENT_LEGEND = `
|
|
303
|
+
${AGENT_MARK} Operator runs this step for its plan; only the plan driver claims it.
|
|
304
|
+
`;
|
|
305
|
+
function isAgentStep(issue) {
|
|
306
|
+
return OPERATOR_TEAM_KEY.test(str(path(issue, "team", "key"))) && str(path(issue, "team", "name")) === OPERATOR_TEAM_NAME;
|
|
307
|
+
}
|
|
308
|
+
function issuesTable(issues, options = {}) {
|
|
309
|
+
if (issues.length === 0) return "No issues.\n";
|
|
310
|
+
let marked = false;
|
|
311
|
+
const rows = issues.map((issue) => {
|
|
312
|
+
const agentStep = options.markAgentSteps === true && isAgentStep(issue);
|
|
313
|
+
if (agentStep) marked = true;
|
|
314
|
+
return [
|
|
315
|
+
str(issue["identifier"]),
|
|
316
|
+
str(path(issue, "state", "name")) || "-",
|
|
317
|
+
str(issue["priority_label"]) || "-",
|
|
318
|
+
str(path(issue, "assignee", "name")) || "-",
|
|
319
|
+
agentStep ? truncate(`${AGENT_MARK} ${clean(issue["title"])}`) : truncate(issue["title"])
|
|
320
|
+
];
|
|
321
|
+
});
|
|
322
|
+
const rendered = table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
|
|
323
|
+
return marked ? rendered + AGENT_LEGEND : rendered;
|
|
324
|
+
}
|
|
325
|
+
function issueDetail(issue, images = []) {
|
|
326
|
+
const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
|
|
327
|
+
`;
|
|
328
|
+
const fields = [
|
|
329
|
+
["State", path(issue, "state", "name")],
|
|
330
|
+
["Priority", issue["priority_label"]],
|
|
331
|
+
["Assignee", path(issue, "assignee", "name")],
|
|
332
|
+
["Team", path(issue, "team", "key")],
|
|
333
|
+
["Plan", issuePlanLine(issue["plan"])],
|
|
334
|
+
["Labels", labelsLine(issue["labels"])],
|
|
335
|
+
["Blocked", blockedLine(issue)],
|
|
336
|
+
["Waiting", waitingLine(issue)],
|
|
337
|
+
["Due", issue["due_date"]],
|
|
338
|
+
["Updated", issue["updated_at"]]
|
|
339
|
+
];
|
|
340
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
341
|
+
`).join("");
|
|
342
|
+
const sections = [
|
|
343
|
+
["Description", issue["description"]],
|
|
344
|
+
["Design", issue["design"]],
|
|
345
|
+
["Acceptance criteria", issue["acceptance_criteria"]],
|
|
346
|
+
["Notes", issue["notes"]]
|
|
347
|
+
];
|
|
348
|
+
const sectionText = sections.filter(([, text]) => text !== null && text !== void 0 && text !== "").map(([title, text]) => `
|
|
349
|
+
${title}:
|
|
350
|
+
${indent(String(text))}
|
|
351
|
+
`).join("");
|
|
352
|
+
return header + fieldLines + sectionText + conditionsSection(issue["conditions"]) + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
|
|
353
|
+
}
|
|
354
|
+
function activityEntries(entries) {
|
|
355
|
+
if (entries.length === 0) return "No activity.\n";
|
|
356
|
+
return entries.map(activityEntry).join("\n");
|
|
357
|
+
}
|
|
358
|
+
function activityEntry(entry) {
|
|
359
|
+
switch (entry["kind"]) {
|
|
360
|
+
case "comment":
|
|
361
|
+
return activityComment(entry);
|
|
362
|
+
case "state_event":
|
|
363
|
+
return activityStateEvent(entry);
|
|
364
|
+
case "assignee_event":
|
|
365
|
+
return activityAssigneeEvent(entry);
|
|
366
|
+
case "condition_event":
|
|
367
|
+
return activityConditionEvent(entry);
|
|
368
|
+
case "field_event":
|
|
369
|
+
return activityFieldEvent(entry);
|
|
370
|
+
default:
|
|
371
|
+
throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function activityComment(entry) {
|
|
375
|
+
const comment = requiredRecord(entry["comment"], "activity comment");
|
|
376
|
+
const authorValue = comment["author"];
|
|
377
|
+
const author = authorValue === null ? "unknown" : optionalLabel(requiredRecord(authorValue, "comment author")["name"], "unknown", "comment author name");
|
|
378
|
+
const deleted = comment["deleted"];
|
|
379
|
+
if (typeof deleted !== "boolean") throw new CommandError("Invalid comment deleted flag.");
|
|
380
|
+
const body = activityCommentBody(comment["body"], deleted);
|
|
381
|
+
const text = cleanMultiline(body).split("\n").map((line) => " " + line).join("\n");
|
|
382
|
+
return `[${occurredAt(entry)}] ${clean(author)} (${clean(requiredString(comment["id"], "comment id"))}):
|
|
383
|
+
${text}
|
|
384
|
+
`;
|
|
385
|
+
}
|
|
386
|
+
function activityCommentBody(value, deleted) {
|
|
387
|
+
if (deleted) return "[deleted comment]";
|
|
388
|
+
if (value === null || value === "") return "[comment has no text]";
|
|
389
|
+
return requiredString(value, "comment body");
|
|
390
|
+
}
|
|
391
|
+
function activityStateEvent(entry) {
|
|
392
|
+
const actor = activityWho(entry);
|
|
393
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
394
|
+
let description;
|
|
395
|
+
switch (entry["action"]) {
|
|
396
|
+
case "created":
|
|
397
|
+
description = `${actor} created the issue in ${stateName(entry["to_state"], "created to_state")}`;
|
|
398
|
+
break;
|
|
399
|
+
case "transition":
|
|
400
|
+
description = `${actor} moved the issue from ${nullableStateName(entry["from_state"], "transition from_state")} to ${stateName(entry["to_state"], "transition to_state")}`;
|
|
401
|
+
break;
|
|
402
|
+
case "milestone":
|
|
403
|
+
description = `${actor} recorded the ${stateName(entry["to_state"], "milestone to_state")} milestone`;
|
|
404
|
+
break;
|
|
405
|
+
default:
|
|
406
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
407
|
+
}
|
|
408
|
+
return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
|
|
409
|
+
`;
|
|
410
|
+
}
|
|
411
|
+
function activityAssigneeEvent(entry) {
|
|
412
|
+
const actor = activityWho(entry);
|
|
413
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
414
|
+
let description;
|
|
415
|
+
switch (entry["action"]) {
|
|
416
|
+
case "assigned":
|
|
417
|
+
description = `${actor} assigned the issue to ${assigneeName(entry["to_assignee"])}`;
|
|
418
|
+
break;
|
|
419
|
+
case "reassigned":
|
|
420
|
+
description = `${actor} reassigned the issue from ${assigneeName(entry["from_assignee"])} to ${assigneeName(entry["to_assignee"])}`;
|
|
421
|
+
break;
|
|
422
|
+
case "unassigned":
|
|
423
|
+
description = `${actor} unassigned the issue from ${assigneeName(entry["from_assignee"])}`;
|
|
424
|
+
break;
|
|
425
|
+
default:
|
|
426
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
427
|
+
}
|
|
428
|
+
return `[${occurredAt(entry)}] ${description} - ${provenance}
|
|
429
|
+
`;
|
|
430
|
+
}
|
|
431
|
+
function activityConditionEvent(entry) {
|
|
432
|
+
const actor = activityWho(entry);
|
|
433
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
434
|
+
const condition = conditionSubject(requiredRecord(entry["condition"], "activity condition"));
|
|
435
|
+
let description;
|
|
436
|
+
switch (entry["action"]) {
|
|
437
|
+
case "added":
|
|
438
|
+
description = `${actor} added ${condition}`;
|
|
439
|
+
break;
|
|
440
|
+
case "changed":
|
|
441
|
+
description = `${actor} changed ${condition}`;
|
|
442
|
+
break;
|
|
443
|
+
case "removed":
|
|
444
|
+
description = `${actor} removed ${condition}`;
|
|
445
|
+
break;
|
|
446
|
+
case "satisfied":
|
|
447
|
+
description = `${actor} satisfied ${condition}`;
|
|
448
|
+
break;
|
|
449
|
+
case "unsatisfied":
|
|
450
|
+
description = `${actor} marked ${condition} unsatisfied`;
|
|
451
|
+
break;
|
|
452
|
+
default:
|
|
453
|
+
throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
|
|
454
|
+
}
|
|
455
|
+
return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
function conditionSubject(condition) {
|
|
459
|
+
const label = clean(condition["label"]);
|
|
460
|
+
switch (condition["kind"]) {
|
|
461
|
+
case "gate":
|
|
462
|
+
return label === "" ? "the gate" : `the gate "${label}"`;
|
|
463
|
+
case "not_before": {
|
|
464
|
+
const at = clean(condition["not_before"]);
|
|
465
|
+
return at === "" ? "the not-before date" : `the not-before date ${at}`;
|
|
466
|
+
}
|
|
467
|
+
case "event":
|
|
468
|
+
return label === "" ? "the wait condition" : `the system event "${label}"`;
|
|
469
|
+
default:
|
|
470
|
+
return "the wait condition";
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function activityFieldEvent(entry) {
|
|
474
|
+
const actor = activityWho(entry);
|
|
475
|
+
const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
|
|
476
|
+
const change = fieldChange(requiredString(entry["field"], "activity field"), entry["from"], entry["to"]);
|
|
477
|
+
return `[${occurredAt(entry)}] ${actor} ${change} \xB7 ${provenance}
|
|
478
|
+
`;
|
|
479
|
+
}
|
|
480
|
+
function fieldChange(field, from, to) {
|
|
481
|
+
switch (field) {
|
|
482
|
+
case "priority":
|
|
483
|
+
return `changed priority from ${priorityName(from)} to ${priorityName(to)}`;
|
|
484
|
+
case "title":
|
|
485
|
+
return `renamed the issue from \u201C${activityTitle(from)}\u201D to \u201C${activityTitle(to)}\u201D`;
|
|
486
|
+
case "description":
|
|
487
|
+
return "edited the description";
|
|
488
|
+
case "labels":
|
|
489
|
+
return labelsChange(to);
|
|
490
|
+
case "parent":
|
|
491
|
+
return parentChange(from, to);
|
|
492
|
+
case "archived":
|
|
493
|
+
if (typeof to !== "boolean") throw new CommandError("Invalid activity archived value.");
|
|
494
|
+
return to ? "archived the issue" : "unarchived the issue";
|
|
495
|
+
default:
|
|
496
|
+
return `changed the ${clean(field)} field`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
var ACTIVITY_PRIORITIES = {
|
|
500
|
+
0: "No priority",
|
|
501
|
+
1: "Urgent",
|
|
502
|
+
2: "High",
|
|
503
|
+
3: "Medium",
|
|
504
|
+
4: "Low"
|
|
505
|
+
};
|
|
506
|
+
function priorityName(value) {
|
|
507
|
+
if (typeof value !== "number" || !Number.isInteger(value)) throw new CommandError("Invalid activity priority.");
|
|
508
|
+
return ACTIVITY_PRIORITIES[value] ?? String(value);
|
|
509
|
+
}
|
|
510
|
+
function activityTitle(value) {
|
|
511
|
+
if (typeof value !== "string") throw new CommandError("Invalid activity title.");
|
|
512
|
+
return clean(value);
|
|
513
|
+
}
|
|
514
|
+
function labelsChange(to) {
|
|
515
|
+
const change = requiredRecord(to, "activity labels");
|
|
516
|
+
const added = labelNames(change["added"]);
|
|
517
|
+
const removed = labelNames(change["removed"]);
|
|
518
|
+
const parts = [
|
|
519
|
+
...added.length > 0 ? [`added ${labelList(added)}`] : [],
|
|
520
|
+
...removed.length > 0 ? [`removed ${labelList(removed)}`] : []
|
|
521
|
+
];
|
|
522
|
+
return parts.length === 0 ? "changed the labels" : parts.join(" and ");
|
|
523
|
+
}
|
|
524
|
+
function labelNames(value) {
|
|
525
|
+
if (!Array.isArray(value)) throw new CommandError("Invalid activity labels.");
|
|
526
|
+
return value.map((label) => clean(requiredString(requiredRecord(label, "activity labels")["name"], "activity labels")));
|
|
527
|
+
}
|
|
528
|
+
function labelList(names) {
|
|
529
|
+
return `${names.length === 1 ? "label" : "labels"} ${names.join(", ")}`;
|
|
530
|
+
}
|
|
531
|
+
function parentChange(from, to) {
|
|
532
|
+
if (from === null && to === null) return "changed the parent";
|
|
533
|
+
if (from === null) return `set the parent to ${parentName(to)}`;
|
|
534
|
+
if (to === null) return `removed the parent ${parentName(from)}`;
|
|
535
|
+
return `changed the parent from ${parentName(from)} to ${parentName(to)}`;
|
|
536
|
+
}
|
|
537
|
+
function parentName(value) {
|
|
538
|
+
const parent = requiredRecord(value, "activity parent");
|
|
539
|
+
return optionalLabel(parent["identifier"], "an unknown issue", "activity parent identifier");
|
|
540
|
+
}
|
|
541
|
+
function activityWho(entry) {
|
|
542
|
+
const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
|
|
543
|
+
const label = entry["actor_label"];
|
|
544
|
+
if (label === void 0 || label === null || label === "") return actor;
|
|
545
|
+
if (typeof label !== "string") throw new CommandError("Invalid activity actor label.");
|
|
546
|
+
const cleaned = clean(label);
|
|
547
|
+
return cleaned === "" ? actor : `${actor} via \u201C${cleaned}\u201D`;
|
|
548
|
+
}
|
|
549
|
+
function occurredAt(entry) {
|
|
550
|
+
return clean(requiredString(entry["occurred_at"], "activity occurrence time"));
|
|
551
|
+
}
|
|
552
|
+
function assigneeName(value) {
|
|
553
|
+
const assignee = requiredRecord(value, "activity assignee");
|
|
554
|
+
if (assignee["kind"] === "unknown") return "Unknown assignee";
|
|
555
|
+
return activityActor(assignee);
|
|
556
|
+
}
|
|
557
|
+
function activityActor(actor) {
|
|
558
|
+
switch (actor["kind"]) {
|
|
559
|
+
case "user":
|
|
560
|
+
return optionalLabel(actor["name"], "Unknown actor", "activity actor name");
|
|
561
|
+
case "system":
|
|
562
|
+
return "System";
|
|
563
|
+
case "deleted_user":
|
|
564
|
+
return "Deleted user";
|
|
565
|
+
case "unknown":
|
|
566
|
+
return "Unknown actor";
|
|
567
|
+
default:
|
|
568
|
+
throw new CommandError(`Unknown activity actor kind: ${clean(actor["kind"])}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function activityProvenance(provenance) {
|
|
572
|
+
switch (provenance["kind"]) {
|
|
573
|
+
case "live":
|
|
574
|
+
case "import":
|
|
575
|
+
case "backfill":
|
|
576
|
+
return String(provenance["kind"]);
|
|
577
|
+
default:
|
|
578
|
+
throw new CommandError(`Unknown activity provenance: ${clean(provenance["kind"])}`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function stateName(value, field) {
|
|
582
|
+
return optionalLabel(requiredRecord(value, field)["name"], "Unknown state", `${field} name`);
|
|
583
|
+
}
|
|
584
|
+
function nullableStateName(value, field) {
|
|
585
|
+
return value === null ? "Unknown state" : stateName(value, field);
|
|
586
|
+
}
|
|
587
|
+
function optionalLabel(value, fallback, field) {
|
|
588
|
+
if (value === null || value === "") return fallback;
|
|
589
|
+
return clean(requiredString(value, field));
|
|
590
|
+
}
|
|
591
|
+
function requiredRecord(value, field) {
|
|
592
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
593
|
+
throw new CommandError(`Invalid ${field}.`);
|
|
594
|
+
}
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
597
|
+
function requiredString(value, field) {
|
|
598
|
+
if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field}.`);
|
|
599
|
+
return value;
|
|
600
|
+
}
|
|
601
|
+
function stats(data) {
|
|
602
|
+
const teams = data["teams"] ?? [];
|
|
603
|
+
const totals = data["totals"] ?? {};
|
|
604
|
+
const rows = teams.map((team) => [
|
|
605
|
+
str(path(team, "team", "key")),
|
|
606
|
+
str(team["open"]),
|
|
607
|
+
str(team["started"]),
|
|
608
|
+
str(team["blocked"]),
|
|
609
|
+
str(team["ready"]),
|
|
610
|
+
str(team["closed"])
|
|
611
|
+
]);
|
|
612
|
+
const totalsRow = ["TOTAL", ...["open", "started", "blocked", "ready", "closed"].map((key) => str(totals[key]))];
|
|
613
|
+
return table(["TEAM", "OPEN", "STARTED", "BLOCKED", "READY", "CLOSED"], [...rows, totalsRow]);
|
|
614
|
+
}
|
|
615
|
+
function table(headers, rows) {
|
|
616
|
+
const all = [headers, ...rows.map((row) => row.map((cell) => clean(cell)))];
|
|
617
|
+
const widths = headers.map((_, column) => Math.max(...all.map((row) => graphemeLength(row[column] ?? ""))));
|
|
618
|
+
return all.map(
|
|
619
|
+
(row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
|
|
620
|
+
).join("");
|
|
621
|
+
}
|
|
622
|
+
function records(value) {
|
|
623
|
+
if (!Array.isArray(value)) return [];
|
|
624
|
+
return value.filter(
|
|
625
|
+
(entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
function waitingLine(issue) {
|
|
629
|
+
if (issue["waiting_on_condition"] !== true) return null;
|
|
630
|
+
const conditions = records(issue["conditions"]);
|
|
631
|
+
const open = conditions.filter((condition) => condition["satisfied"] !== true).length;
|
|
632
|
+
return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
|
|
633
|
+
}
|
|
634
|
+
function conditionsSection(conditions) {
|
|
635
|
+
const list = records(conditions);
|
|
636
|
+
if (list.length === 0) return "";
|
|
637
|
+
return "\nWait conditions:\n" + list.map((condition) => ` ${conditionLine(condition)}
|
|
638
|
+
`).join("");
|
|
639
|
+
}
|
|
640
|
+
function conditionLine(condition, options = {}) {
|
|
641
|
+
const satisfied = condition["satisfied"] === true;
|
|
642
|
+
let what;
|
|
643
|
+
switch (condition["kind"]) {
|
|
644
|
+
case "gate": {
|
|
645
|
+
const owner = condition["owner"];
|
|
646
|
+
let ownedBy = "";
|
|
647
|
+
if (typeof owner === "object" && owner !== null) {
|
|
648
|
+
const record = owner;
|
|
649
|
+
ownedBy = options.expertView === true && record["kind"] === "expert" ? "you" : ownerText(record);
|
|
650
|
+
}
|
|
651
|
+
what = `gate ${gateName(condition)}${ownedBy === "" ? "" : ` owned by ${ownedBy}`}`;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
case "not_before":
|
|
655
|
+
what = `not before ${clean(condition["not_before"])}`;
|
|
656
|
+
break;
|
|
657
|
+
case "event":
|
|
658
|
+
what = "system event";
|
|
659
|
+
break;
|
|
660
|
+
default:
|
|
661
|
+
what = clean(condition["kind"]) || "condition";
|
|
662
|
+
}
|
|
663
|
+
let when = "";
|
|
664
|
+
if (satisfied && typeof condition["satisfied_at"] === "string") {
|
|
665
|
+
let by = clean(path(condition, "satisfied_by", "name"));
|
|
666
|
+
if (by === "" && condition["satisfied_by_kind"] === "expert") by = "an outside expert";
|
|
667
|
+
let role = typeof condition["satisfied_as_role"] === "string" ? roleName(condition["satisfied_as_role"]) : "";
|
|
668
|
+
switch (path(condition, "satisfied_by", "kind")) {
|
|
669
|
+
case "you":
|
|
670
|
+
by = "you";
|
|
671
|
+
break;
|
|
672
|
+
case "role": {
|
|
673
|
+
const as = path(condition, "satisfied_by", "role");
|
|
674
|
+
if (typeof as === "string" && as !== "") role = roleName(as);
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "expert":
|
|
678
|
+
by = "an outside expert";
|
|
679
|
+
break;
|
|
680
|
+
case "member":
|
|
681
|
+
by = "a member";
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`}${role === "" ? "" : ` as ${role}`})`;
|
|
685
|
+
}
|
|
686
|
+
return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
|
|
687
|
+
}
|
|
688
|
+
function gateName(gate) {
|
|
689
|
+
const key = clean(gate["key"]);
|
|
690
|
+
return `"${clean(gate["label"])}"${key === "" ? "" : ` [${key}]`}`;
|
|
691
|
+
}
|
|
692
|
+
function ownerText(owner) {
|
|
693
|
+
const label = clean(owner["label"]) || (owner["kind"] === "role" && typeof owner["role"] === "string" ? roleName(owner["role"]) : "");
|
|
694
|
+
const ref = clean(owner["ref"]);
|
|
695
|
+
if (owner["kind"] === "expert") {
|
|
696
|
+
const email = label || ref.replace(/^expert:/, "");
|
|
697
|
+
return email === "" ? "" : `${email} (expert)`;
|
|
698
|
+
}
|
|
699
|
+
if (label === "") return ref;
|
|
700
|
+
return ref === "" || ref === label ? label : `${label} (${ref})`;
|
|
701
|
+
}
|
|
702
|
+
function expertSummary(seat) {
|
|
703
|
+
const role = typeof seat.role === "string" && seat.role !== "" ? roleName(seat.role) : "Outside expert";
|
|
704
|
+
const status = clean(seat.plan?.status);
|
|
705
|
+
const plan = seat.plan === null ? ", no plan" : ` on plan ${clean(seat.plan.id)}${status === "" ? "" : ` (${status})`}`;
|
|
706
|
+
const steps = seat.assigned_steps;
|
|
707
|
+
const count = steps === 0 ? "no assigned steps" : `${steps} assigned step${steps === 1 ? "" : "s"}`;
|
|
708
|
+
return `${role}${plan} \xB7 ${count}`;
|
|
709
|
+
}
|
|
710
|
+
function expertAssignments(steps) {
|
|
711
|
+
return steps.map((step) => "\n" + assignedStep(step)).join("");
|
|
712
|
+
}
|
|
713
|
+
function assignedStep(step) {
|
|
714
|
+
const blockers = typeof step["open_blockers"] === "number" ? step["open_blockers"] : 0;
|
|
715
|
+
const fields = [
|
|
716
|
+
["State", path(step, "state", "name")],
|
|
717
|
+
["Due", step["due_date"]],
|
|
718
|
+
["Blocked", blockers > 0 ? `yes, by ${blockers} open issue${blockers === 1 ? "" : "s"}` : null]
|
|
719
|
+
];
|
|
720
|
+
const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
|
|
721
|
+
`).join("");
|
|
722
|
+
const gates = records(step["gates"]);
|
|
723
|
+
const gateLines = gates.length === 0 ? " Gates: none of yours\n" : " Gates:\n" + gates.map((gate) => ` ${conditionLine(gate, { expertView: true })}
|
|
724
|
+
`).join("");
|
|
725
|
+
const description = step["description"];
|
|
726
|
+
const descriptionLines = typeof description === "string" && description !== "" ? " Description:\n" + indent(indent(description)) + "\n" : "";
|
|
727
|
+
return `${clean(step["identifier"])} \xB7 ${clean(step["title"])}
|
|
728
|
+
${fieldLines}${gateLines}${descriptionLines}`;
|
|
729
|
+
}
|
|
730
|
+
var ROLE_NAMES = { gp: "GP" };
|
|
731
|
+
function roleName(role) {
|
|
732
|
+
const slug = clean(role);
|
|
733
|
+
if (Object.hasOwn(ROLE_NAMES, slug)) return ROLE_NAMES[slug];
|
|
734
|
+
const words = slug.replace(/_/g, " ");
|
|
735
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
736
|
+
}
|
|
737
|
+
var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
|
|
738
|
+
function plansTable(plans) {
|
|
739
|
+
const rows = plans.map((plan) => [
|
|
740
|
+
str(plan["root"]) || "-",
|
|
741
|
+
str(plan["status"]) || "-",
|
|
742
|
+
plan["held"] === true ? "yes" : plan["held"] === false ? "no" : "-",
|
|
743
|
+
typeof plan["revision"] === "number" ? String(plan["revision"]) : "-",
|
|
744
|
+
truncate(plan["title"])
|
|
745
|
+
]);
|
|
746
|
+
return table(["ROOT", "STATUS", "HELD", "REVISION", "TITLE"], rows);
|
|
747
|
+
}
|
|
748
|
+
function issuePlanLine(plan) {
|
|
749
|
+
if (typeof plan !== "object" || plan === null || Array.isArray(plan)) return null;
|
|
750
|
+
const record = plan;
|
|
751
|
+
const root = clean(record["root"]);
|
|
752
|
+
if (root === "") return null;
|
|
753
|
+
const status = record["held"] === true ? "held" : clean(record["status"]);
|
|
754
|
+
const revision = record["revision"];
|
|
755
|
+
const parts = [root];
|
|
756
|
+
if (status !== "") parts.push(status);
|
|
757
|
+
if (typeof revision === "number") {
|
|
758
|
+
parts.push(`revision ${revision}`);
|
|
759
|
+
if (typeof record["in_revision"] === "boolean") parts.push(record["in_revision"] ? "in revision" : "not in revision");
|
|
760
|
+
} else {
|
|
761
|
+
parts.push("never released");
|
|
762
|
+
}
|
|
763
|
+
if (record["executor"] === "operator") parts.push("Operator step");
|
|
764
|
+
return parts.join(" \xB7 ");
|
|
765
|
+
}
|
|
766
|
+
function planDetail(plan) {
|
|
767
|
+
const steps = records(plan["steps"]);
|
|
768
|
+
const fields = [
|
|
769
|
+
["Root", planRootLine(plan["root"])],
|
|
770
|
+
["Revision", planRevisionLine(plan)],
|
|
771
|
+
["Budgets", planBudgetsLine(plan)],
|
|
772
|
+
["Estimate", planEstimateLine(plan["estimate"])]
|
|
773
|
+
];
|
|
774
|
+
const fieldLines = fields.filter((field) => field[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
|
|
775
|
+
`).join("");
|
|
776
|
+
return `Plan ${clean(plan["id"])} \xB7 ${clean(plan["status"])}
|
|
777
|
+
` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planWaitingSection(steps) + planHeldSection(plan["held_steps"]);
|
|
778
|
+
}
|
|
779
|
+
function planRootLine(root) {
|
|
780
|
+
if (typeof root !== "object" || root === null) return null;
|
|
781
|
+
const record = root;
|
|
782
|
+
return `${clean(record["identifier"])} \xB7 ${clean(record["title"])}`;
|
|
783
|
+
}
|
|
784
|
+
function planRevisionLine(plan) {
|
|
785
|
+
const revision = plan["revision"];
|
|
786
|
+
if (typeof revision !== "object" || revision === null) return "none yet (never released)";
|
|
787
|
+
const number = clean(revision["number"]);
|
|
788
|
+
const by = clean(path(plan, "released_by", "name"));
|
|
789
|
+
const at = clean(plan["released_at"]);
|
|
790
|
+
if (by === "" && at === "") return number;
|
|
791
|
+
return `${number} \xB7 released${by === "" ? "" : ` by ${by}`}${at === "" ? "" : ` at ${at}`}`;
|
|
792
|
+
}
|
|
793
|
+
function planBudgetsLine(plan) {
|
|
794
|
+
const retries = plan["retry_budget"];
|
|
795
|
+
const retryText = typeof retries === "number" ? `${retries} ${retries === 1 ? "retry" : "retries"} per failed Operator step` : null;
|
|
796
|
+
const spend = plan["spend_budget_cents"];
|
|
797
|
+
const spendText = typeof spend === "number" ? `spend budget ${spend} cents` : "no spend budget";
|
|
798
|
+
return [retryText, spendText].filter((part) => part !== null).join(" \xB7 ");
|
|
799
|
+
}
|
|
800
|
+
function planEstimateLine(estimate) {
|
|
801
|
+
if (typeof estimate !== "object" || estimate === null) return null;
|
|
802
|
+
const record = estimate;
|
|
803
|
+
const ceiling = record["ceiling_cents"];
|
|
804
|
+
if (typeof ceiling !== "number") return null;
|
|
805
|
+
const minimum = record["minimum_budget_cents"];
|
|
806
|
+
return `up to ${ceiling} cents${typeof minimum === "number" ? ` (minimum budget ${minimum} cents)` : ""}`;
|
|
807
|
+
}
|
|
808
|
+
function planStepsSection(steps, planStatus) {
|
|
809
|
+
if (steps.length === 0) return "";
|
|
810
|
+
const depths = stepDepths(steps);
|
|
811
|
+
const rows = steps.map((step) => [
|
|
812
|
+
" ".repeat(depths.get(str(step["identifier"])) ?? 0) + str(step["identifier"]),
|
|
813
|
+
str(step["key"]) || "-",
|
|
814
|
+
str(step["executor"]) || "-",
|
|
815
|
+
str(path(step, "state", "name")) || "-",
|
|
816
|
+
stepStatus(step, planStatus),
|
|
817
|
+
truncate(step["title"])
|
|
818
|
+
]);
|
|
819
|
+
const rendered = table(["STEP", "KEY", "EXECUTOR", "STATE", "STATUS", "TITLE"], rows);
|
|
820
|
+
return "\nSteps:\n" + indentLines(rendered);
|
|
821
|
+
}
|
|
822
|
+
function stepDepths(steps) {
|
|
823
|
+
const parents = new Map(steps.map((step) => [str(step["identifier"]), str(step["parent"])]));
|
|
824
|
+
const depths = /* @__PURE__ */ new Map();
|
|
825
|
+
for (const identifier of parents.keys()) {
|
|
826
|
+
let depth = 0;
|
|
827
|
+
let parent = parents.get(identifier) ?? "";
|
|
828
|
+
const seen = /* @__PURE__ */ new Set([identifier]);
|
|
829
|
+
while (parent !== "" && parents.has(parent) && !seen.has(parent)) {
|
|
830
|
+
seen.add(parent);
|
|
831
|
+
depth += 1;
|
|
832
|
+
parent = parents.get(parent) ?? "";
|
|
833
|
+
}
|
|
834
|
+
depths.set(identifier, depth);
|
|
835
|
+
}
|
|
836
|
+
return depths;
|
|
837
|
+
}
|
|
838
|
+
function stepStatus(step, planStatus) {
|
|
839
|
+
const stateType = path(step, "state", "type");
|
|
840
|
+
if (step["archived"] === true) return "archived";
|
|
841
|
+
if (stateType === "completed") return "done";
|
|
842
|
+
if (stateType === "canceled") return "cancelled";
|
|
843
|
+
if (step["container"] === true) return "container";
|
|
844
|
+
if (step["ready"] === true) return "ready";
|
|
845
|
+
if (step["held"] === true) return "held";
|
|
846
|
+
if (step["in_revision"] === false && LIVE_PLAN_STATUSES.includes(planStatus)) return "not in revision";
|
|
847
|
+
if (records(step["conditions"]).some((condition) => condition["satisfied"] !== true)) return "waiting";
|
|
848
|
+
return "-";
|
|
849
|
+
}
|
|
850
|
+
function planEdgesSection(edges) {
|
|
851
|
+
const list = records(edges);
|
|
852
|
+
if (list.length === 0) return "";
|
|
853
|
+
return "\nEdges (blocker \u2192 blocked):\n" + list.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
|
|
854
|
+
`).join("");
|
|
855
|
+
}
|
|
856
|
+
function planConditionsSection(steps) {
|
|
857
|
+
const lines = steps.flatMap(
|
|
858
|
+
(step) => records(step["conditions"]).map((condition) => [clean(step["identifier"]), conditionLine(condition)])
|
|
859
|
+
);
|
|
860
|
+
if (lines.length === 0) return "";
|
|
861
|
+
const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
|
|
862
|
+
return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
|
|
863
|
+
`).join("");
|
|
864
|
+
}
|
|
865
|
+
function planWaitingSection(steps) {
|
|
866
|
+
const lines = steps.flatMap(
|
|
867
|
+
(step) => records(step["waiting_for"]).map((wait) => [clean(step["identifier"]), waitLine(wait)])
|
|
868
|
+
);
|
|
869
|
+
if (lines.length === 0) return "";
|
|
870
|
+
const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
|
|
871
|
+
return "\nWaiting for:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
|
|
872
|
+
`).join("");
|
|
873
|
+
}
|
|
874
|
+
function waitLine(wait) {
|
|
875
|
+
const name = gateName(wait);
|
|
876
|
+
const owner = wait["owner"];
|
|
877
|
+
if (typeof owner !== "object" || owner === null) return `${name} \xB7 no owner, anyone with standing can tick`;
|
|
878
|
+
const holders = wait["holders"];
|
|
879
|
+
let who;
|
|
880
|
+
if (!Array.isArray(holders)) who = "who can tick is not known";
|
|
881
|
+
else if (holders.length === 0) who = "nobody can tick";
|
|
882
|
+
else who = `can tick: ${records(holders).map(holderText).join(", ")}`;
|
|
883
|
+
return `${name} \xB7 ${ownerText(owner)} \xB7 ${who}`;
|
|
884
|
+
}
|
|
885
|
+
function holderText(holder) {
|
|
886
|
+
const name = clean(holder["name"]);
|
|
887
|
+
if (holder["kind"] !== "expert") return name;
|
|
888
|
+
const organization = clean(holder["organization"]);
|
|
889
|
+
return `${name} (expert${organization === "" ? "" : `, ${organization}`})`;
|
|
890
|
+
}
|
|
891
|
+
function planHeldSection(heldSteps) {
|
|
892
|
+
if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
|
|
893
|
+
return `
|
|
894
|
+
Changed since the release: ${heldSteps.map((step) => clean(step)).join(", ")} (only Operator steps are held until the next release takes them in)
|
|
895
|
+
`;
|
|
896
|
+
}
|
|
897
|
+
function indentLines(text) {
|
|
898
|
+
return text.split("\n").map((line) => line === "" ? line : " " + line).join("\n");
|
|
899
|
+
}
|
|
900
|
+
function errorDetails(body) {
|
|
901
|
+
return changesetDetails(body["details"]) + violationDetails(body["violations"]);
|
|
902
|
+
}
|
|
903
|
+
function changesetDetails(details) {
|
|
904
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
|
|
905
|
+
return Object.entries(details).map(([field, messages]) => {
|
|
906
|
+
const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
|
|
907
|
+
return `
|
|
908
|
+
${clean(field)}: ${clean(text)}`;
|
|
909
|
+
}).join("");
|
|
910
|
+
}
|
|
911
|
+
function violationDetails(violations) {
|
|
912
|
+
return records(violations).map((violation) => {
|
|
913
|
+
const code = clean(violation["code"]);
|
|
914
|
+
const message = capMessage(violation) ?? (clean(violation["message"]) || code || "invalid");
|
|
915
|
+
return `
|
|
916
|
+
${violationSubject(violation)}: ${message}${code === "" ? "" : ` [${code}]`}`;
|
|
917
|
+
}).join("");
|
|
918
|
+
}
|
|
919
|
+
var MAX_PLAN_STEPS_CEILING = 2e3;
|
|
920
|
+
function capMessage(violation) {
|
|
921
|
+
if (violation["code"] !== "too_many_steps") return null;
|
|
922
|
+
const cap = violation["cap"];
|
|
923
|
+
if (typeof cap !== "object" || cap === null || Array.isArray(cap)) return null;
|
|
924
|
+
const { kind, max, count, ceiling: served } = cap;
|
|
925
|
+
if (!Number.isInteger(max) || !Number.isInteger(count)) return null;
|
|
926
|
+
if (kind === "operator_steps") {
|
|
927
|
+
return `the plan has ${count} Operator steps, above the fixed limit of ${max}: Operator's delegated grant caps a plan's agent steps, and no setting raises it \u2014 hand some to people (executor none) or split the plan`;
|
|
928
|
+
}
|
|
929
|
+
if (kind === "plan_steps") {
|
|
930
|
+
const ceiling = Number.isInteger(served) ? served : MAX_PLAN_STEPS_CEILING;
|
|
931
|
+
const over = `the plan has ${count} steps, above this organization's max plan steps (${max})`;
|
|
932
|
+
if (count > ceiling && max < ceiling) {
|
|
933
|
+
return `${over}, and above the ceiling of ${ceiling} that no setting raises past \u2014 split the plan`;
|
|
934
|
+
}
|
|
935
|
+
if (max >= ceiling) return `${over}, which is already at its ceiling of ${ceiling} \u2014 split the plan`;
|
|
936
|
+
return `${over}: a Harness admin can raise "Max plan steps" in Harness settings \u2192 Plans (max_plan_steps, up to ${ceiling})`;
|
|
937
|
+
}
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
function violationSubject(violation) {
|
|
941
|
+
const key = typeof violation["key"] === "string" ? clean(violation["key"]) : null;
|
|
942
|
+
const index = typeof violation["index"] === "number" ? `steps[${violation["index"]}]` : null;
|
|
943
|
+
const issue = typeof violation["issue"] === "string" ? clean(violation["issue"]) : null;
|
|
944
|
+
if (key !== null) {
|
|
945
|
+
const where = index ?? issue;
|
|
946
|
+
return where === null ? `step "${key}"` : `step "${key}" (${where})`;
|
|
947
|
+
}
|
|
948
|
+
return issue ?? index ?? "plan";
|
|
949
|
+
}
|
|
950
|
+
function relationsSection(relations) {
|
|
951
|
+
if (!Array.isArray(relations) || relations.length === 0) return "";
|
|
952
|
+
const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
|
|
953
|
+
`).join("");
|
|
954
|
+
return "\nRelations:\n" + lines;
|
|
955
|
+
}
|
|
956
|
+
function childrenSection(children) {
|
|
957
|
+
if (!Array.isArray(children) || children.length === 0) return "";
|
|
958
|
+
const lines = children.map((child) => {
|
|
959
|
+
const state = str(path(child, "state", "name")) || "-";
|
|
960
|
+
return ` ${str(child["identifier"])} [${state}] ${truncate(child["title"])}
|
|
961
|
+
`;
|
|
962
|
+
}).join("");
|
|
963
|
+
return "\nSub-issues:\n" + lines;
|
|
964
|
+
}
|
|
965
|
+
function commentsSection(comments) {
|
|
966
|
+
if (!Array.isArray(comments) || comments.length === 0) return "";
|
|
967
|
+
const lines = comments.map((comment) => {
|
|
968
|
+
const author = clean(path(comment, "author", "name") ?? "unknown");
|
|
969
|
+
return ` [${str(comment["inserted_at"])}] ${author}: ${truncate(comment["body"])}
|
|
970
|
+
`;
|
|
971
|
+
}).join("");
|
|
972
|
+
return "\nComments:\n" + lines;
|
|
973
|
+
}
|
|
974
|
+
function imagesSection(images) {
|
|
975
|
+
if (images.length === 0) return "";
|
|
976
|
+
const lines = images.map((image) => ` [${image["source"]}] ${clean(image["path"])}
|
|
977
|
+
`).join("");
|
|
978
|
+
return "\nImages (attachments downloaded locally \u2014 read them, they are part of the issue):\n" + lines;
|
|
979
|
+
}
|
|
980
|
+
function labelsLine(labels) {
|
|
981
|
+
if (!Array.isArray(labels) || labels.length === 0) return null;
|
|
982
|
+
return labels.map((label) => clean(label["name"])).join(", ");
|
|
983
|
+
}
|
|
984
|
+
function blockedLine(issue) {
|
|
985
|
+
if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
|
|
986
|
+
const notes = blockerNotes(issue["blockers"]);
|
|
987
|
+
const blockers = issue["blocked_by"].map((blocker) => {
|
|
988
|
+
const identifier = String(blocker);
|
|
989
|
+
const note = notes.get(identifier);
|
|
990
|
+
return note === void 0 ? identifier : `${identifier} (${note})`;
|
|
991
|
+
});
|
|
992
|
+
return `yes, by ${blockers.join(", ")}`;
|
|
993
|
+
}
|
|
994
|
+
return null;
|
|
995
|
+
}
|
|
996
|
+
function blockerNotes(blockers) {
|
|
997
|
+
const notes = /* @__PURE__ */ new Map();
|
|
998
|
+
if (!Array.isArray(blockers)) return notes;
|
|
999
|
+
for (const blocker of blockers) {
|
|
1000
|
+
if (typeof blocker !== "object" || blocker === null) continue;
|
|
1001
|
+
const record = blocker;
|
|
1002
|
+
if (typeof record["identifier"] !== "string") continue;
|
|
1003
|
+
const reasons = [];
|
|
1004
|
+
if (record["state_type"] === "canceled") reasons.push("cancelled");
|
|
1005
|
+
if (record["archived"] === true) reasons.push("archived");
|
|
1006
|
+
if (reasons.length > 0) notes.set(record["identifier"], reasons.join(", "));
|
|
1007
|
+
}
|
|
1008
|
+
return notes;
|
|
1009
|
+
}
|
|
1010
|
+
function indent(text) {
|
|
1011
|
+
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
1012
|
+
}
|
|
1013
|
+
function cleanMultiline(text) {
|
|
1014
|
+
return text.split("\n").map((line) => clean(line)).join("\n");
|
|
1015
|
+
}
|
|
1016
|
+
function truncate(text) {
|
|
1017
|
+
if (text === null || text === void 0) return "";
|
|
1018
|
+
const cleaned = clean(text);
|
|
1019
|
+
const pieces = graphemes(cleaned);
|
|
1020
|
+
if (pieces.length > TITLE_WIDTH) {
|
|
1021
|
+
return pieces.slice(0, TITLE_WIDTH - 1).join("") + "\u2026";
|
|
1022
|
+
}
|
|
1023
|
+
return cleaned;
|
|
1024
|
+
}
|
|
1025
|
+
function str(value) {
|
|
1026
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
1027
|
+
}
|
|
1028
|
+
function path(record, ...keys) {
|
|
1029
|
+
let current = record;
|
|
1030
|
+
for (const key of keys) {
|
|
1031
|
+
if (typeof current !== "object" || current === null) return null;
|
|
1032
|
+
current = current[key];
|
|
1033
|
+
}
|
|
1034
|
+
return current ?? null;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// src/core/credentials.ts
|
|
1038
|
+
import { createHash } from "crypto";
|
|
1039
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
1040
|
+
import { homedir } from "os";
|
|
1041
|
+
import { dirname, isAbsolute, join } from "path";
|
|
1042
|
+
function defaultCredentialsPath(env = process.env) {
|
|
1043
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
1044
|
+
const base = xdg !== void 0 && xdg !== "" && isAbsolute(xdg) ? xdg : join(homedir(), ".config");
|
|
1045
|
+
return join(base, "harness", "credentials.json");
|
|
1046
|
+
}
|
|
1047
|
+
function orgCredentialsPath(path2, baseUrl, org) {
|
|
1048
|
+
const url = new URL(baseUrl);
|
|
1049
|
+
const host = url.origin + url.pathname.replace(/\/+$/, "");
|
|
1050
|
+
const key = createHash("sha256").update(JSON.stringify([path2, host, org.toLowerCase()])).digest("hex");
|
|
1051
|
+
return join(dirname(path2), `credentials-org-${key}.json`);
|
|
1052
|
+
}
|
|
1053
|
+
function validOrgId(org) {
|
|
1054
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(org);
|
|
1055
|
+
}
|
|
1056
|
+
function readCredentials(path2) {
|
|
1057
|
+
try {
|
|
1058
|
+
const parsed = JSON.parse(readFileSync(path2, "utf8"));
|
|
1059
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
1060
|
+
const { url, token } = parsed;
|
|
1061
|
+
if (typeof url === "string" && typeof token === "string") return { url, token };
|
|
1062
|
+
}
|
|
1063
|
+
} catch {
|
|
1064
|
+
}
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
var uniqueCounter = 0;
|
|
1068
|
+
function writeCredentials(path2, url, token) {
|
|
1069
|
+
mkdirSync(dirname(path2), { recursive: true });
|
|
1070
|
+
const tempPath = `${path2}.${process.pid}.${++uniqueCounter}.tmp`;
|
|
1071
|
+
try {
|
|
1072
|
+
writeFileSync(tempPath, "", { mode: 384 });
|
|
1073
|
+
chmodSync(tempPath, 384);
|
|
1074
|
+
writeFileSync(tempPath, JSON.stringify({ url, token }));
|
|
1075
|
+
renameSync(tempPath, path2);
|
|
1076
|
+
} finally {
|
|
1077
|
+
rmSync(tempPath, { force: true });
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
function deleteCredentials(path2) {
|
|
1081
|
+
rmSync(path2, { force: true });
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// src/core/repo-config.ts
|
|
1085
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1086
|
+
import { homedir as homedir2 } from "os";
|
|
1087
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, parse } from "path";
|
|
1088
|
+
var CONFIG_RELATIVE_PATH = join2(".harness-cli", "config.json");
|
|
1089
|
+
function findRepoConfig(startDir = process.cwd()) {
|
|
1090
|
+
let dir = startDir;
|
|
1091
|
+
const { root } = parse(startDir);
|
|
1092
|
+
for (; ; ) {
|
|
1093
|
+
const candidate = join2(dir, CONFIG_RELATIVE_PATH);
|
|
1094
|
+
if (existsSync(candidate)) {
|
|
1095
|
+
return readRepoConfig(candidate);
|
|
1096
|
+
}
|
|
1097
|
+
if (dir === root) return { config: null, warnings: [] };
|
|
1098
|
+
dir = dirname2(dir);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function readRepoConfig(path2) {
|
|
1102
|
+
let parsed;
|
|
1103
|
+
try {
|
|
1104
|
+
parsed = JSON.parse(readFileSync2(path2, "utf8"));
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1107
|
+
return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
|
|
1108
|
+
}
|
|
1109
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1110
|
+
return { config: null, warnings: [`harness: ignoring ${path2} \u2014 expected a JSON object`] };
|
|
1111
|
+
}
|
|
1112
|
+
const record = parsed;
|
|
1113
|
+
const config = { path: path2 };
|
|
1114
|
+
const warnings = [];
|
|
1115
|
+
for (const field of ["team", "url", "org"]) {
|
|
1116
|
+
const value = record[field];
|
|
1117
|
+
if (value === void 0) continue;
|
|
1118
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
1119
|
+
config[field] = value.trim();
|
|
1120
|
+
} else {
|
|
1121
|
+
warnings.push(`harness: ignoring "${field}" in ${path2} \u2014 expected a non-empty string`);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return { config, warnings };
|
|
1125
|
+
}
|
|
1126
|
+
function trustedHostsPath(env = process.env) {
|
|
1127
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
1128
|
+
const base = xdg !== void 0 && xdg !== "" && isAbsolute2(xdg) ? xdg : join2(homedir2(), ".config");
|
|
1129
|
+
return join2(base, "harness", "trusted-hosts.json");
|
|
1130
|
+
}
|
|
1131
|
+
function readTrustedHosts(env) {
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(readFileSync2(trustedHostsPath(env), "utf8"));
|
|
1134
|
+
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
|
|
1135
|
+
return parsed["hosts"].filter(
|
|
1136
|
+
(host) => typeof host === "string"
|
|
1137
|
+
);
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
}
|
|
1141
|
+
return [];
|
|
1142
|
+
}
|
|
1143
|
+
function isHostTrusted(baseUrl, env) {
|
|
1144
|
+
return readTrustedHosts(env).includes(baseUrl);
|
|
1145
|
+
}
|
|
1146
|
+
function trustHost(baseUrl, env) {
|
|
1147
|
+
const hosts = readTrustedHosts(env);
|
|
1148
|
+
if (hosts.includes(baseUrl)) return;
|
|
1149
|
+
const path2 = trustedHostsPath(env);
|
|
1150
|
+
mkdirSync2(dirname2(path2), { recursive: true });
|
|
1151
|
+
writeFileSync2(path2, JSON.stringify({ hosts: [...hosts, baseUrl] }, null, 2) + "\n");
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// src/core/config.ts
|
|
1155
|
+
import { homedir as homedir3, tmpdir } from "os";
|
|
1156
|
+
import { isAbsolute as isAbsolute3, join as join3 } from "path";
|
|
1157
|
+
var DEFAULT_BASE_URL = "https://harness.infra.one";
|
|
1158
|
+
function resolveConfig(env = process.env, credentialsPath, repo) {
|
|
1159
|
+
let warnings = [];
|
|
1160
|
+
if (repo === void 0) {
|
|
1161
|
+
const lookup = findRepoConfig();
|
|
1162
|
+
repo = lookup.config;
|
|
1163
|
+
warnings = lookup.warnings;
|
|
1164
|
+
}
|
|
1165
|
+
const path2 = credentialsPath ?? defaultCredentialsPath(env);
|
|
1166
|
+
const stored = readCredentials(path2);
|
|
1167
|
+
const envUrl = blankToNull(env["HARNESS_API_URL"]);
|
|
1168
|
+
const envToken = blankToNull(env["HARNESS_API_TOKEN"]);
|
|
1169
|
+
const repoUrl = repo?.url?.replace(/\/+$/, "") ?? null;
|
|
1170
|
+
const org = envToken === null ? repo?.org?.toLowerCase() : void 0;
|
|
1171
|
+
if (org !== void 0 && !validOrgId(org)) {
|
|
1172
|
+
return { kind: "error", message: `repo org in ${repo?.path} must be an organization UUID`, warnings };
|
|
1173
|
+
}
|
|
1174
|
+
let rawUrl;
|
|
1175
|
+
let hostSource;
|
|
1176
|
+
if (envUrl !== null) {
|
|
1177
|
+
rawUrl = envUrl;
|
|
1178
|
+
hostSource = "env";
|
|
1179
|
+
} else if (envToken !== null) {
|
|
1180
|
+
if (repoUrl !== null && normalize(repoUrl) !== normalize(DEFAULT_BASE_URL)) {
|
|
1181
|
+
return {
|
|
1182
|
+
kind: "error",
|
|
1183
|
+
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`,
|
|
1184
|
+
warnings
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
1188
|
+
hostSource = "default";
|
|
1189
|
+
} else if (repoUrl !== null) {
|
|
1190
|
+
rawUrl = repoUrl;
|
|
1191
|
+
hostSource = "repo-config";
|
|
1192
|
+
} else if (org !== void 0) {
|
|
1193
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
1194
|
+
hostSource = "default";
|
|
1195
|
+
} else if (stored !== null) {
|
|
1196
|
+
rawUrl = stored.url;
|
|
1197
|
+
hostSource = "stored-login";
|
|
1198
|
+
} else {
|
|
1199
|
+
rawUrl = DEFAULT_BASE_URL;
|
|
1200
|
+
hostSource = "default";
|
|
1201
|
+
}
|
|
1202
|
+
const validated = validateBaseUrl(rawUrl);
|
|
1203
|
+
if (!validated.ok) return { kind: "error", message: validated.message, warnings };
|
|
1204
|
+
const baseUrl = rawUrl.replace(/\/+$/, "");
|
|
1205
|
+
const selectedPath = org === void 0 ? path2 : orgCredentialsPath(path2, baseUrl, org);
|
|
1206
|
+
const selected = org === void 0 ? stored : readCredentials(selectedPath);
|
|
1207
|
+
let token;
|
|
1208
|
+
if (envToken !== null) {
|
|
1209
|
+
token = envToken;
|
|
1210
|
+
} else if (selected !== null && normalize(selected.url) !== null && normalize(selected.url) === normalize(baseUrl)) {
|
|
1211
|
+
token = selected.token;
|
|
1212
|
+
} else {
|
|
1213
|
+
const result = { kind: "needs-login", baseUrl, hostSource, credentialsPath: selectedPath, warnings };
|
|
1214
|
+
if (org !== void 0) result.org = org;
|
|
1215
|
+
if (hostSource === "repo-config" && repo?.path !== void 0) result.repoConfigPath = repo.path;
|
|
1216
|
+
return result;
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
kind: "ok",
|
|
1220
|
+
config: {
|
|
1221
|
+
baseUrl,
|
|
1222
|
+
token,
|
|
1223
|
+
actor: blankToNull(env["HARNESS_ACTOR"]),
|
|
1224
|
+
// The committed team is the fallback: the environment (and, above it,
|
|
1225
|
+
// the --team flag at the command layer) stays authoritative.
|
|
1226
|
+
team: blankToNull(env["HARNESS_TEAM"]) ?? repo?.team ?? null,
|
|
1227
|
+
cacheDir: resolveCacheDir(env),
|
|
1228
|
+
hostSource,
|
|
1229
|
+
credentialsPath: selectedPath
|
|
1230
|
+
},
|
|
1231
|
+
warnings
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function resolveCacheDir(env = process.env) {
|
|
1235
|
+
return absolute(env["HARNESS_CACHE_DIR"]) ?? join3(resolveCacheHome(env), "harness", "attachments");
|
|
1236
|
+
}
|
|
1237
|
+
function absolute(value) {
|
|
1238
|
+
return value !== void 0 && value !== "" && isAbsolute3(value) ? value : null;
|
|
1239
|
+
}
|
|
1240
|
+
function resolveCacheHome(env = process.env) {
|
|
1241
|
+
return absolute(env["XDG_CACHE_HOME"]) ?? join3(homedir3() || tmpdir(), ".cache");
|
|
1242
|
+
}
|
|
1243
|
+
function normalize(url) {
|
|
1244
|
+
let parsed;
|
|
1245
|
+
try {
|
|
1246
|
+
parsed = new URL(url);
|
|
1247
|
+
} catch {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
const port = parsed.port !== "" ? parsed.port : parsed.protocol === "https:" ? "443" : parsed.protocol === "http:" ? "80" : "";
|
|
1251
|
+
return JSON.stringify([parsed.protocol, parsed.hostname, port, parsed.pathname.replace(/\/+$/, "")]);
|
|
1252
|
+
}
|
|
1253
|
+
function validateBaseUrl(baseUrl) {
|
|
1254
|
+
let parsed;
|
|
1255
|
+
try {
|
|
1256
|
+
parsed = new URL(baseUrl);
|
|
1257
|
+
} catch {
|
|
1258
|
+
return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
|
|
1259
|
+
}
|
|
1260
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
1261
|
+
return { ok: false, message: "HARNESS_API_URL must not embed credentials (user:pass@) \u2014 use HARNESS_API_TOKEN" };
|
|
1262
|
+
}
|
|
1263
|
+
if (parsed.search !== "" || parsed.hash !== "") {
|
|
1264
|
+
return { ok: false, message: `HARNESS_API_URL must not carry a query or fragment, got: ${baseUrl}` };
|
|
1265
|
+
}
|
|
1266
|
+
if (parsed.protocol === "https:" && parsed.hostname !== "") return { ok: true };
|
|
1267
|
+
if (parsed.protocol === "http:") {
|
|
1268
|
+
const host = parsed.hostname;
|
|
1269
|
+
if (["localhost", "127.0.0.1", "[::1]"].includes(host) || host.endsWith(".localhost")) return { ok: true };
|
|
1270
|
+
return {
|
|
1271
|
+
ok: false,
|
|
1272
|
+
message: `refusing plain-http HARNESS_API_URL (${baseUrl}) \u2014 the API token would travel in cleartext. Use https, or http against localhost, *.localhost, 127.0.0.1 or [::1] only.`
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
return { ok: false, message: `HARNESS_API_URL must be an http(s) URL with a host, got: ${baseUrl}` };
|
|
1276
|
+
}
|
|
1277
|
+
function loginHelp(baseUrl, name = invokedName()) {
|
|
1278
|
+
return `Not signed in to ${baseUrl}.
|
|
1279
|
+
|
|
1280
|
+
Interactively, in a terminal (not from inside an agent \u2014 the browser
|
|
1281
|
+
tab needs someone to see it):
|
|
1282
|
+
|
|
1283
|
+
${name} login
|
|
1284
|
+
|
|
1285
|
+
For agents and CI, mint an API token (admin \u2192 API tokens) with the
|
|
1286
|
+
harness:read scope \u2014 plus harness:write only where the workflow
|
|
1287
|
+
creates or closes \u2014 set an expiry, and export it:
|
|
1288
|
+
|
|
1289
|
+
export HARNESS_API_TOKEN=aone_...
|
|
1290
|
+
|
|
1291
|
+
An outside expert uses the pxc_ credential minted in their expert view:
|
|
1292
|
+
export it the same way, or paste it into \`${name} login --no-browser\`.
|
|
1293
|
+
`;
|
|
1294
|
+
}
|
|
1295
|
+
function blankToNull(value) {
|
|
1296
|
+
if (value === void 0 || value.trim() === "") return null;
|
|
1297
|
+
return value;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export {
|
|
1301
|
+
HttpError,
|
|
1302
|
+
UpgradeRequiredError,
|
|
1303
|
+
TransportError,
|
|
1304
|
+
MalformedResponseError,
|
|
1305
|
+
UsageError,
|
|
1306
|
+
LocalError,
|
|
1307
|
+
CommandError,
|
|
1308
|
+
EXPERT_ASSIGNMENTS_PATH,
|
|
1309
|
+
isExpertToken,
|
|
1310
|
+
EXPERT_LAPSED,
|
|
1311
|
+
explainExpertRefusal,
|
|
1312
|
+
SESSION_PATH,
|
|
1313
|
+
isLoginCredential,
|
|
1314
|
+
Client,
|
|
1315
|
+
graphemeLength,
|
|
1316
|
+
clean,
|
|
1317
|
+
json,
|
|
1318
|
+
isAgentStep,
|
|
1319
|
+
issuesTable,
|
|
1320
|
+
issueDetail,
|
|
1321
|
+
activityEntries,
|
|
1322
|
+
stats,
|
|
1323
|
+
table,
|
|
1324
|
+
conditionLine,
|
|
1325
|
+
gateName,
|
|
1326
|
+
ownerText,
|
|
1327
|
+
expertSummary,
|
|
1328
|
+
expertAssignments,
|
|
1329
|
+
plansTable,
|
|
1330
|
+
planDetail,
|
|
1331
|
+
errorDetails,
|
|
1332
|
+
cleanMultiline,
|
|
1333
|
+
truncate,
|
|
1334
|
+
defaultCredentialsPath,
|
|
1335
|
+
orgCredentialsPath,
|
|
1336
|
+
validOrgId,
|
|
1337
|
+
readCredentials,
|
|
1338
|
+
writeCredentials,
|
|
1339
|
+
deleteCredentials,
|
|
1340
|
+
findRepoConfig,
|
|
1341
|
+
isHostTrusted,
|
|
1342
|
+
trustHost,
|
|
1343
|
+
DEFAULT_BASE_URL,
|
|
1344
|
+
resolveConfig,
|
|
1345
|
+
resolveCacheHome,
|
|
1346
|
+
validateBaseUrl,
|
|
1347
|
+
loginHelp
|
|
1348
|
+
};
|