@allocator-one/harness-cli 1.12.1 → 1.14.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 +584 -199
- package/dist/{chunk-VOH6BBJD.js → chunk-F2YVTB2A.js} +736 -84
- package/dist/cli.js +90 -135
- package/dist/{server-FNNMISOZ.js → server-75QJASTY.js} +142 -10
- package/package.json +2 -1
- package/spec/openapi.json +3965 -14
|
@@ -25,6 +25,12 @@ var TransportError = class extends Error {
|
|
|
25
25
|
this.name = "TransportError";
|
|
26
26
|
}
|
|
27
27
|
};
|
|
28
|
+
var MalformedResponseError = class extends TransportError {
|
|
29
|
+
constructor(message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "MalformedResponseError";
|
|
32
|
+
}
|
|
33
|
+
};
|
|
28
34
|
var UsageError = class extends Error {
|
|
29
35
|
constructor(message) {
|
|
30
36
|
super(message);
|
|
@@ -83,8 +89,13 @@ var Client = class {
|
|
|
83
89
|
async get(path2, params) {
|
|
84
90
|
return this.json(await this.raw("GET", path2, { params }));
|
|
85
91
|
}
|
|
86
|
-
async post(path2, body = {}) {
|
|
87
|
-
return this.json(
|
|
92
|
+
async post(path2, body = {}, options = {}) {
|
|
93
|
+
return this.json(
|
|
94
|
+
await this.raw("POST", path2, {
|
|
95
|
+
json: this.withActor(body),
|
|
96
|
+
idempotencyKey: options.idempotencyKey
|
|
97
|
+
})
|
|
98
|
+
);
|
|
88
99
|
}
|
|
89
100
|
async patch(path2, body) {
|
|
90
101
|
return this.json(await this.raw("PATCH", path2, { json: this.withActor(body) }));
|
|
@@ -138,6 +149,9 @@ var Client = class {
|
|
|
138
149
|
authorization: `Bearer ${this.config.token}`,
|
|
139
150
|
"x-harness-cli-version": CLI_VERSION
|
|
140
151
|
};
|
|
152
|
+
if (options.idempotencyKey !== void 0) {
|
|
153
|
+
headers["idempotency-key"] = options.idempotencyKey;
|
|
154
|
+
}
|
|
141
155
|
let body;
|
|
142
156
|
if (options.json !== void 0) {
|
|
143
157
|
headers["content-type"] = "application/json";
|
|
@@ -170,10 +184,10 @@ var Client = class {
|
|
|
170
184
|
try {
|
|
171
185
|
parsed = JSON.parse(text);
|
|
172
186
|
} catch {
|
|
173
|
-
throw new
|
|
187
|
+
throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
|
|
174
188
|
}
|
|
175
189
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
176
|
-
throw new
|
|
190
|
+
throw new MalformedResponseError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
|
|
177
191
|
}
|
|
178
192
|
return parsed;
|
|
179
193
|
}
|
|
@@ -222,16 +236,31 @@ function clean(value) {
|
|
|
222
236
|
function json(term) {
|
|
223
237
|
return JSON.stringify(term) + "\n";
|
|
224
238
|
}
|
|
225
|
-
|
|
239
|
+
var OPERATOR_TEAM_KEY = /^OPERATOR\d*$/;
|
|
240
|
+
var OPERATOR_TEAM_NAME = "Operator";
|
|
241
|
+
var AGENT_MARK = "[agent]";
|
|
242
|
+
var AGENT_LEGEND = `
|
|
243
|
+
${AGENT_MARK} Operator runs this step for its plan; only the plan driver claims it.
|
|
244
|
+
`;
|
|
245
|
+
function isAgentStep(issue) {
|
|
246
|
+
return OPERATOR_TEAM_KEY.test(str(path(issue, "team", "key"))) && str(path(issue, "team", "name")) === OPERATOR_TEAM_NAME;
|
|
247
|
+
}
|
|
248
|
+
function issuesTable(issues, options = {}) {
|
|
226
249
|
if (issues.length === 0) return "No issues.\n";
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
250
|
+
let marked = false;
|
|
251
|
+
const rows = issues.map((issue) => {
|
|
252
|
+
const agentStep = options.markAgentSteps === true && isAgentStep(issue);
|
|
253
|
+
if (agentStep) marked = true;
|
|
254
|
+
return [
|
|
255
|
+
str(issue["identifier"]),
|
|
256
|
+
str(path(issue, "state", "name")) || "-",
|
|
257
|
+
str(issue["priority_label"]) || "-",
|
|
258
|
+
str(path(issue, "assignee", "name")) || "-",
|
|
259
|
+
agentStep ? truncate(`${AGENT_MARK} ${clean(issue["title"])}`) : truncate(issue["title"])
|
|
260
|
+
];
|
|
261
|
+
});
|
|
262
|
+
const rendered = table(["ID", "STATE", "PRIORITY", "ASSIGNEE", "TITLE"], rows);
|
|
263
|
+
return marked ? rendered + AGENT_LEGEND : rendered;
|
|
235
264
|
}
|
|
236
265
|
function issueDetail(issue, images = []) {
|
|
237
266
|
const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
|
|
@@ -243,6 +272,7 @@ function issueDetail(issue, images = []) {
|
|
|
243
272
|
["Team", path(issue, "team", "key")],
|
|
244
273
|
["Labels", labelsLine(issue["labels"])],
|
|
245
274
|
["Blocked", blockedLine(issue)],
|
|
275
|
+
["Waiting", waitingLine(issue)],
|
|
246
276
|
["Due", issue["due_date"]],
|
|
247
277
|
["Updated", issue["updated_at"]]
|
|
248
278
|
];
|
|
@@ -258,7 +288,7 @@ function issueDetail(issue, images = []) {
|
|
|
258
288
|
${title}:
|
|
259
289
|
${indent(String(text))}
|
|
260
290
|
`).join("");
|
|
261
|
-
return header + fieldLines + sectionText + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
|
|
291
|
+
return header + fieldLines + sectionText + conditionsSection(issue["conditions"]) + relationsSection(issue["relations"]) + childrenSection(issue["children"]) + commentsSection(issue["comments"]) + imagesSection(images);
|
|
262
292
|
}
|
|
263
293
|
function activityEntries(entries) {
|
|
264
294
|
if (entries.length === 0) return "No activity.\n";
|
|
@@ -403,6 +433,185 @@ function table(headers, rows) {
|
|
|
403
433
|
(row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
|
|
404
434
|
).join("");
|
|
405
435
|
}
|
|
436
|
+
function records(value) {
|
|
437
|
+
if (!Array.isArray(value)) return [];
|
|
438
|
+
return value.filter(
|
|
439
|
+
(entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
function waitingLine(issue) {
|
|
443
|
+
if (issue["waiting_on_condition"] !== true) return null;
|
|
444
|
+
const conditions = records(issue["conditions"]);
|
|
445
|
+
const open = conditions.filter((condition) => condition["satisfied"] !== true).length;
|
|
446
|
+
return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
|
|
447
|
+
}
|
|
448
|
+
function conditionsSection(conditions) {
|
|
449
|
+
const list2 = records(conditions);
|
|
450
|
+
if (list2.length === 0) return "";
|
|
451
|
+
return "\nWait conditions:\n" + list2.map((condition) => ` ${conditionLine(condition)}
|
|
452
|
+
`).join("");
|
|
453
|
+
}
|
|
454
|
+
function conditionLine(condition) {
|
|
455
|
+
const satisfied = condition["satisfied"] === true;
|
|
456
|
+
let what;
|
|
457
|
+
switch (condition["kind"]) {
|
|
458
|
+
case "gate":
|
|
459
|
+
what = `gate "${clean(condition["label"])}"`;
|
|
460
|
+
break;
|
|
461
|
+
case "not_before":
|
|
462
|
+
what = `not before ${clean(condition["not_before"])}`;
|
|
463
|
+
break;
|
|
464
|
+
case "event":
|
|
465
|
+
what = "system event";
|
|
466
|
+
break;
|
|
467
|
+
default:
|
|
468
|
+
what = clean(condition["kind"]) || "condition";
|
|
469
|
+
}
|
|
470
|
+
let when = "";
|
|
471
|
+
if (satisfied && typeof condition["satisfied_at"] === "string") {
|
|
472
|
+
const by = clean(path(condition, "satisfied_by", "name"));
|
|
473
|
+
when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`})`;
|
|
474
|
+
}
|
|
475
|
+
return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
|
|
476
|
+
}
|
|
477
|
+
var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
|
|
478
|
+
function planDetail(plan) {
|
|
479
|
+
const steps = records(plan["steps"]);
|
|
480
|
+
const fields = [
|
|
481
|
+
["Root", planRootLine(plan["root"])],
|
|
482
|
+
["Revision", planRevisionLine(plan)],
|
|
483
|
+
["Budgets", planBudgetsLine(plan)],
|
|
484
|
+
["Estimate", planEstimateLine(plan["estimate"])]
|
|
485
|
+
];
|
|
486
|
+
const fieldLines = fields.filter((field2) => field2[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
|
|
487
|
+
`).join("");
|
|
488
|
+
return `Plan ${clean(plan["id"])} \xB7 ${clean(plan["status"])}
|
|
489
|
+
` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planHeldSection(plan["held_steps"]);
|
|
490
|
+
}
|
|
491
|
+
function planRootLine(root) {
|
|
492
|
+
if (typeof root !== "object" || root === null) return null;
|
|
493
|
+
const record = root;
|
|
494
|
+
return `${clean(record["identifier"])} \xB7 ${clean(record["title"])}`;
|
|
495
|
+
}
|
|
496
|
+
function planRevisionLine(plan) {
|
|
497
|
+
const revision = plan["revision"];
|
|
498
|
+
if (typeof revision !== "object" || revision === null) return "none yet (never released)";
|
|
499
|
+
const number = clean(revision["number"]);
|
|
500
|
+
const by = clean(path(plan, "released_by", "name"));
|
|
501
|
+
const at = clean(plan["released_at"]);
|
|
502
|
+
if (by === "" && at === "") return number;
|
|
503
|
+
return `${number} \xB7 released${by === "" ? "" : ` by ${by}`}${at === "" ? "" : ` at ${at}`}`;
|
|
504
|
+
}
|
|
505
|
+
function planBudgetsLine(plan) {
|
|
506
|
+
const retries = plan["retry_budget"];
|
|
507
|
+
const retryText = typeof retries === "number" ? `${retries} ${retries === 1 ? "retry" : "retries"} per failed Operator step` : null;
|
|
508
|
+
const spend = plan["spend_budget_cents"];
|
|
509
|
+
const spendText = typeof spend === "number" ? `spend budget ${spend} cents` : "no spend budget";
|
|
510
|
+
return [retryText, spendText].filter((part) => part !== null).join(" \xB7 ");
|
|
511
|
+
}
|
|
512
|
+
function planEstimateLine(estimate) {
|
|
513
|
+
if (typeof estimate !== "object" || estimate === null) return null;
|
|
514
|
+
const record = estimate;
|
|
515
|
+
const ceiling = record["ceiling_cents"];
|
|
516
|
+
if (typeof ceiling !== "number") return null;
|
|
517
|
+
const minimum = record["minimum_budget_cents"];
|
|
518
|
+
return `up to ${ceiling} cents${typeof minimum === "number" ? ` (minimum budget ${minimum} cents)` : ""}`;
|
|
519
|
+
}
|
|
520
|
+
function planStepsSection(steps, planStatus) {
|
|
521
|
+
if (steps.length === 0) return "";
|
|
522
|
+
const depths = stepDepths(steps);
|
|
523
|
+
const rows = steps.map((step) => [
|
|
524
|
+
" ".repeat(depths.get(str(step["identifier"])) ?? 0) + str(step["identifier"]),
|
|
525
|
+
str(step["key"]) || "-",
|
|
526
|
+
str(step["executor"]) || "-",
|
|
527
|
+
str(path(step, "state", "name")) || "-",
|
|
528
|
+
stepStatus(step, planStatus),
|
|
529
|
+
truncate(step["title"])
|
|
530
|
+
]);
|
|
531
|
+
const rendered = table(["STEP", "KEY", "EXECUTOR", "STATE", "STATUS", "TITLE"], rows);
|
|
532
|
+
return "\nSteps:\n" + indentLines(rendered);
|
|
533
|
+
}
|
|
534
|
+
function stepDepths(steps) {
|
|
535
|
+
const parents = new Map(steps.map((step) => [str(step["identifier"]), str(step["parent"])]));
|
|
536
|
+
const depths = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const identifier of parents.keys()) {
|
|
538
|
+
let depth = 0;
|
|
539
|
+
let parent = parents.get(identifier) ?? "";
|
|
540
|
+
const seen = /* @__PURE__ */ new Set([identifier]);
|
|
541
|
+
while (parent !== "" && parents.has(parent) && !seen.has(parent)) {
|
|
542
|
+
seen.add(parent);
|
|
543
|
+
depth += 1;
|
|
544
|
+
parent = parents.get(parent) ?? "";
|
|
545
|
+
}
|
|
546
|
+
depths.set(identifier, depth);
|
|
547
|
+
}
|
|
548
|
+
return depths;
|
|
549
|
+
}
|
|
550
|
+
function stepStatus(step, planStatus) {
|
|
551
|
+
const stateType = path(step, "state", "type");
|
|
552
|
+
if (step["archived"] === true) return "archived";
|
|
553
|
+
if (stateType === "completed") return "done";
|
|
554
|
+
if (stateType === "canceled") return "cancelled";
|
|
555
|
+
if (step["container"] === true) return "container";
|
|
556
|
+
if (step["ready"] === true) return "ready";
|
|
557
|
+
if (step["held"] === true) return "held";
|
|
558
|
+
if (step["in_revision"] === false && LIVE_PLAN_STATUSES.includes(planStatus)) return "not in revision";
|
|
559
|
+
if (records(step["conditions"]).some((condition) => condition["satisfied"] !== true)) return "waiting";
|
|
560
|
+
return "-";
|
|
561
|
+
}
|
|
562
|
+
function planEdgesSection(edges) {
|
|
563
|
+
const list2 = records(edges);
|
|
564
|
+
if (list2.length === 0) return "";
|
|
565
|
+
return "\nEdges (blocker \u2192 blocked):\n" + list2.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
|
|
566
|
+
`).join("");
|
|
567
|
+
}
|
|
568
|
+
function planConditionsSection(steps) {
|
|
569
|
+
const lines = steps.flatMap(
|
|
570
|
+
(step) => records(step["conditions"]).map((condition) => [clean(step["identifier"]), conditionLine(condition)])
|
|
571
|
+
);
|
|
572
|
+
if (lines.length === 0) return "";
|
|
573
|
+
const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
|
|
574
|
+
return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
|
|
575
|
+
`).join("");
|
|
576
|
+
}
|
|
577
|
+
function planHeldSection(heldSteps) {
|
|
578
|
+
if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
|
|
579
|
+
return `
|
|
580
|
+
Held by an edit until the plan is released again: ${heldSteps.map((step) => clean(step)).join(", ")}
|
|
581
|
+
`;
|
|
582
|
+
}
|
|
583
|
+
function indentLines(text) {
|
|
584
|
+
return text.split("\n").map((line) => line === "" ? line : " " + line).join("\n");
|
|
585
|
+
}
|
|
586
|
+
function errorDetails(body) {
|
|
587
|
+
return changesetDetails(body["details"]) + violationDetails(body["violations"]);
|
|
588
|
+
}
|
|
589
|
+
function changesetDetails(details) {
|
|
590
|
+
if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
|
|
591
|
+
return Object.entries(details).map(([field2, messages]) => {
|
|
592
|
+
const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
|
|
593
|
+
return `
|
|
594
|
+
${clean(field2)}: ${clean(text)}`;
|
|
595
|
+
}).join("");
|
|
596
|
+
}
|
|
597
|
+
function violationDetails(violations) {
|
|
598
|
+
return records(violations).map((violation) => {
|
|
599
|
+
const code = clean(violation["code"]);
|
|
600
|
+
const message = clean(violation["message"]) || code || "invalid";
|
|
601
|
+
return `
|
|
602
|
+
${violationSubject(violation)}: ${message}${code === "" ? "" : ` [${code}]`}`;
|
|
603
|
+
}).join("");
|
|
604
|
+
}
|
|
605
|
+
function violationSubject(violation) {
|
|
606
|
+
const key = typeof violation["key"] === "string" ? clean(violation["key"]) : null;
|
|
607
|
+
const index = typeof violation["index"] === "number" ? `steps[${violation["index"]}]` : null;
|
|
608
|
+
const issue = typeof violation["issue"] === "string" ? clean(violation["issue"]) : null;
|
|
609
|
+
if (key !== null) {
|
|
610
|
+
const where = index ?? issue;
|
|
611
|
+
return where === null ? `step "${key}"` : `step "${key}" (${where})`;
|
|
612
|
+
}
|
|
613
|
+
return issue ?? index ?? "plan";
|
|
614
|
+
}
|
|
406
615
|
function relationsSection(relations) {
|
|
407
616
|
if (!Array.isArray(relations) || relations.length === 0) return "";
|
|
408
617
|
const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
|
|
@@ -439,10 +648,30 @@ function labelsLine(labels) {
|
|
|
439
648
|
}
|
|
440
649
|
function blockedLine(issue) {
|
|
441
650
|
if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
|
|
442
|
-
|
|
651
|
+
const notes = blockerNotes(issue["blockers"]);
|
|
652
|
+
const blockers = issue["blocked_by"].map((blocker) => {
|
|
653
|
+
const identifier = String(blocker);
|
|
654
|
+
const note = notes.get(identifier);
|
|
655
|
+
return note === void 0 ? identifier : `${identifier} (${note})`;
|
|
656
|
+
});
|
|
657
|
+
return `yes, by ${blockers.join(", ")}`;
|
|
443
658
|
}
|
|
444
659
|
return null;
|
|
445
660
|
}
|
|
661
|
+
function blockerNotes(blockers) {
|
|
662
|
+
const notes = /* @__PURE__ */ new Map();
|
|
663
|
+
if (!Array.isArray(blockers)) return notes;
|
|
664
|
+
for (const blocker of blockers) {
|
|
665
|
+
if (typeof blocker !== "object" || blocker === null) continue;
|
|
666
|
+
const record = blocker;
|
|
667
|
+
if (typeof record["identifier"] !== "string") continue;
|
|
668
|
+
const reasons = [];
|
|
669
|
+
if (record["state_type"] === "canceled") reasons.push("cancelled");
|
|
670
|
+
if (record["archived"] === true) reasons.push("archived");
|
|
671
|
+
if (reasons.length > 0) notes.set(record["identifier"], reasons.join(", "));
|
|
672
|
+
}
|
|
673
|
+
return notes;
|
|
674
|
+
}
|
|
446
675
|
function indent(text) {
|
|
447
676
|
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
448
677
|
}
|
|
@@ -600,8 +829,8 @@ function write(path2, bytes) {
|
|
|
600
829
|
}
|
|
601
830
|
|
|
602
831
|
// src/core/pagination.ts
|
|
603
|
-
function notices(meta, requestedPageSize,
|
|
604
|
-
return capNotice(meta, requestedPageSize) + truncationNotice(meta,
|
|
832
|
+
function notices(meta, requestedPageSize, records2) {
|
|
833
|
+
return capNotice(meta, requestedPageSize) + truncationNotice(meta, records2);
|
|
605
834
|
}
|
|
606
835
|
function capNotice(meta, requestedPageSize) {
|
|
607
836
|
const served = meta?.page_size;
|
|
@@ -611,12 +840,12 @@ function capNotice(meta, requestedPageSize) {
|
|
|
611
840
|
}
|
|
612
841
|
return "";
|
|
613
842
|
}
|
|
614
|
-
function truncationNotice(meta,
|
|
843
|
+
function truncationNotice(meta, records2) {
|
|
615
844
|
const total = meta?.total;
|
|
616
845
|
const page = meta?.page;
|
|
617
846
|
const pages = meta?.total_pages;
|
|
618
847
|
if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
|
|
619
|
-
const shown =
|
|
848
|
+
const shown = records2.length;
|
|
620
849
|
if (shown < total) {
|
|
621
850
|
return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
|
|
622
851
|
`;
|
|
@@ -627,20 +856,20 @@ async function fetchEveryPage(fetchPage) {
|
|
|
627
856
|
const first = await fetchPage(1);
|
|
628
857
|
const totalPages = pageCount(first.meta);
|
|
629
858
|
if (totalPages === null) return first.data;
|
|
630
|
-
let
|
|
859
|
+
let records2 = first.data;
|
|
631
860
|
for (let page = 2; page <= totalPages; page++) {
|
|
632
861
|
const body = await fetchPage(page);
|
|
633
|
-
|
|
862
|
+
records2 = dedupe([...records2, ...body.data]);
|
|
634
863
|
}
|
|
635
|
-
return
|
|
864
|
+
return records2;
|
|
636
865
|
}
|
|
637
866
|
function pageCount(meta) {
|
|
638
867
|
const total = meta?.total_pages;
|
|
639
868
|
return typeof total === "number" && total > 0 ? total : null;
|
|
640
869
|
}
|
|
641
|
-
function dedupe(
|
|
870
|
+
function dedupe(records2) {
|
|
642
871
|
const seen = /* @__PURE__ */ new Set();
|
|
643
|
-
return
|
|
872
|
+
return records2.filter((record) => {
|
|
644
873
|
const id = record["id"];
|
|
645
874
|
const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
|
|
646
875
|
if (seen.has(key)) return false;
|
|
@@ -663,7 +892,8 @@ var STATUS_STATE_TYPES = {
|
|
|
663
892
|
in_progress: "started",
|
|
664
893
|
closed: "completed,canceled"
|
|
665
894
|
};
|
|
666
|
-
var
|
|
895
|
+
var MAX_IDEMPOTENCY_KEY_BYTES = 200;
|
|
896
|
+
var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
|
|
667
897
|
var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
668
898
|
var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
669
899
|
function isUuid(value) {
|
|
@@ -676,22 +906,72 @@ function requireIdentifiers(identifiers) {
|
|
|
676
906
|
}
|
|
677
907
|
}
|
|
678
908
|
async function ready(ctx, opts) {
|
|
909
|
+
if (opts.claim === true) return claimNext(ctx, opts);
|
|
679
910
|
const params = { ...listParams(ctx, opts), ready: true };
|
|
680
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
911
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
681
912
|
const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
|
|
682
|
-
const sorted = [...
|
|
683
|
-
|
|
684
|
-
|
|
913
|
+
const sorted = markAgentSteps([...records2].sort((a, b) => rank(a) - rank(b)));
|
|
914
|
+
return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
|
|
915
|
+
}
|
|
916
|
+
function markAgentSteps(issues) {
|
|
917
|
+
return issues.map((issue) => ({ ...issue, agent_step: isAgentStep(issue) }));
|
|
918
|
+
}
|
|
919
|
+
function requireReadyClaimArgs(opts) {
|
|
920
|
+
const paging = [
|
|
921
|
+
opts.all === true ? "--all" : null,
|
|
922
|
+
opts.limit !== void 0 ? "--limit" : null,
|
|
923
|
+
opts.page !== void 0 ? "--page" : null
|
|
924
|
+
].filter((flag) => flag !== null);
|
|
925
|
+
if (paging.length === 0) return;
|
|
926
|
+
const last = paging.pop();
|
|
927
|
+
const flags = paging.length === 0 ? last : `${paging.join(", ")} or ${last}`;
|
|
928
|
+
throw new UsageError(
|
|
929
|
+
`--claim cannot be combined with ${flags}: the server picks from the team's whole ready queue, so there are no pages to choose from`
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
async function claimNext(ctx, opts) {
|
|
933
|
+
requireReadyClaimArgs(opts);
|
|
934
|
+
const team = requiredTeam(ctx, opts.team);
|
|
935
|
+
const check = `check \`harness list --team ${team} --assignee me --status in_progress\``;
|
|
936
|
+
const unreadable = (why) => new CommandError(`The claim-next response ${why}, so an issue may have been claimed \u2014 ${check}.`);
|
|
937
|
+
let body;
|
|
938
|
+
try {
|
|
939
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/issues/claim-next`);
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (error instanceof MalformedResponseError) throw unreadable(`could not be read (${error.message})`);
|
|
942
|
+
if (error instanceof TransportError) {
|
|
943
|
+
throw new TransportError(
|
|
944
|
+
`${error.message} \u2014 if the request reached the server, an issue may have been claimed; ${check} before claiming again`
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
throw error;
|
|
948
|
+
}
|
|
949
|
+
if (body.data === null) {
|
|
950
|
+
return { json: null, human: `Nothing to claim: team ${team} has no unassigned ready issues.
|
|
951
|
+
` };
|
|
952
|
+
}
|
|
953
|
+
const claimed = body.data;
|
|
954
|
+
if (!isIssueRecord(claimed)) throw unreadable("carried neither an issue nor null");
|
|
955
|
+
return {
|
|
956
|
+
json: claimed,
|
|
957
|
+
human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
|
|
958
|
+
`
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
function isIssueRecord(value) {
|
|
962
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
963
|
+
const identifier = value["identifier"];
|
|
964
|
+
return typeof identifier === "string" && identifier !== "";
|
|
685
965
|
}
|
|
686
966
|
async function blocked(ctx, opts) {
|
|
687
967
|
const params = { ...listParams(ctx, opts), blocked: true };
|
|
688
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
689
|
-
return { json:
|
|
968
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
969
|
+
return { json: records2, human: issuesTable(records2), notice };
|
|
690
970
|
}
|
|
691
971
|
async function list(ctx, opts) {
|
|
692
972
|
const params = listParams(ctx, opts);
|
|
693
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
694
|
-
return { json:
|
|
973
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
974
|
+
return { json: records2, human: issuesTable(records2), notice };
|
|
695
975
|
}
|
|
696
976
|
async function search(ctx, queryWords, opts) {
|
|
697
977
|
if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
|
|
@@ -782,9 +1062,9 @@ async function stats2(ctx) {
|
|
|
782
1062
|
}
|
|
783
1063
|
async function prime(ctx) {
|
|
784
1064
|
const statsBody = await ctx.client.get("/api/v1/stats");
|
|
785
|
-
const
|
|
786
|
-
const json2 = { stats: statsBody.data, ready:
|
|
787
|
-
const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(
|
|
1065
|
+
const ready2 = markAgentSteps(await mostImportantReady(ctx));
|
|
1066
|
+
const json2 = { stats: statsBody.data, ready: ready2 };
|
|
1067
|
+
const human = "# Harness workflow context\n\n" + stats(statsBody.data) + "\nReady to work:\n" + issuesTable(ready2, { markAgentSteps: true }) + `
|
|
788
1068
|
Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
|
|
789
1069
|
harness close <id> --reason "Implemented in <commit-hash>" (response
|
|
790
1070
|
lists newly unblocked issues \u2014 surface them).
|
|
@@ -794,6 +1074,14 @@ Before reporting done: close the issues you finished.
|
|
|
794
1074
|
`;
|
|
795
1075
|
return { json: json2, human };
|
|
796
1076
|
}
|
|
1077
|
+
async function mostImportantReady(ctx) {
|
|
1078
|
+
try {
|
|
1079
|
+
return (await ctx.client.get("/api/v1/issues/next", { limit: 10 })).data;
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
if (!(error instanceof HttpError) || error.status !== 404) throw error;
|
|
1082
|
+
return (await ctx.client.get("/api/v1/issues", { ready: true, page_size: 10 })).data;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
797
1085
|
async function version(config) {
|
|
798
1086
|
let server;
|
|
799
1087
|
if (config === null || config.baseUrl === "") {
|
|
@@ -810,6 +1098,7 @@ async function create(ctx, opts) {
|
|
|
810
1098
|
}
|
|
811
1099
|
validateType(opts.type);
|
|
812
1100
|
const team = requiredTeam(ctx, opts.team);
|
|
1101
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
813
1102
|
const priority = optionalPriority(opts.priority);
|
|
814
1103
|
const dueDate = optionalDueDate(opts.dueDate);
|
|
815
1104
|
const labels = requestedLabelNames(wantedLabels(opts));
|
|
@@ -824,7 +1113,9 @@ async function create(ctx, opts) {
|
|
|
824
1113
|
body = putDueDate(body, dueDate);
|
|
825
1114
|
body = putAssignee(body, opts.assignee);
|
|
826
1115
|
if (labelIds.length > 0) body["label_ids"] = labelIds;
|
|
827
|
-
const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body
|
|
1116
|
+
const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body, {
|
|
1117
|
+
idempotencyKey: opts.idempotencyKey
|
|
1118
|
+
});
|
|
828
1119
|
const issue = created.data;
|
|
829
1120
|
return {
|
|
830
1121
|
json: issue,
|
|
@@ -832,6 +1123,16 @@ async function create(ctx, opts) {
|
|
|
832
1123
|
`
|
|
833
1124
|
};
|
|
834
1125
|
}
|
|
1126
|
+
function validateIdempotencyKey(key) {
|
|
1127
|
+
if (key === void 0) return;
|
|
1128
|
+
if (key.trim() === "") throw new UsageError("idempotency key must not be blank");
|
|
1129
|
+
if (Buffer.byteLength(key, "utf8") > MAX_IDEMPOTENCY_KEY_BYTES) {
|
|
1130
|
+
throw new UsageError(`idempotency key must not exceed ${MAX_IDEMPOTENCY_KEY_BYTES} UTF-8 bytes`);
|
|
1131
|
+
}
|
|
1132
|
+
if (!IDEMPOTENCY_KEY_FORMAT.test(key)) {
|
|
1133
|
+
throw new UsageError("idempotency key must use printable ASCII and cannot start or end with a space");
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
835
1136
|
async function update(ctx, identifier, opts) {
|
|
836
1137
|
requireIdentifiers([identifier]);
|
|
837
1138
|
if (opts.notes !== void 0 && opts.appendNotes !== void 0) {
|
|
@@ -850,8 +1151,12 @@ async function update(ctx, identifier, opts) {
|
|
|
850
1151
|
let issue = labelPlan?.issue ?? null;
|
|
851
1152
|
let wrote = false;
|
|
852
1153
|
if (opts.claim === true) {
|
|
853
|
-
|
|
854
|
-
|
|
1154
|
+
try {
|
|
1155
|
+
const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
|
|
1156
|
+
issue = body.data;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
throw explainClaimRefusal(error, identifier);
|
|
1159
|
+
}
|
|
855
1160
|
wrote = true;
|
|
856
1161
|
}
|
|
857
1162
|
let patch = {};
|
|
@@ -896,14 +1201,41 @@ ${opts.appendNotes}`;
|
|
|
896
1201
|
`;
|
|
897
1202
|
return { json: issue, human };
|
|
898
1203
|
}
|
|
899
|
-
|
|
1204
|
+
var CLAIM_REFUSAL_HINTS = {
|
|
1205
|
+
// The edit holds a released step, so the plan needs releasing again before
|
|
1206
|
+
// the step is claimable.
|
|
1207
|
+
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",
|
|
1208
|
+
waiting_on_condition: (identifier) => `harness show ${identifier} lists what it waits on; tick a gate with harness gate satisfy ${identifier} <gate>`
|
|
1209
|
+
};
|
|
1210
|
+
function explainClaimRefusal(error, identifier) {
|
|
1211
|
+
if (!(error instanceof HttpError) || error.status !== 409) return error;
|
|
1212
|
+
const reason = error.body["reason"];
|
|
1213
|
+
const hint = typeof reason === "string" && Object.hasOwn(CLAIM_REFUSAL_HINTS, reason) ? CLAIM_REFUSAL_HINTS[reason](identifier) : void 0;
|
|
1214
|
+
if (hint === void 0) return error;
|
|
1215
|
+
return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
|
|
1216
|
+
}
|
|
1217
|
+
function requireCloseArgs(identifiers, opts) {
|
|
900
1218
|
if (identifiers.length === 0) {
|
|
901
|
-
throw new UsageError(
|
|
1219
|
+
throw new UsageError(
|
|
1220
|
+
"usage: harness close <id> [<id>\u2026] [--reason <text>] [--cancel | --duplicate-of <id>]"
|
|
1221
|
+
);
|
|
902
1222
|
}
|
|
903
1223
|
requireIdentifiers(identifiers);
|
|
1224
|
+
if (opts.duplicateOf === void 0) return;
|
|
1225
|
+
requireIdentifiers([opts.duplicateOf]);
|
|
1226
|
+
if (opts.cancel === true) {
|
|
1227
|
+
throw new UsageError("--duplicate-of and --cancel are mutually exclusive");
|
|
1228
|
+
}
|
|
1229
|
+
if (identifiers.length !== 1) {
|
|
1230
|
+
throw new UsageError("--duplicate-of accepts exactly one source issue");
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
async function close(ctx, identifiers, opts) {
|
|
1234
|
+
requireCloseArgs(identifiers, opts);
|
|
904
1235
|
let body = {};
|
|
905
1236
|
body = putPresent(body, "reason", opts.reason);
|
|
906
1237
|
body = putPresent(body, "cancel", opts.cancel);
|
|
1238
|
+
body = putPresent(body, "duplicate_of", opts.duplicateOf);
|
|
907
1239
|
const results = [];
|
|
908
1240
|
for (const identifier of identifiers) {
|
|
909
1241
|
try {
|
|
@@ -917,22 +1249,34 @@ async function close(ctx, identifiers, opts) {
|
|
|
917
1249
|
}
|
|
918
1250
|
}
|
|
919
1251
|
}
|
|
920
|
-
const human = results.map((result) => closeLine(result)).join("");
|
|
1252
|
+
const human = results.map((result) => closeLine(result, opts.duplicateOf)).join("");
|
|
921
1253
|
const json2 = results.map(
|
|
922
|
-
(result) => result.ok ? {
|
|
1254
|
+
(result) => result.ok ? {
|
|
1255
|
+
identifier: result.identifier,
|
|
1256
|
+
closed: true,
|
|
1257
|
+
...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
|
|
1258
|
+
unblocked: result.response["unblocked"] ?? null,
|
|
1259
|
+
// Passed through as sent: older servers omit it, and so does this.
|
|
1260
|
+
..."still_blocking" in result.response ? { still_blocking: result.response["still_blocking"] } : {}
|
|
1261
|
+
} : { identifier: result.identifier, closed: false, error: errorText(result.error) }
|
|
923
1262
|
);
|
|
924
1263
|
const partial = results.some((result) => !result.ok);
|
|
925
1264
|
return partial ? { json: json2, human, partial: true } : { json: json2, human };
|
|
926
1265
|
}
|
|
927
|
-
function closeLine(result) {
|
|
1266
|
+
function closeLine(result, duplicateOf) {
|
|
928
1267
|
if (!result.ok) return `FAILED ${result.identifier}: ${errorText(result.error)}
|
|
929
1268
|
`;
|
|
1269
|
+
const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
|
|
930
1270
|
const unblocked = result.response["unblocked"];
|
|
931
|
-
|
|
932
|
-
|
|
1271
|
+
const line = Array.isArray(unblocked) && unblocked.length > 0 ? `${closure} \u2014 unblocked: ${unblocked.map((identifier) => clean(identifier)).join(", ")}
|
|
1272
|
+
` : `${closure}
|
|
933
1273
|
`;
|
|
934
|
-
|
|
935
|
-
|
|
1274
|
+
return line + stillBlockingLine(result.response["still_blocking"]);
|
|
1275
|
+
}
|
|
1276
|
+
function stillBlockingLine(stillBlocking) {
|
|
1277
|
+
if (!Array.isArray(stillBlocking) || stillBlocking.length === 0) return "";
|
|
1278
|
+
const identifiers = stillBlocking.map((identifier) => clean(identifier)).join(", ");
|
|
1279
|
+
return ` Still blocking: ${identifiers} (a cancelled issue still blocks its dependents)
|
|
936
1280
|
`;
|
|
937
1281
|
}
|
|
938
1282
|
async function archive(ctx, identifiers, opts) {
|
|
@@ -1167,23 +1511,6 @@ function posixDetail(error) {
|
|
|
1167
1511
|
if (code === "EACCES") return "permission denied";
|
|
1168
1512
|
return error instanceof Error ? error.message : String(error);
|
|
1169
1513
|
}
|
|
1170
|
-
async function claimFirst(ctx, issues) {
|
|
1171
|
-
for (const issue of issues.slice(0, READY_CLAIM_ATTEMPTS)) {
|
|
1172
|
-
try {
|
|
1173
|
-
const body = await ctx.client.post(`/api/v1/issues/${str2(issue["identifier"])}/claim`);
|
|
1174
|
-
const claimed = body.data;
|
|
1175
|
-
return {
|
|
1176
|
-
json: claimed,
|
|
1177
|
-
human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
|
|
1178
|
-
`
|
|
1179
|
-
};
|
|
1180
|
-
} catch (error) {
|
|
1181
|
-
if (error instanceof HttpError && error.status === 409) continue;
|
|
1182
|
-
throw error;
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
throw new CommandError("No claimable ready issues.");
|
|
1186
|
-
}
|
|
1187
1514
|
async function setState(ctx, identifier, stateName2) {
|
|
1188
1515
|
const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
1189
1516
|
const teamKey = str2((showBody.data["team"] ?? {})["key"]);
|
|
@@ -1347,15 +1674,15 @@ async function fetchIssues(ctx, params, opts) {
|
|
|
1347
1674
|
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1348
1675
|
}
|
|
1349
1676
|
if (opts.all === true) {
|
|
1350
|
-
const
|
|
1677
|
+
const records3 = await fetchEveryPage(
|
|
1351
1678
|
(page) => ctx.client.get("/api/v1/issues", { ...params, page })
|
|
1352
1679
|
);
|
|
1353
|
-
return { records:
|
|
1680
|
+
return { records: records3, notice: "" };
|
|
1354
1681
|
}
|
|
1355
1682
|
const body = await ctx.client.get("/api/v1/issues", params);
|
|
1356
|
-
const
|
|
1683
|
+
const records2 = body.data;
|
|
1357
1684
|
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
1358
|
-
return { records, notice: notices(body.meta, requested,
|
|
1685
|
+
return { records: records2, notice: notices(body.meta, requested, records2) };
|
|
1359
1686
|
}
|
|
1360
1687
|
function listParams(ctx, opts) {
|
|
1361
1688
|
if (opts.type !== void 0 && opts.label !== void 0) {
|
|
@@ -1481,8 +1808,8 @@ async function paginatedRead(ctx, path2, params, opts) {
|
|
|
1481
1808
|
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1482
1809
|
}
|
|
1483
1810
|
if (opts.all === true) {
|
|
1484
|
-
const
|
|
1485
|
-
return { records, notice: "" };
|
|
1811
|
+
const records2 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
|
|
1812
|
+
return { records: records2, notice: "" };
|
|
1486
1813
|
}
|
|
1487
1814
|
const body = await ctx.client.get(path2, params);
|
|
1488
1815
|
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
@@ -1492,10 +1819,10 @@ async function projectList(ctx, opts) {
|
|
|
1492
1819
|
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1493
1820
|
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1494
1821
|
if (opts.state !== void 0) params = { ...params, state: opts.state };
|
|
1495
|
-
const { records, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
|
|
1496
|
-
const human =
|
|
1822
|
+
const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
|
|
1823
|
+
const human = records2.length === 0 ? "No projects.\n" : table(
|
|
1497
1824
|
["ID", "STATE", "LEAD", "TARGET", "NAME"],
|
|
1498
|
-
|
|
1825
|
+
records2.map((project) => [
|
|
1499
1826
|
str3(project["id"]),
|
|
1500
1827
|
str3(project["state"]) || "-",
|
|
1501
1828
|
str3(field(project, "lead", "name")) || "-",
|
|
@@ -1503,7 +1830,7 @@ async function projectList(ctx, opts) {
|
|
|
1503
1830
|
str3(project["name"])
|
|
1504
1831
|
])
|
|
1505
1832
|
);
|
|
1506
|
-
return { json:
|
|
1833
|
+
return { json: records2, human, notice };
|
|
1507
1834
|
}
|
|
1508
1835
|
async function projectShow(ctx, id) {
|
|
1509
1836
|
requireSafeId("project", id);
|
|
@@ -1594,12 +1921,12 @@ async function milestoneCreate(ctx, opts) {
|
|
|
1594
1921
|
async function docList(ctx, opts) {
|
|
1595
1922
|
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1596
1923
|
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1597
|
-
const { records, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
|
|
1598
|
-
const human =
|
|
1924
|
+
const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
|
|
1925
|
+
const human = records2.length === 0 ? "No documents.\n" : table(
|
|
1599
1926
|
["ID", "UPDATED", "TITLE"],
|
|
1600
|
-
|
|
1927
|
+
records2.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
|
|
1601
1928
|
);
|
|
1602
|
-
return { json:
|
|
1929
|
+
return { json: records2, human, notice };
|
|
1603
1930
|
}
|
|
1604
1931
|
async function docShow(ctx, id) {
|
|
1605
1932
|
requireSafeId("document", id);
|
|
@@ -1888,8 +2215,315 @@ function indent2(text) {
|
|
|
1888
2215
|
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
1889
2216
|
}
|
|
1890
2217
|
|
|
2218
|
+
// src/core/textinput.ts
|
|
2219
|
+
import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
|
|
2220
|
+
import { isatty } from "tty";
|
|
2221
|
+
var STDIN_PATH = "-";
|
|
2222
|
+
var MAX_TEXT_BYTES = 1e6;
|
|
2223
|
+
var TEXT_LIMIT = { bytes: MAX_TEXT_BYTES, why: "that is not prose; check the path" };
|
|
2224
|
+
var DOCUMENT_LIMIT = { bytes: 8e6, why: "larger than the API accepts in one request" };
|
|
2225
|
+
function flagOf(name) {
|
|
2226
|
+
return name.replace(/[A-Z]/g, (upper) => `-${upper.toLowerCase()}`);
|
|
2227
|
+
}
|
|
2228
|
+
function resolveTextSources(sources) {
|
|
2229
|
+
const resolved = {};
|
|
2230
|
+
const toRead = [];
|
|
2231
|
+
let stdinClaimedBy;
|
|
2232
|
+
for (const { name, inline, file } of sources) {
|
|
2233
|
+
if (inline !== void 0 && file !== void 0) {
|
|
2234
|
+
throw new UsageError(
|
|
2235
|
+
`--${flagOf(name)} and --${flagOf(name)}-file both given \u2014 pass one; there is no precedence between them`
|
|
2236
|
+
);
|
|
2237
|
+
}
|
|
2238
|
+
if (file === void 0) {
|
|
2239
|
+
if (inline !== void 0) resolved[name] = inline;
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
if (file === STDIN_PATH) {
|
|
2243
|
+
if (isatty(0)) {
|
|
2244
|
+
throw new UsageError(
|
|
2245
|
+
`--${flagOf(name)}-file - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
if (stdinClaimedBy !== void 0) {
|
|
2249
|
+
throw new UsageError(
|
|
2250
|
+
`--${flagOf(name)}-file and --${flagOf(stdinClaimedBy)}-file both read stdin \u2014 there is only one stream, so only one may be "-"`
|
|
2251
|
+
);
|
|
2252
|
+
}
|
|
2253
|
+
stdinClaimedBy = name;
|
|
2254
|
+
}
|
|
2255
|
+
if (file !== STDIN_PATH) checkReadable(file, `--${flagOf(name)}-file`);
|
|
2256
|
+
toRead.push({ name, file });
|
|
2257
|
+
}
|
|
2258
|
+
for (const { name, file } of [...toRead].sort(byStdinLast)) {
|
|
2259
|
+
resolved[name] = stripOneTrailingNewline(readTextFile(file, `--${flagOf(name)}-file`));
|
|
2260
|
+
}
|
|
2261
|
+
return resolved;
|
|
2262
|
+
}
|
|
2263
|
+
function readFileOption(flag, path2) {
|
|
2264
|
+
if (path2 === STDIN_PATH) {
|
|
2265
|
+
if (isatty(0)) {
|
|
2266
|
+
throw new UsageError(`${flag} - reads stdin, but stdin is a terminal \u2014 pipe something in or pass a path`);
|
|
2267
|
+
}
|
|
2268
|
+
} else {
|
|
2269
|
+
checkReadable(path2, flag, DOCUMENT_LIMIT);
|
|
2270
|
+
}
|
|
2271
|
+
return readTextFile(path2, flag, DOCUMENT_LIMIT);
|
|
2272
|
+
}
|
|
2273
|
+
function readTextFile(path2, option, limit = TEXT_LIMIT) {
|
|
2274
|
+
if (path2 === STDIN_PATH) {
|
|
2275
|
+
let piped;
|
|
2276
|
+
try {
|
|
2277
|
+
piped = readFileSync3(0);
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
throw new UsageError(`${option} -: cannot read stdin (${describe(error)})`);
|
|
2280
|
+
}
|
|
2281
|
+
if (piped.length > limit.bytes) {
|
|
2282
|
+
throw new UsageError(oversize(`${option} -`, limit));
|
|
2283
|
+
}
|
|
2284
|
+
return decodeUtf8(piped, `${option} -`);
|
|
2285
|
+
}
|
|
2286
|
+
const flag = `${option} ${path2}`;
|
|
2287
|
+
let bytes;
|
|
2288
|
+
try {
|
|
2289
|
+
bytes = readFileSync3(path2);
|
|
2290
|
+
} catch (error) {
|
|
2291
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
2292
|
+
}
|
|
2293
|
+
if (bytes.length > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
2294
|
+
return decodeUtf8(bytes, flag);
|
|
2295
|
+
}
|
|
2296
|
+
function decodeUtf8(bytes, flag) {
|
|
2297
|
+
try {
|
|
2298
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
2299
|
+
} catch {
|
|
2300
|
+
throw new UsageError(`${flag}: not valid UTF-8 \u2014 convert it first`);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
function oversize(what, limit) {
|
|
2304
|
+
return `${what}: over ${limit.bytes / 1e6} MB \u2014 ${limit.why}`;
|
|
2305
|
+
}
|
|
2306
|
+
function checkReadable(path2, option, limit = TEXT_LIMIT) {
|
|
2307
|
+
const flag = `${option} ${path2}`;
|
|
2308
|
+
let stats3;
|
|
2309
|
+
try {
|
|
2310
|
+
stats3 = statSync3(path2);
|
|
2311
|
+
} catch (error) {
|
|
2312
|
+
throw new UsageError(`${flag}: ${describe(error)}`);
|
|
2313
|
+
}
|
|
2314
|
+
if (!stats3.isFile()) {
|
|
2315
|
+
throw new UsageError(
|
|
2316
|
+
`${flag}: ${stats3.isDirectory() ? "is a directory" : "not a regular file"}`
|
|
2317
|
+
);
|
|
2318
|
+
}
|
|
2319
|
+
if (stats3.size > limit.bytes) throw new UsageError(oversize(flag, limit));
|
|
2320
|
+
}
|
|
2321
|
+
function byStdinLast(a, b) {
|
|
2322
|
+
return Number(a.file === STDIN_PATH) - Number(b.file === STDIN_PATH);
|
|
2323
|
+
}
|
|
2324
|
+
function stripOneTrailingNewline(text) {
|
|
2325
|
+
if (text.endsWith("\r\n")) return text.slice(0, -2);
|
|
2326
|
+
if (text.endsWith("\n")) return text.slice(0, -1);
|
|
2327
|
+
return text;
|
|
2328
|
+
}
|
|
2329
|
+
function describe(error) {
|
|
2330
|
+
const code = error?.code;
|
|
2331
|
+
if (code === "ENOENT") return "no such file";
|
|
2332
|
+
if (code === "EISDIR") return "is a directory";
|
|
2333
|
+
if (code === "EACCES") return "permission denied";
|
|
2334
|
+
return error instanceof Error ? error.message : String(error);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// src/commands/plans.ts
|
|
2338
|
+
import { extname as extname2 } from "path";
|
|
2339
|
+
import { parseDocument } from "yaml";
|
|
2340
|
+
var PLAN_EXECUTORS = ["none", "operator"];
|
|
2341
|
+
var CONDITION_KINDS = ["not_before", "gate", "event"];
|
|
2342
|
+
function parsePlanDocument(text, source) {
|
|
2343
|
+
const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
|
|
2344
|
+
if (!isRecord(parsed)) {
|
|
2345
|
+
throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
|
|
2346
|
+
}
|
|
2347
|
+
return parsed;
|
|
2348
|
+
}
|
|
2349
|
+
function parseJson(text, source) {
|
|
2350
|
+
try {
|
|
2351
|
+
return JSON.parse(text);
|
|
2352
|
+
} catch (error) {
|
|
2353
|
+
throw new UsageError(`${source}: not valid JSON (${firstLine(error)})`);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
function parseYamlDocument(text, source) {
|
|
2357
|
+
const document = parseDocument(text, { resolveKnownTags: false });
|
|
2358
|
+
const problem = document.errors[0] ?? document.warnings[0];
|
|
2359
|
+
if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine(problem)})`);
|
|
2360
|
+
let value;
|
|
2361
|
+
try {
|
|
2362
|
+
value = document.toJS();
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
throw new UsageError(`${source}: not valid YAML (${firstLine(error)})`);
|
|
2365
|
+
}
|
|
2366
|
+
if (!jsonSafe(value)) {
|
|
2367
|
+
throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
|
|
2368
|
+
}
|
|
2369
|
+
return value;
|
|
2370
|
+
}
|
|
2371
|
+
function jsonSafe(value) {
|
|
2372
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
2373
|
+
if (Array.isArray(value)) return value.every(jsonSafe);
|
|
2374
|
+
if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
|
|
2375
|
+
return true;
|
|
2376
|
+
}
|
|
2377
|
+
function firstLine(error) {
|
|
2378
|
+
return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
|
|
2379
|
+
}
|
|
2380
|
+
function readPlanFile(path2, flag = "--file") {
|
|
2381
|
+
return parsePlanDocument(readFileOption(flag, path2), path2);
|
|
2382
|
+
}
|
|
2383
|
+
async function planCreate(ctx, plan, opts) {
|
|
2384
|
+
requireDocument(plan, "a plan is one object: the root's fields and its steps");
|
|
2385
|
+
const team = requiredTeam(ctx, opts.team);
|
|
2386
|
+
validateIdempotencyKey(opts.idempotencyKey);
|
|
2387
|
+
let body;
|
|
2388
|
+
try {
|
|
2389
|
+
body = await ctx.client.post(`/api/v1/teams/${team}/plans`, plan, { idempotencyKey: opts.idempotencyKey });
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
|
|
2392
|
+
}
|
|
2393
|
+
const created = body.data;
|
|
2394
|
+
const id = str4(created["id"]);
|
|
2395
|
+
return {
|
|
2396
|
+
json: created,
|
|
2397
|
+
human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
|
|
2398
|
+
|
|
2399
|
+
` + planDetail(created) + `
|
|
2400
|
+
Nothing runs until it is released: harness plan release ${clean(id)}
|
|
2401
|
+
`
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
async function planShow(ctx, id) {
|
|
2405
|
+
requirePlanId(id);
|
|
2406
|
+
const plan = await planRequest(id, () => ctx.client.get(`/api/v1/plans/${id}`));
|
|
2407
|
+
return { json: plan, human: planDetail(plan) };
|
|
2408
|
+
}
|
|
2409
|
+
async function planEdit(ctx, id, edits) {
|
|
2410
|
+
requirePlanId(id);
|
|
2411
|
+
requireDocument(edits, "a plan edit is one object: steps to change and budgets");
|
|
2412
|
+
const plan = await planRequest(id, () => ctx.client.patch(`/api/v1/plans/${id}`, edits));
|
|
2413
|
+
const held = Array.isArray(plan["held_steps"]) && plan["held_steps"].length > 0;
|
|
2414
|
+
return {
|
|
2415
|
+
json: plan,
|
|
2416
|
+
human: `Updated plan ${clean(id)}
|
|
2417
|
+
|
|
2418
|
+
` + planDetail(plan) + (held ? `
|
|
2419
|
+
Release it again to run the held steps: harness plan release ${clean(id)}
|
|
2420
|
+
` : "")
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
async function planRelease(ctx, id, opts) {
|
|
2424
|
+
requirePlanId(id);
|
|
2425
|
+
const body = {};
|
|
2426
|
+
if (opts.collaborators !== void 0 && opts.collaborators.length > 0) body["collaborators"] = opts.collaborators;
|
|
2427
|
+
if (opts.retryBudget !== void 0) body["retry_budget"] = opts.retryBudget;
|
|
2428
|
+
if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
|
|
2429
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
|
|
2430
|
+
const revision = plan["revision"] ?? {};
|
|
2431
|
+
const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord(step) && step["ready"] === true).length : 0;
|
|
2432
|
+
return {
|
|
2433
|
+
json: plan,
|
|
2434
|
+
human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
|
|
2435
|
+
`
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
async function planHold(ctx, id) {
|
|
2439
|
+
requirePlanId(id);
|
|
2440
|
+
const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/hold`));
|
|
2441
|
+
return {
|
|
2442
|
+
json: plan,
|
|
2443
|
+
human: `Held plan ${clean(id)}: none of its steps is ready until it is released again
|
|
2444
|
+
`
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
2447
|
+
async function gateSet(ctx, issue, gate, satisfied) {
|
|
2448
|
+
requireIdentifiers([issue]);
|
|
2449
|
+
const wanted = gate.trim();
|
|
2450
|
+
if (wanted === "") throw new UsageError("name the gate by its label or its condition id");
|
|
2451
|
+
let conditions;
|
|
2452
|
+
try {
|
|
2453
|
+
conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
|
|
2454
|
+
} catch (error) {
|
|
2455
|
+
throw explainMissing(error, `no issue ${issue} here, or this Harness server has no wait conditions yet`);
|
|
2456
|
+
}
|
|
2457
|
+
if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
|
|
2458
|
+
const list2 = conditions.filter(isRecord);
|
|
2459
|
+
const target = findGate(list2, issue, wanted);
|
|
2460
|
+
const id = str4(target["id"]);
|
|
2461
|
+
const body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, {
|
|
2462
|
+
satisfied
|
|
2463
|
+
});
|
|
2464
|
+
const others = list2.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
|
|
2465
|
+
let tail = "";
|
|
2466
|
+
if (satisfied) {
|
|
2467
|
+
tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
|
|
2468
|
+
}
|
|
2469
|
+
return {
|
|
2470
|
+
json: body.data,
|
|
2471
|
+
human: `${satisfied ? "Satisfied" : "Un-satisfied"} gate "${clean(target["label"])}" on ${issue}${tail}
|
|
2472
|
+
`
|
|
2473
|
+
};
|
|
2474
|
+
}
|
|
2475
|
+
function requirePlanId(id) {
|
|
2476
|
+
if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
|
|
2477
|
+
}
|
|
2478
|
+
function requireDocument(value, what) {
|
|
2479
|
+
if (!isRecord(value)) throw new UsageError(what);
|
|
2480
|
+
}
|
|
2481
|
+
async function planRequest(id, send) {
|
|
2482
|
+
try {
|
|
2483
|
+
return (await send()).data;
|
|
2484
|
+
} catch (error) {
|
|
2485
|
+
throw explainMissing(error, `no plan ${id} in this organization, or this Harness server has no plan API yet`);
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
function explainMissing(error, why) {
|
|
2489
|
+
if (!(error instanceof HttpError) || error.status !== 404) return error;
|
|
2490
|
+
return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
|
|
2491
|
+
}
|
|
2492
|
+
function findGate(conditions, issue, wanted) {
|
|
2493
|
+
const gates = conditions.filter((condition) => condition["kind"] === "gate");
|
|
2494
|
+
const label = (condition) => str4(condition["label"]).trim();
|
|
2495
|
+
if (isUuid(wanted) && !gates.some((condition) => label(condition) === wanted)) {
|
|
2496
|
+
const byId = conditions.find((condition) => str4(condition["id"]).toLowerCase() === wanted.toLowerCase());
|
|
2497
|
+
if (byId === void 0) throw new CommandError(`No condition ${wanted} on ${issue}.`);
|
|
2498
|
+
if (byId["kind"] !== "gate") {
|
|
2499
|
+
throw new CommandError(
|
|
2500
|
+
`condition ${wanted} on ${issue} is a ${clean(byId["kind"])} condition \u2014 only a gate is satisfied by hand`
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
return byId;
|
|
2504
|
+
}
|
|
2505
|
+
let matches = gates.filter((condition) => label(condition) === wanted);
|
|
2506
|
+
if (matches.length === 0) {
|
|
2507
|
+
matches = gates.filter((condition) => label(condition).toLowerCase() === wanted.toLowerCase());
|
|
2508
|
+
}
|
|
2509
|
+
if (matches.length === 1) return matches[0];
|
|
2510
|
+
if (matches.length > 1) {
|
|
2511
|
+
const ids = matches.map((condition) => clean(condition["id"])).join(", ");
|
|
2512
|
+
throw new CommandError(`several gates on ${issue} match "${wanted}" \u2014 pass the condition id: ${ids}`);
|
|
2513
|
+
}
|
|
2514
|
+
if (gates.length === 0) throw new CommandError(`No gate on ${issue} matches "${wanted}" \u2014 it has no gates`);
|
|
2515
|
+
const labels = gates.map((condition) => `"${clean(label(condition))}"`).join(", ");
|
|
2516
|
+
throw new CommandError(`No gate on ${issue} matches "${wanted}". Its gates: ${labels}`);
|
|
2517
|
+
}
|
|
2518
|
+
function isRecord(value) {
|
|
2519
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2520
|
+
}
|
|
2521
|
+
function str4(value) {
|
|
2522
|
+
return value === null || value === void 0 ? "" : String(value);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
1891
2525
|
// src/core/credentials.ts
|
|
1892
|
-
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as
|
|
2526
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1893
2527
|
import { homedir } from "os";
|
|
1894
2528
|
import { dirname as dirname2, isAbsolute, join as join2 } from "path";
|
|
1895
2529
|
function defaultCredentialsPath(env = process.env) {
|
|
@@ -1899,7 +2533,7 @@ function defaultCredentialsPath(env = process.env) {
|
|
|
1899
2533
|
}
|
|
1900
2534
|
function readCredentials(path2) {
|
|
1901
2535
|
try {
|
|
1902
|
-
const parsed = JSON.parse(
|
|
2536
|
+
const parsed = JSON.parse(readFileSync4(path2, "utf8"));
|
|
1903
2537
|
if (typeof parsed === "object" && parsed !== null) {
|
|
1904
2538
|
const { url, token } = parsed;
|
|
1905
2539
|
if (typeof url === "string" && typeof token === "string") return { url, token };
|
|
@@ -1926,7 +2560,7 @@ function deleteCredentials(path2) {
|
|
|
1926
2560
|
}
|
|
1927
2561
|
|
|
1928
2562
|
// src/core/repo-config.ts
|
|
1929
|
-
import { existsSync, mkdirSync as mkdirSync3, readFileSync as
|
|
2563
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1930
2564
|
import { homedir as homedir2 } from "os";
|
|
1931
2565
|
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
|
|
1932
2566
|
var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
|
|
@@ -1945,7 +2579,7 @@ function findRepoConfig(startDir = process.cwd()) {
|
|
|
1945
2579
|
function readRepoConfig(path2) {
|
|
1946
2580
|
let parsed;
|
|
1947
2581
|
try {
|
|
1948
|
-
parsed = JSON.parse(
|
|
2582
|
+
parsed = JSON.parse(readFileSync5(path2, "utf8"));
|
|
1949
2583
|
} catch (error) {
|
|
1950
2584
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1951
2585
|
return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
|
|
@@ -1974,7 +2608,7 @@ function trustedHostsPath(env = process.env) {
|
|
|
1974
2608
|
}
|
|
1975
2609
|
function readTrustedHosts(env) {
|
|
1976
2610
|
try {
|
|
1977
|
-
const parsed = JSON.parse(
|
|
2611
|
+
const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
|
|
1978
2612
|
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
|
|
1979
2613
|
return parsed["hosts"].filter(
|
|
1980
2614
|
(host) => typeof host === "string"
|
|
@@ -2137,8 +2771,12 @@ export {
|
|
|
2137
2771
|
Client,
|
|
2138
2772
|
clean,
|
|
2139
2773
|
json,
|
|
2774
|
+
errorDetails,
|
|
2775
|
+
MAX_IDEMPOTENCY_KEY_BYTES,
|
|
2776
|
+
IDEMPOTENCY_KEY_FORMAT,
|
|
2140
2777
|
requireIdentifiers,
|
|
2141
2778
|
ready,
|
|
2779
|
+
requireReadyClaimArgs,
|
|
2142
2780
|
blocked,
|
|
2143
2781
|
list,
|
|
2144
2782
|
search,
|
|
@@ -2149,7 +2787,9 @@ export {
|
|
|
2149
2787
|
prime,
|
|
2150
2788
|
version,
|
|
2151
2789
|
create,
|
|
2790
|
+
validateIdempotencyKey,
|
|
2152
2791
|
update,
|
|
2792
|
+
requireCloseArgs,
|
|
2153
2793
|
close,
|
|
2154
2794
|
archive,
|
|
2155
2795
|
comments,
|
|
@@ -2165,6 +2805,7 @@ export {
|
|
|
2165
2805
|
deleteComment,
|
|
2166
2806
|
requireCommentId,
|
|
2167
2807
|
attachImage,
|
|
2808
|
+
requiredTeam,
|
|
2168
2809
|
projectList,
|
|
2169
2810
|
projectShow,
|
|
2170
2811
|
projectCreate,
|
|
@@ -2197,6 +2838,17 @@ export {
|
|
|
2197
2838
|
memberAdd,
|
|
2198
2839
|
memberSetRole,
|
|
2199
2840
|
upload,
|
|
2841
|
+
resolveTextSources,
|
|
2842
|
+
PLAN_EXECUTORS,
|
|
2843
|
+
CONDITION_KINDS,
|
|
2844
|
+
readPlanFile,
|
|
2845
|
+
planCreate,
|
|
2846
|
+
planShow,
|
|
2847
|
+
planEdit,
|
|
2848
|
+
planRelease,
|
|
2849
|
+
planHold,
|
|
2850
|
+
gateSet,
|
|
2851
|
+
requirePlanId,
|
|
2200
2852
|
defaultCredentialsPath,
|
|
2201
2853
|
readCredentials,
|
|
2202
2854
|
writeCredentials,
|