@allocator-one/harness-cli 1.13.0 → 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 +197 -19
- package/dist/{chunk-XFMECLOU.js → chunk-F2YVTB2A.js} +683 -77
- package/dist/cli.js +81 -127
- package/dist/{server-CYBKYG5E.js → server-75QJASTY.js} +130 -7
- package/package.json +2 -1
- package/spec/openapi.json +3924 -11
|
@@ -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);
|
|
@@ -178,10 +184,10 @@ var Client = class {
|
|
|
178
184
|
try {
|
|
179
185
|
parsed = JSON.parse(text);
|
|
180
186
|
} catch {
|
|
181
|
-
throw new
|
|
187
|
+
throw new MalformedResponseError(`the API answered with a non-JSON body (HTTP ${response.status})`);
|
|
182
188
|
}
|
|
183
189
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
184
|
-
throw new
|
|
190
|
+
throw new MalformedResponseError(`the API answered with an invalid JSON envelope (HTTP ${response.status})`);
|
|
185
191
|
}
|
|
186
192
|
return parsed;
|
|
187
193
|
}
|
|
@@ -230,16 +236,31 @@ function clean(value) {
|
|
|
230
236
|
function json(term) {
|
|
231
237
|
return JSON.stringify(term) + "\n";
|
|
232
238
|
}
|
|
233
|
-
|
|
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 = {}) {
|
|
234
249
|
if (issues.length === 0) return "No issues.\n";
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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;
|
|
243
264
|
}
|
|
244
265
|
function issueDetail(issue, images = []) {
|
|
245
266
|
const header = `${str(issue["identifier"])} \xB7 ${clean(issue["title"])}
|
|
@@ -251,6 +272,7 @@ function issueDetail(issue, images = []) {
|
|
|
251
272
|
["Team", path(issue, "team", "key")],
|
|
252
273
|
["Labels", labelsLine(issue["labels"])],
|
|
253
274
|
["Blocked", blockedLine(issue)],
|
|
275
|
+
["Waiting", waitingLine(issue)],
|
|
254
276
|
["Due", issue["due_date"]],
|
|
255
277
|
["Updated", issue["updated_at"]]
|
|
256
278
|
];
|
|
@@ -266,7 +288,7 @@ function issueDetail(issue, images = []) {
|
|
|
266
288
|
${title}:
|
|
267
289
|
${indent(String(text))}
|
|
268
290
|
`).join("");
|
|
269
|
-
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);
|
|
270
292
|
}
|
|
271
293
|
function activityEntries(entries) {
|
|
272
294
|
if (entries.length === 0) return "No activity.\n";
|
|
@@ -411,6 +433,185 @@ function table(headers, rows) {
|
|
|
411
433
|
(row) => row.map((cell, column) => padGraphemes(cell, widths[column] ?? 0)).join(" ").replace(/\s+$/, "") + "\n"
|
|
412
434
|
).join("");
|
|
413
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
|
+
}
|
|
414
615
|
function relationsSection(relations) {
|
|
415
616
|
if (!Array.isArray(relations) || relations.length === 0) return "";
|
|
416
617
|
const lines = relations.map((relation) => ` ${str(relation["direction"]).padEnd(14)}${str(relation["other_issue"])}
|
|
@@ -447,10 +648,30 @@ function labelsLine(labels) {
|
|
|
447
648
|
}
|
|
448
649
|
function blockedLine(issue) {
|
|
449
650
|
if (issue["blocked"] === true && Array.isArray(issue["blocked_by"])) {
|
|
450
|
-
|
|
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(", ")}`;
|
|
451
658
|
}
|
|
452
659
|
return null;
|
|
453
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
|
+
}
|
|
454
675
|
function indent(text) {
|
|
455
676
|
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
456
677
|
}
|
|
@@ -608,8 +829,8 @@ function write(path2, bytes) {
|
|
|
608
829
|
}
|
|
609
830
|
|
|
610
831
|
// src/core/pagination.ts
|
|
611
|
-
function notices(meta, requestedPageSize,
|
|
612
|
-
return capNotice(meta, requestedPageSize) + truncationNotice(meta,
|
|
832
|
+
function notices(meta, requestedPageSize, records2) {
|
|
833
|
+
return capNotice(meta, requestedPageSize) + truncationNotice(meta, records2);
|
|
613
834
|
}
|
|
614
835
|
function capNotice(meta, requestedPageSize) {
|
|
615
836
|
const served = meta?.page_size;
|
|
@@ -619,12 +840,12 @@ function capNotice(meta, requestedPageSize) {
|
|
|
619
840
|
}
|
|
620
841
|
return "";
|
|
621
842
|
}
|
|
622
|
-
function truncationNotice(meta,
|
|
843
|
+
function truncationNotice(meta, records2) {
|
|
623
844
|
const total = meta?.total;
|
|
624
845
|
const page = meta?.page;
|
|
625
846
|
const pages = meta?.total_pages;
|
|
626
847
|
if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
|
|
627
|
-
const shown =
|
|
848
|
+
const shown = records2.length;
|
|
628
849
|
if (shown < total) {
|
|
629
850
|
return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
|
|
630
851
|
`;
|
|
@@ -635,20 +856,20 @@ async function fetchEveryPage(fetchPage) {
|
|
|
635
856
|
const first = await fetchPage(1);
|
|
636
857
|
const totalPages = pageCount(first.meta);
|
|
637
858
|
if (totalPages === null) return first.data;
|
|
638
|
-
let
|
|
859
|
+
let records2 = first.data;
|
|
639
860
|
for (let page = 2; page <= totalPages; page++) {
|
|
640
861
|
const body = await fetchPage(page);
|
|
641
|
-
|
|
862
|
+
records2 = dedupe([...records2, ...body.data]);
|
|
642
863
|
}
|
|
643
|
-
return
|
|
864
|
+
return records2;
|
|
644
865
|
}
|
|
645
866
|
function pageCount(meta) {
|
|
646
867
|
const total = meta?.total_pages;
|
|
647
868
|
return typeof total === "number" && total > 0 ? total : null;
|
|
648
869
|
}
|
|
649
|
-
function dedupe(
|
|
870
|
+
function dedupe(records2) {
|
|
650
871
|
const seen = /* @__PURE__ */ new Set();
|
|
651
|
-
return
|
|
872
|
+
return records2.filter((record) => {
|
|
652
873
|
const id = record["id"];
|
|
653
874
|
const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
|
|
654
875
|
if (seen.has(key)) return false;
|
|
@@ -671,7 +892,6 @@ var STATUS_STATE_TYPES = {
|
|
|
671
892
|
in_progress: "started",
|
|
672
893
|
closed: "completed,canceled"
|
|
673
894
|
};
|
|
674
|
-
var READY_CLAIM_ATTEMPTS = 10;
|
|
675
895
|
var MAX_IDEMPOTENCY_KEY_BYTES = 200;
|
|
676
896
|
var IDEMPOTENCY_KEY_FORMAT = /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?(?![\s\S])/;
|
|
677
897
|
var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
@@ -686,22 +906,72 @@ function requireIdentifiers(identifiers) {
|
|
|
686
906
|
}
|
|
687
907
|
}
|
|
688
908
|
async function ready(ctx, opts) {
|
|
909
|
+
if (opts.claim === true) return claimNext(ctx, opts);
|
|
689
910
|
const params = { ...listParams(ctx, opts), ready: true };
|
|
690
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
911
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
691
912
|
const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
|
|
692
|
-
const sorted = [...
|
|
693
|
-
|
|
694
|
-
|
|
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 !== "";
|
|
695
965
|
}
|
|
696
966
|
async function blocked(ctx, opts) {
|
|
697
967
|
const params = { ...listParams(ctx, opts), blocked: true };
|
|
698
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
699
|
-
return { json:
|
|
968
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
969
|
+
return { json: records2, human: issuesTable(records2), notice };
|
|
700
970
|
}
|
|
701
971
|
async function list(ctx, opts) {
|
|
702
972
|
const params = listParams(ctx, opts);
|
|
703
|
-
const { records, notice } = await fetchIssues(ctx, params, opts);
|
|
704
|
-
return { json:
|
|
973
|
+
const { records: records2, notice } = await fetchIssues(ctx, params, opts);
|
|
974
|
+
return { json: records2, human: issuesTable(records2), notice };
|
|
705
975
|
}
|
|
706
976
|
async function search(ctx, queryWords, opts) {
|
|
707
977
|
if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
|
|
@@ -792,9 +1062,9 @@ async function stats2(ctx) {
|
|
|
792
1062
|
}
|
|
793
1063
|
async function prime(ctx) {
|
|
794
1064
|
const statsBody = await ctx.client.get("/api/v1/stats");
|
|
795
|
-
const
|
|
796
|
-
const json2 = { stats: statsBody.data, ready:
|
|
797
|
-
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 }) + `
|
|
798
1068
|
Workflow: harness ready \u2192 harness update <id> --claim \u2192 work \u2192
|
|
799
1069
|
harness close <id> --reason "Implemented in <commit-hash>" (response
|
|
800
1070
|
lists newly unblocked issues \u2014 surface them).
|
|
@@ -804,6 +1074,14 @@ Before reporting done: close the issues you finished.
|
|
|
804
1074
|
`;
|
|
805
1075
|
return { json: json2, human };
|
|
806
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
|
+
}
|
|
807
1085
|
async function version(config) {
|
|
808
1086
|
let server;
|
|
809
1087
|
if (config === null || config.baseUrl === "") {
|
|
@@ -873,8 +1151,12 @@ async function update(ctx, identifier, opts) {
|
|
|
873
1151
|
let issue = labelPlan?.issue ?? null;
|
|
874
1152
|
let wrote = false;
|
|
875
1153
|
if (opts.claim === true) {
|
|
876
|
-
|
|
877
|
-
|
|
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
|
+
}
|
|
878
1160
|
wrote = true;
|
|
879
1161
|
}
|
|
880
1162
|
let patch = {};
|
|
@@ -919,6 +1201,19 @@ ${opts.appendNotes}`;
|
|
|
919
1201
|
`;
|
|
920
1202
|
return { json: issue, human };
|
|
921
1203
|
}
|
|
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
|
+
}
|
|
922
1217
|
function requireCloseArgs(identifiers, opts) {
|
|
923
1218
|
if (identifiers.length === 0) {
|
|
924
1219
|
throw new UsageError(
|
|
@@ -960,7 +1255,9 @@ async function close(ctx, identifiers, opts) {
|
|
|
960
1255
|
identifier: result.identifier,
|
|
961
1256
|
closed: true,
|
|
962
1257
|
...opts.duplicateOf === void 0 ? {} : { duplicate_of: opts.duplicateOf },
|
|
963
|
-
unblocked: result.response["unblocked"] ?? null
|
|
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"] } : {}
|
|
964
1261
|
} : { identifier: result.identifier, closed: false, error: errorText(result.error) }
|
|
965
1262
|
);
|
|
966
1263
|
const partial = results.some((result) => !result.ok);
|
|
@@ -971,11 +1268,15 @@ function closeLine(result, duplicateOf) {
|
|
|
971
1268
|
`;
|
|
972
1269
|
const closure = duplicateOf === void 0 ? `Closed ${result.identifier}` : `Closed ${result.identifier} as duplicate of ${duplicateOf}`;
|
|
973
1270
|
const unblocked = result.response["unblocked"];
|
|
974
|
-
|
|
975
|
-
|
|
1271
|
+
const line = Array.isArray(unblocked) && unblocked.length > 0 ? `${closure} \u2014 unblocked: ${unblocked.map((identifier) => clean(identifier)).join(", ")}
|
|
1272
|
+
` : `${closure}
|
|
976
1273
|
`;
|
|
977
|
-
|
|
978
|
-
|
|
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)
|
|
979
1280
|
`;
|
|
980
1281
|
}
|
|
981
1282
|
async function archive(ctx, identifiers, opts) {
|
|
@@ -1210,23 +1511,6 @@ function posixDetail(error) {
|
|
|
1210
1511
|
if (code === "EACCES") return "permission denied";
|
|
1211
1512
|
return error instanceof Error ? error.message : String(error);
|
|
1212
1513
|
}
|
|
1213
|
-
async function claimFirst(ctx, issues) {
|
|
1214
|
-
for (const issue of issues.slice(0, READY_CLAIM_ATTEMPTS)) {
|
|
1215
|
-
try {
|
|
1216
|
-
const body = await ctx.client.post(`/api/v1/issues/${str2(issue["identifier"])}/claim`);
|
|
1217
|
-
const claimed = body.data;
|
|
1218
|
-
return {
|
|
1219
|
-
json: claimed,
|
|
1220
|
-
human: `Claimed ${str2(claimed["identifier"])}: ${clean(claimed["title"])}
|
|
1221
|
-
`
|
|
1222
|
-
};
|
|
1223
|
-
} catch (error) {
|
|
1224
|
-
if (error instanceof HttpError && error.status === 409) continue;
|
|
1225
|
-
throw error;
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
throw new CommandError("No claimable ready issues.");
|
|
1229
|
-
}
|
|
1230
1514
|
async function setState(ctx, identifier, stateName2) {
|
|
1231
1515
|
const showBody = await ctx.client.get(`/api/v1/issues/${identifier}`);
|
|
1232
1516
|
const teamKey = str2((showBody.data["team"] ?? {})["key"]);
|
|
@@ -1390,15 +1674,15 @@ async function fetchIssues(ctx, params, opts) {
|
|
|
1390
1674
|
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1391
1675
|
}
|
|
1392
1676
|
if (opts.all === true) {
|
|
1393
|
-
const
|
|
1677
|
+
const records3 = await fetchEveryPage(
|
|
1394
1678
|
(page) => ctx.client.get("/api/v1/issues", { ...params, page })
|
|
1395
1679
|
);
|
|
1396
|
-
return { records:
|
|
1680
|
+
return { records: records3, notice: "" };
|
|
1397
1681
|
}
|
|
1398
1682
|
const body = await ctx.client.get("/api/v1/issues", params);
|
|
1399
|
-
const
|
|
1683
|
+
const records2 = body.data;
|
|
1400
1684
|
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
1401
|
-
return { records, notice: notices(body.meta, requested,
|
|
1685
|
+
return { records: records2, notice: notices(body.meta, requested, records2) };
|
|
1402
1686
|
}
|
|
1403
1687
|
function listParams(ctx, opts) {
|
|
1404
1688
|
if (opts.type !== void 0 && opts.label !== void 0) {
|
|
@@ -1524,8 +1808,8 @@ async function paginatedRead(ctx, path2, params, opts) {
|
|
|
1524
1808
|
throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
|
|
1525
1809
|
}
|
|
1526
1810
|
if (opts.all === true) {
|
|
1527
|
-
const
|
|
1528
|
-
return { records, notice: "" };
|
|
1811
|
+
const records2 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
|
|
1812
|
+
return { records: records2, notice: "" };
|
|
1529
1813
|
}
|
|
1530
1814
|
const body = await ctx.client.get(path2, params);
|
|
1531
1815
|
const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
|
|
@@ -1535,10 +1819,10 @@ async function projectList(ctx, opts) {
|
|
|
1535
1819
|
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1536
1820
|
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1537
1821
|
if (opts.state !== void 0) params = { ...params, state: opts.state };
|
|
1538
|
-
const { records, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
|
|
1539
|
-
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(
|
|
1540
1824
|
["ID", "STATE", "LEAD", "TARGET", "NAME"],
|
|
1541
|
-
|
|
1825
|
+
records2.map((project) => [
|
|
1542
1826
|
str3(project["id"]),
|
|
1543
1827
|
str3(project["state"]) || "-",
|
|
1544
1828
|
str3(field(project, "lead", "name")) || "-",
|
|
@@ -1546,7 +1830,7 @@ async function projectList(ctx, opts) {
|
|
|
1546
1830
|
str3(project["name"])
|
|
1547
1831
|
])
|
|
1548
1832
|
);
|
|
1549
|
-
return { json:
|
|
1833
|
+
return { json: records2, human, notice };
|
|
1550
1834
|
}
|
|
1551
1835
|
async function projectShow(ctx, id) {
|
|
1552
1836
|
requireSafeId("project", id);
|
|
@@ -1637,12 +1921,12 @@ async function milestoneCreate(ctx, opts) {
|
|
|
1637
1921
|
async function docList(ctx, opts) {
|
|
1638
1922
|
let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
|
|
1639
1923
|
if (opts.search !== void 0) params = { ...params, search: opts.search };
|
|
1640
|
-
const { records, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
|
|
1641
|
-
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(
|
|
1642
1926
|
["ID", "UPDATED", "TITLE"],
|
|
1643
|
-
|
|
1927
|
+
records2.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
|
|
1644
1928
|
);
|
|
1645
|
-
return { json:
|
|
1929
|
+
return { json: records2, human, notice };
|
|
1646
1930
|
}
|
|
1647
1931
|
async function docShow(ctx, id) {
|
|
1648
1932
|
requireSafeId("document", id);
|
|
@@ -1931,8 +2215,315 @@ function indent2(text) {
|
|
|
1931
2215
|
return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
|
|
1932
2216
|
}
|
|
1933
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
|
+
|
|
1934
2525
|
// src/core/credentials.ts
|
|
1935
|
-
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";
|
|
1936
2527
|
import { homedir } from "os";
|
|
1937
2528
|
import { dirname as dirname2, isAbsolute, join as join2 } from "path";
|
|
1938
2529
|
function defaultCredentialsPath(env = process.env) {
|
|
@@ -1942,7 +2533,7 @@ function defaultCredentialsPath(env = process.env) {
|
|
|
1942
2533
|
}
|
|
1943
2534
|
function readCredentials(path2) {
|
|
1944
2535
|
try {
|
|
1945
|
-
const parsed = JSON.parse(
|
|
2536
|
+
const parsed = JSON.parse(readFileSync4(path2, "utf8"));
|
|
1946
2537
|
if (typeof parsed === "object" && parsed !== null) {
|
|
1947
2538
|
const { url, token } = parsed;
|
|
1948
2539
|
if (typeof url === "string" && typeof token === "string") return { url, token };
|
|
@@ -1969,7 +2560,7 @@ function deleteCredentials(path2) {
|
|
|
1969
2560
|
}
|
|
1970
2561
|
|
|
1971
2562
|
// src/core/repo-config.ts
|
|
1972
|
-
import { existsSync, mkdirSync as mkdirSync3, readFileSync as
|
|
2563
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
|
|
1973
2564
|
import { homedir as homedir2 } from "os";
|
|
1974
2565
|
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, parse } from "path";
|
|
1975
2566
|
var CONFIG_RELATIVE_PATH = join3(".harness-cli", "config.json");
|
|
@@ -1988,7 +2579,7 @@ function findRepoConfig(startDir = process.cwd()) {
|
|
|
1988
2579
|
function readRepoConfig(path2) {
|
|
1989
2580
|
let parsed;
|
|
1990
2581
|
try {
|
|
1991
|
-
parsed = JSON.parse(
|
|
2582
|
+
parsed = JSON.parse(readFileSync5(path2, "utf8"));
|
|
1992
2583
|
} catch (error) {
|
|
1993
2584
|
const detail = error instanceof Error ? error.message : String(error);
|
|
1994
2585
|
return { config: null, warnings: [`harness: ignoring malformed ${path2} (${detail})`] };
|
|
@@ -2017,7 +2608,7 @@ function trustedHostsPath(env = process.env) {
|
|
|
2017
2608
|
}
|
|
2018
2609
|
function readTrustedHosts(env) {
|
|
2019
2610
|
try {
|
|
2020
|
-
const parsed = JSON.parse(
|
|
2611
|
+
const parsed = JSON.parse(readFileSync5(trustedHostsPath(env), "utf8"));
|
|
2021
2612
|
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed["hosts"])) {
|
|
2022
2613
|
return parsed["hosts"].filter(
|
|
2023
2614
|
(host) => typeof host === "string"
|
|
@@ -2180,10 +2771,12 @@ export {
|
|
|
2180
2771
|
Client,
|
|
2181
2772
|
clean,
|
|
2182
2773
|
json,
|
|
2774
|
+
errorDetails,
|
|
2183
2775
|
MAX_IDEMPOTENCY_KEY_BYTES,
|
|
2184
2776
|
IDEMPOTENCY_KEY_FORMAT,
|
|
2185
2777
|
requireIdentifiers,
|
|
2186
2778
|
ready,
|
|
2779
|
+
requireReadyClaimArgs,
|
|
2187
2780
|
blocked,
|
|
2188
2781
|
list,
|
|
2189
2782
|
search,
|
|
@@ -2194,6 +2787,7 @@ export {
|
|
|
2194
2787
|
prime,
|
|
2195
2788
|
version,
|
|
2196
2789
|
create,
|
|
2790
|
+
validateIdempotencyKey,
|
|
2197
2791
|
update,
|
|
2198
2792
|
requireCloseArgs,
|
|
2199
2793
|
close,
|
|
@@ -2211,6 +2805,7 @@ export {
|
|
|
2211
2805
|
deleteComment,
|
|
2212
2806
|
requireCommentId,
|
|
2213
2807
|
attachImage,
|
|
2808
|
+
requiredTeam,
|
|
2214
2809
|
projectList,
|
|
2215
2810
|
projectShow,
|
|
2216
2811
|
projectCreate,
|
|
@@ -2243,6 +2838,17 @@ export {
|
|
|
2243
2838
|
memberAdd,
|
|
2244
2839
|
memberSetRole,
|
|
2245
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,
|
|
2246
2852
|
defaultCredentialsPath,
|
|
2247
2853
|
readCredentials,
|
|
2248
2854
|
writeCredentials,
|