@allocator-one/harness-cli 1.15.1 → 1.16.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 +141 -38
- package/dist/{chunk-L2OWOZ4N.js → chunk-GVTZKAXC.js} +194 -46
- package/dist/cli.js +277 -52
- package/dist/{server-KVRQBVXI.js → server-F6YRKNC2.js} +27 -11
- package/package.json +1 -1
- package/spec/openapi.json +1392 -25
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
JOBS_MAX_PAGES,
|
|
15
15
|
JOB_STATES,
|
|
16
16
|
LocalError,
|
|
17
|
+
PLAN_LIST_MAX_LIMIT,
|
|
18
|
+
PLAN_STATUSES,
|
|
17
19
|
RELATION_CREATE_TYPES,
|
|
18
20
|
RELATION_UPDATE_TYPES,
|
|
19
21
|
SESSION_PATH,
|
|
@@ -73,9 +75,11 @@ import {
|
|
|
73
75
|
memberSetRole,
|
|
74
76
|
milestoneCreate,
|
|
75
77
|
milestoneList,
|
|
78
|
+
orgCredentialsPath,
|
|
76
79
|
planCreate,
|
|
77
80
|
planEdit,
|
|
78
81
|
planHold,
|
|
82
|
+
planList,
|
|
79
83
|
planRelease,
|
|
80
84
|
planShow,
|
|
81
85
|
prepareProcedureRun,
|
|
@@ -100,7 +104,8 @@ import {
|
|
|
100
104
|
requireIdentifiers,
|
|
101
105
|
requireJobId,
|
|
102
106
|
requireJobsArgs,
|
|
103
|
-
|
|
107
|
+
requirePlanListArgs,
|
|
108
|
+
requirePlanRef,
|
|
104
109
|
requireProcedureId,
|
|
105
110
|
requireProcedureName,
|
|
106
111
|
requireReadyClaimArgs,
|
|
@@ -133,13 +138,14 @@ import {
|
|
|
133
138
|
update,
|
|
134
139
|
upload,
|
|
135
140
|
userList,
|
|
141
|
+
validOrgId,
|
|
136
142
|
validateBaseUrl,
|
|
137
143
|
validateIdempotencyKey,
|
|
138
144
|
version,
|
|
139
145
|
watchStopSignal,
|
|
140
146
|
workflowStates,
|
|
141
147
|
writeCredentials
|
|
142
|
-
} from "./chunk-
|
|
148
|
+
} from "./chunk-GVTZKAXC.js";
|
|
143
149
|
import {
|
|
144
150
|
CLI_VERSION,
|
|
145
151
|
OPERATOR_PACKAGE,
|
|
@@ -336,6 +342,172 @@ async function exec(ctx, opts) {
|
|
|
336
342
|
return { json: { exit_code: exitCode }, human: "", exitCode };
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
// src/commands/review.ts
|
|
346
|
+
import { execFileSync } from "child_process";
|
|
347
|
+
function isRecord2(value) {
|
|
348
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
349
|
+
}
|
|
350
|
+
var SLUG = /^([\w.-]+)\/([\w.-]+)$/;
|
|
351
|
+
function githubSlug(remote) {
|
|
352
|
+
const match = /^(?:(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?|[\w.-]+@)github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(remote.trim());
|
|
353
|
+
return match ? `${match[1]}/${match[2]}` : void 0;
|
|
354
|
+
}
|
|
355
|
+
function originRemote() {
|
|
356
|
+
try {
|
|
357
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
358
|
+
return githubSlug(url);
|
|
359
|
+
} catch {
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function prNumber(digits, input) {
|
|
364
|
+
const number = Number(digits);
|
|
365
|
+
if (!Number.isSafeInteger(number) || number < 1) throw new UsageError(`not a pull request number: ${input}`);
|
|
366
|
+
return number;
|
|
367
|
+
}
|
|
368
|
+
var NAME = /^[\w.-]+$/;
|
|
369
|
+
function fromUrl(input) {
|
|
370
|
+
let url;
|
|
371
|
+
try {
|
|
372
|
+
url = new URL(input);
|
|
373
|
+
} catch {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
if (!["https:", "http:"].includes(url.protocol) || !/^(www\.)?github\.com$/i.test(url.hostname)) return null;
|
|
377
|
+
let segments;
|
|
378
|
+
try {
|
|
379
|
+
segments = url.pathname.split("/").filter((segment) => segment !== "").map(decodeURIComponent);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
const [owner, repo, kind, number] = segments;
|
|
384
|
+
if (kind !== "pull" || !owner || !repo || !number || !NAME.test(owner) || !NAME.test(repo) || !/^\d+$/.test(number)) {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
return [owner, repo, number];
|
|
388
|
+
}
|
|
389
|
+
function parsePullRequest(input, repo, origin = originRemote) {
|
|
390
|
+
const trimmed = input.trim();
|
|
391
|
+
const url = fromUrl(trimmed);
|
|
392
|
+
if (url) return { owner: url[0], repo: url[1], number: prNumber(url[2], input) };
|
|
393
|
+
const named2 = /^([\w.-]+)\/([\w.-]+)#(\d+)$/.exec(trimmed);
|
|
394
|
+
if (named2) return { owner: named2[1], repo: named2[2], number: prNumber(named2[3], input) };
|
|
395
|
+
const bare = /^#?(\d+)$/.exec(trimmed);
|
|
396
|
+
if (!bare) throw new UsageError(`not a pull request: ${input} \u2014 use owner/repo#<pr>, its URL, or <pr> with --repo`);
|
|
397
|
+
const slug = repo ?? origin();
|
|
398
|
+
const parts = slug === void 0 ? null : SLUG.exec(slug.trim());
|
|
399
|
+
if (!parts) throw new UsageError("name the repository: owner/repo#<pr>, the pull request URL, or --repo owner/repo");
|
|
400
|
+
return { owner: parts[1], repo: parts[2], number: prNumber(bare[1], input) };
|
|
401
|
+
}
|
|
402
|
+
var label = (pr) => `${pr.owner}/${pr.repo}#${pr.number}`;
|
|
403
|
+
var gatePath = (pr) => `/api/v1/reviews/prs/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}/${pr.number}`;
|
|
404
|
+
async function fetchGate(ctx, pr) {
|
|
405
|
+
const body = await ctx.client.get(gatePath(pr));
|
|
406
|
+
if (!isRecord2(body.data)) throw new CommandError(`unexpected gate status for ${label(pr)}: no data`);
|
|
407
|
+
return body.data;
|
|
408
|
+
}
|
|
409
|
+
function latestAttempt(lifecycle) {
|
|
410
|
+
const attempts = Array.isArray(lifecycle["attempts"]) ? lifecycle["attempts"].filter(isRecord2) : [];
|
|
411
|
+
return attempts.reduce(
|
|
412
|
+
(latest, attempt) => latest === void 0 || Number(attempt["number"]) > Number(latest["number"]) ? attempt : latest,
|
|
413
|
+
void 0
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
function recovery(lifecycle) {
|
|
417
|
+
const latest = latestAttempt(lifecycle);
|
|
418
|
+
const sent = isRecord2(lifecycle["recovery"]) ? lifecycle["recovery"] : void 0;
|
|
419
|
+
const state2 = String(sent?.["state"] ?? latest?.["state"] ?? "");
|
|
420
|
+
if (state2 !== "failed" && state2 !== "timed_out") return void 0;
|
|
421
|
+
const reason = sent?.["terminal_reason"] ?? latest?.["terminal_reason"];
|
|
422
|
+
return {
|
|
423
|
+
number: latest?.["number"],
|
|
424
|
+
state: state2 === "timed_out" ? "timed out" : state2,
|
|
425
|
+
reason: typeof reason === "string" ? reason : null,
|
|
426
|
+
evidence: sent ? sent["evidence_recorded"] === true : latest?.["review_run_id"] != null,
|
|
427
|
+
next: sent?.["next"] === "automatic_retry" ? "automatic_retry" : "retry"
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function describe(r) {
|
|
431
|
+
const why = [r.reason, r.evidence ? null : "no evidence recorded"].filter((part) => part !== null).join(", ");
|
|
432
|
+
return `attempt ${clean(String(r.number))} ${r.state}${why === "" ? "" : ` (${clean(why)})`}`;
|
|
433
|
+
}
|
|
434
|
+
function projection(gate2, key) {
|
|
435
|
+
const value = gate2[key];
|
|
436
|
+
return isRecord2(value) && typeof value["status"] === "string" ? clean(value["status"]) : "unknown";
|
|
437
|
+
}
|
|
438
|
+
async function status(ctx, pr) {
|
|
439
|
+
const gate2 = await fetchGate(ctx, pr);
|
|
440
|
+
const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
|
|
441
|
+
const head = isRecord2(gate2["head"]) && typeof gate2["head"]["sha"] === "string" ? gate2["head"]["sha"].slice(0, 10) : "?";
|
|
442
|
+
const lines = [
|
|
443
|
+
`${label(pr)} at ${clean(head)}`,
|
|
444
|
+
`enforced: ${projection(gate2, "enforced")} advisory: ${projection(gate2, "advisory")}`
|
|
445
|
+
];
|
|
446
|
+
if (lifecycle) {
|
|
447
|
+
lines.push(`gate check: ${clean(String(lifecycle["state"] ?? "unknown")).replaceAll("_", " ")}`);
|
|
448
|
+
const r = recovery(lifecycle);
|
|
449
|
+
if (r) {
|
|
450
|
+
lines.push(`latest ${describe(r)}`);
|
|
451
|
+
lines.push(
|
|
452
|
+
r.next === "automatic_retry" ? "Harness retries it automatically; check again shortly." : named(`Retry it with: harness review retry ${label(pr)} --reason "\u2026"`)
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { json: gate2, human: lines.join("\n") + "\n" };
|
|
457
|
+
}
|
|
458
|
+
var REFUSALS = {
|
|
459
|
+
execution_not_terminal: "the latest attempt is still running; wait for it to finish",
|
|
460
|
+
attempt_changed: "another attempt was created meanwhile; read the status and run this again",
|
|
461
|
+
check_changed: "the head's gate check changed meanwhile; run this again",
|
|
462
|
+
head_changed: "the pull request's head moved; the new head gets its own check",
|
|
463
|
+
advisory_not_converged: "the head's advisory review has not converged; converge it first",
|
|
464
|
+
retry_conflict: "this attempt was already retried with a different reason",
|
|
465
|
+
merged: "the pull request is merged"
|
|
466
|
+
};
|
|
467
|
+
async function retry(ctx, pr, reason) {
|
|
468
|
+
const gate2 = await fetchGate(ctx, pr);
|
|
469
|
+
const lifecycle = isRecord2(gate2["lifecycle"]) ? gate2["lifecycle"] : void 0;
|
|
470
|
+
if (!lifecycle || typeof lifecycle["check_id"] !== "string" || typeof lifecycle["head_sha"] !== "string") {
|
|
471
|
+
throw new CommandError(`${label(pr)} has no gate check at its current head: nothing to retry`);
|
|
472
|
+
}
|
|
473
|
+
const r = recovery(lifecycle);
|
|
474
|
+
if (r?.next === "automatic_retry") {
|
|
475
|
+
throw new CommandError(named(`Harness retries attempt ${clean(String(r.number))} itself; run harness review status ${label(pr)} shortly`));
|
|
476
|
+
}
|
|
477
|
+
const previous = latestAttempt(lifecycle);
|
|
478
|
+
const previousId = typeof previous?.["id"] === "string" ? previous["id"] : null;
|
|
479
|
+
const body = {
|
|
480
|
+
head_sha: lifecycle["head_sha"],
|
|
481
|
+
check_id: lifecycle["check_id"],
|
|
482
|
+
previous_attempt_id: previousId,
|
|
483
|
+
// One key per attempt retried: running this again for the same attempt returns the attempt already
|
|
484
|
+
// queued instead of paying for another review run.
|
|
485
|
+
request_key: `harness-cli:${previousId ?? lifecycle["check_id"]}`,
|
|
486
|
+
reason: reason?.trim() || "Retry requested with harness review retry"
|
|
487
|
+
};
|
|
488
|
+
let response;
|
|
489
|
+
try {
|
|
490
|
+
response = await ctx.client.post(`${gatePath(pr)}/retry`, body, { actor: false });
|
|
491
|
+
} catch (error) {
|
|
492
|
+
if (error instanceof HttpError && error.status === 409) {
|
|
493
|
+
const code = error.message.trim().split(/[\s:]+/).pop() ?? "";
|
|
494
|
+
throw new CommandError(`could not retry ${label(pr)}: ${REFUSALS[code] ?? clean(error.message)}`);
|
|
495
|
+
}
|
|
496
|
+
if (error instanceof HttpError && error.status === 403) {
|
|
497
|
+
throw new CommandError(`${error.message} \u2014 a retry needs a login with reviews:write`);
|
|
498
|
+
}
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
const attempt = isRecord2(response.data) ? response.data : {};
|
|
502
|
+
const outcome = response["outcome"];
|
|
503
|
+
const verb = outcome === "existing" ? "Already queued" : "Queued";
|
|
504
|
+
return {
|
|
505
|
+
json: { attempt, outcome },
|
|
506
|
+
human: `${verb} attempt ${clean(String(attempt["number"]))} (${clean(String(attempt["id"]))}) for ${label(pr)}
|
|
507
|
+
`
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
339
511
|
// src/core/auth.ts
|
|
340
512
|
import { createHash, randomBytes } from "crypto";
|
|
341
513
|
import { execFile } from "child_process";
|
|
@@ -365,6 +537,9 @@ function challengeFor(verifier) {
|
|
|
365
537
|
async function login(baseUrl, options = {}) {
|
|
366
538
|
const validated = validateBaseUrl(baseUrl);
|
|
367
539
|
if (!validated.ok) return { ok: false, message: validated.message };
|
|
540
|
+
if (options.org !== void 0 && !validOrgId(options.org)) {
|
|
541
|
+
return { ok: false, message: "org must be an organization UUID" };
|
|
542
|
+
}
|
|
368
543
|
const base = baseUrl.replace(/\/+$/, "");
|
|
369
544
|
if (options.noBrowser === true || !browserAvailable(options)) {
|
|
370
545
|
return manualLogin(base, options);
|
|
@@ -437,6 +612,7 @@ async function loopbackLogin(baseUrl, options) {
|
|
|
437
612
|
const detail = error instanceof Error ? error.message : String(error);
|
|
438
613
|
return { ok: false, message: `could not open a browser: ${detail}` };
|
|
439
614
|
}
|
|
615
|
+
if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
|
|
440
616
|
notify(`Opening ${url}`);
|
|
441
617
|
notify("Waiting for the browser authorization (Ctrl-C to abort)\u2026");
|
|
442
618
|
const callback = await callbackPromise;
|
|
@@ -523,15 +699,15 @@ function awaitCallback(server, state2, timeoutMs) {
|
|
|
523
699
|
});
|
|
524
700
|
server.on("request", (request, response) => {
|
|
525
701
|
const respond = callbackResponder(response);
|
|
526
|
-
const respondWithPage = (
|
|
702
|
+
const respondWithPage = (status2, message, pageState) => {
|
|
527
703
|
if (callbackPageResponses >= MAX_PAGE_RESPONSES) {
|
|
528
704
|
respondEmpty(response, 429);
|
|
529
705
|
return Promise.resolve();
|
|
530
706
|
}
|
|
531
707
|
callbackPageResponses += 1;
|
|
532
|
-
return respond(
|
|
708
|
+
return respond(status2, message, pageState);
|
|
533
709
|
};
|
|
534
|
-
const respondAtRoot = (
|
|
710
|
+
const respondAtRoot = (status2, message, pageState) => {
|
|
535
711
|
const rootPhase = phase === "terminal" ? "terminal" : "waiting";
|
|
536
712
|
const responses = rootPageResponses.get(rootPhase) ?? 0;
|
|
537
713
|
if (responses >= MAX_PAGE_RESPONSES) {
|
|
@@ -539,7 +715,7 @@ function awaitCallback(server, state2, timeoutMs) {
|
|
|
539
715
|
return Promise.resolve();
|
|
540
716
|
}
|
|
541
717
|
rootPageResponses.set(rootPhase, responses + 1);
|
|
542
|
-
return respond(
|
|
718
|
+
return respond(status2, message, pageState);
|
|
543
719
|
};
|
|
544
720
|
let url;
|
|
545
721
|
try {
|
|
@@ -623,12 +799,12 @@ function awaitCallback(server, state2, timeoutMs) {
|
|
|
623
799
|
resolve({
|
|
624
800
|
ok: true,
|
|
625
801
|
code,
|
|
626
|
-
respond: async (
|
|
627
|
-
finalResponse = { status, message, pageState };
|
|
802
|
+
respond: async (status2, message, pageState) => {
|
|
803
|
+
finalResponse = { status: status2, message, pageState };
|
|
628
804
|
phase = "terminal";
|
|
629
805
|
const responders = acceptedResponders.splice(0);
|
|
630
806
|
await Promise.all(
|
|
631
|
-
responders.map((acceptedRespond) => trackResponse(acceptedRespond(
|
|
807
|
+
responders.map((acceptedRespond) => trackResponse(acceptedRespond(status2, message, pageState)))
|
|
632
808
|
);
|
|
633
809
|
await drainTrackedResponses();
|
|
634
810
|
}
|
|
@@ -640,7 +816,7 @@ function awaitCallback(server, state2, timeoutMs) {
|
|
|
640
816
|
});
|
|
641
817
|
}
|
|
642
818
|
function callbackResponder(response) {
|
|
643
|
-
return async (
|
|
819
|
+
return async (status2, message, state2) => {
|
|
644
820
|
if (response.writableEnded || response.destroyed) return;
|
|
645
821
|
return new Promise((resolve) => {
|
|
646
822
|
let flushTimer;
|
|
@@ -671,7 +847,7 @@ function callbackResponder(response) {
|
|
|
671
847
|
}
|
|
672
848
|
try {
|
|
673
849
|
const html = loginPage(message, state2);
|
|
674
|
-
response.writeHead(
|
|
850
|
+
response.writeHead(status2, {
|
|
675
851
|
"cache-control": "no-store",
|
|
676
852
|
connection: "close",
|
|
677
853
|
"content-length": String(Buffer.byteLength(html)),
|
|
@@ -691,10 +867,10 @@ function callbackResponder(response) {
|
|
|
691
867
|
});
|
|
692
868
|
};
|
|
693
869
|
}
|
|
694
|
-
function respondEmpty(response,
|
|
870
|
+
function respondEmpty(response, status2) {
|
|
695
871
|
response.once("error", () => void 0);
|
|
696
872
|
try {
|
|
697
|
-
response.writeHead(
|
|
873
|
+
response.writeHead(status2, {
|
|
698
874
|
"cache-control": "no-store",
|
|
699
875
|
connection: "close",
|
|
700
876
|
"x-content-type-options": "nosniff"
|
|
@@ -733,6 +909,7 @@ async function exchangeCode(baseUrl, code, verifier, options) {
|
|
|
733
909
|
async function manualLogin(baseUrl, options) {
|
|
734
910
|
const notify = options.notify ?? defaultNotify;
|
|
735
911
|
const prompt = options.prompt ?? defaultPrompt;
|
|
912
|
+
if (options.org !== void 0) notify(`Choose organization ${options.org} on the login page; another organization's token will be refused.`);
|
|
736
913
|
notify(
|
|
737
914
|
`Open ${baseUrl}/cli/login in a browser, authorize, and paste the token below (an outside expert pastes the pxc_ credential minted in their expert view instead).`
|
|
738
915
|
);
|
|
@@ -741,17 +918,23 @@ async function manualLogin(baseUrl, options) {
|
|
|
741
918
|
return verifyAndStore(baseUrl, token, options, { minted: false, kind: null });
|
|
742
919
|
}
|
|
743
920
|
async function verifyAndStore(baseUrl, token, options, received) {
|
|
744
|
-
const
|
|
921
|
+
const defaultPath = options.credentialsPath ?? defaultCredentialsPath(options.env ?? process.env);
|
|
922
|
+
const path = options.org === void 0 ? defaultPath : orgCredentialsPath(defaultPath, baseUrl, options.org);
|
|
745
923
|
const client = bareClient(baseUrl, token, options);
|
|
746
924
|
const expert = isExpertToken(token);
|
|
747
925
|
let kind = received.kind;
|
|
748
926
|
try {
|
|
749
927
|
if (expert) {
|
|
928
|
+
if (options.org !== void 0) return { ok: false, message: "cannot verify an outside expert credential against a repo org \u2014 nothing was stored" };
|
|
750
929
|
await client.get(EXPERT_ASSIGNMENTS_PATH);
|
|
751
930
|
} else {
|
|
752
931
|
const described = isLoginCredential(token) ? await describeSession(client) : null;
|
|
932
|
+
if (described !== null) kind = described.session.kind ?? kind;
|
|
933
|
+
if (options.org !== void 0 && (typeof described?.session.org?.["id"] !== "string" || described.session.org["id"].toLowerCase() !== options.org.toLowerCase())) {
|
|
934
|
+
const revoked = received.minted && await revokeSession(client, token);
|
|
935
|
+
return { ok: false, message: `the received token does not verify organization ${options.org} \u2014 nothing was stored` + (revoked ? ", and the token was revoked" : "") };
|
|
936
|
+
}
|
|
753
937
|
if (described === null) await client.get("/api/v1/teams");
|
|
754
|
-
else kind = described.session.kind ?? kind;
|
|
755
938
|
}
|
|
756
939
|
} catch (error) {
|
|
757
940
|
if (error instanceof HttpError) {
|
|
@@ -815,14 +998,14 @@ async function describeSession(client) {
|
|
|
815
998
|
}
|
|
816
999
|
}
|
|
817
1000
|
function sessionOf(data) {
|
|
818
|
-
if (!
|
|
1001
|
+
if (!isRecord3(data)) return NO_SESSION;
|
|
819
1002
|
const text = (value) => typeof value === "string" ? value : null;
|
|
820
1003
|
return {
|
|
821
1004
|
kind: text(data["kind"]),
|
|
822
1005
|
expires_at: text(data["expires_at"]),
|
|
823
1006
|
scopes: Array.isArray(data["scopes"]) ? data["scopes"].filter((scope) => typeof scope === "string") : null,
|
|
824
|
-
user:
|
|
825
|
-
org:
|
|
1007
|
+
user: isRecord3(data["user"]) ? data["user"] : null,
|
|
1008
|
+
org: isRecord3(data["org"]) ? data["org"] : null
|
|
826
1009
|
};
|
|
827
1010
|
}
|
|
828
1011
|
async function revokeSession(client, token) {
|
|
@@ -957,12 +1140,12 @@ function sessionLines(session) {
|
|
|
957
1140
|
return lines;
|
|
958
1141
|
}
|
|
959
1142
|
function expertSeat(data) {
|
|
960
|
-
if (!
|
|
961
|
-
const plan2 =
|
|
962
|
-
const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(
|
|
1143
|
+
if (!isRecord3(data) || !isRecord3(data["expert"])) return null;
|
|
1144
|
+
const plan2 = isRecord3(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
|
|
1145
|
+
const steps = Array.isArray(data["assignments"]) ? data["assignments"].filter(isRecord3).length : 0;
|
|
963
1146
|
return { id: data["expert"]["id"], role: data["expert"]["role"] ?? null, plan: plan2, assigned_steps: steps };
|
|
964
1147
|
}
|
|
965
|
-
function
|
|
1148
|
+
function isRecord3(value) {
|
|
966
1149
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
967
1150
|
}
|
|
968
1151
|
function bareClient(baseUrl, token, options) {
|
|
@@ -1564,6 +1747,22 @@ function interactive() {
|
|
|
1564
1747
|
const ci = process.env["CI"];
|
|
1565
1748
|
return process.stdout.isTTY === true && (ci === void 0 || ci === "");
|
|
1566
1749
|
}
|
|
1750
|
+
function canConfirmRepoLogin() {
|
|
1751
|
+
return process.stdin.isTTY === true && process.stderr.isTTY === true;
|
|
1752
|
+
}
|
|
1753
|
+
async function confirmRepoLogin(baseUrl, source, path) {
|
|
1754
|
+
if (source !== "repo-config" || isHostTrusted(baseUrl)) return true;
|
|
1755
|
+
const answer = await promptLine(
|
|
1756
|
+
`This repo's config (${path ?? ".harness-cli/config.json"}) targets ${baseUrl} \u2014 log in there? [y/N] `
|
|
1757
|
+
);
|
|
1758
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
1759
|
+
writeStderr(`harness: not logging in to ${baseUrl} \u2014 set HARNESS_API_URL to override the repo config
|
|
1760
|
+
`);
|
|
1761
|
+
return false;
|
|
1762
|
+
}
|
|
1763
|
+
trustHost(baseUrl);
|
|
1764
|
+
return true;
|
|
1765
|
+
}
|
|
1567
1766
|
async function withConfig(jsonFlag, mutating, run2) {
|
|
1568
1767
|
const resolved = resolveConfig();
|
|
1569
1768
|
for (const warning of resolved.warnings) writeStderr(warning + "\n");
|
|
@@ -1575,22 +1774,14 @@ async function withConfig(jsonFlag, mutating, run2) {
|
|
|
1575
1774
|
if (resolved.kind === "needs-login") {
|
|
1576
1775
|
if (!interactive()) {
|
|
1577
1776
|
writeStderr(loginHelp(resolved.baseUrl));
|
|
1777
|
+
if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
|
|
1778
|
+
`);
|
|
1578
1779
|
return exitWith(2);
|
|
1579
1780
|
}
|
|
1580
|
-
if (resolved.hostSource
|
|
1581
|
-
|
|
1582
|
-
`This repo's config (${resolved.repoConfigPath ?? ".harness-cli/config.json"}) targets ${resolved.baseUrl} \u2014 log in there? [y/N] `
|
|
1583
|
-
);
|
|
1584
|
-
if (answer.trim().toLowerCase() !== "y") {
|
|
1585
|
-
writeStderr(
|
|
1586
|
-
`harness: not logging in to ${resolved.baseUrl} \u2014 set HARNESS_API_URL to override the repo config
|
|
1587
|
-
`
|
|
1588
|
-
);
|
|
1589
|
-
return exitWith(2);
|
|
1590
|
-
}
|
|
1591
|
-
trustHost(resolved.baseUrl);
|
|
1781
|
+
if (!await confirmRepoLogin(resolved.baseUrl, resolved.hostSource, resolved.repoConfigPath)) {
|
|
1782
|
+
return exitWith(2);
|
|
1592
1783
|
}
|
|
1593
|
-
const loginResult = await login(resolved.baseUrl, {});
|
|
1784
|
+
const loginResult = await login(resolved.baseUrl, resolved.org === void 0 ? {} : { org: resolved.org });
|
|
1594
1785
|
if (!loginResult.ok) {
|
|
1595
1786
|
writeStderr(`harness: ${clean(loginResult.message)}
|
|
1596
1787
|
`);
|
|
@@ -1926,32 +2117,37 @@ plan.command("create").description("create a plan from a JSON or YAML file (the
|
|
|
1926
2117
|
return planCreate(ctx, readPlanFile(opts.file), { ...opts, team: team2 });
|
|
1927
2118
|
});
|
|
1928
2119
|
});
|
|
1929
|
-
plan.command("
|
|
2120
|
+
plan.command("list").description("a team's plans, newest first: root, status, held, revision and title").option("--team <key>", "the team whose issues root the plans (or HARNESS_TEAM)").option("--status <status>", PLAN_STATUSES.join(" | ")).option("--limit <n>", `maximum plans (default 50, at most ${PLAN_LIST_MAX_LIMIT}; there are no pages)`, integer).option("--json", "emit JSON").action(async (opts) => {
|
|
2121
|
+
requirePlanListArgs(opts);
|
|
2122
|
+
await withConfig(opts.json === true, false, (ctx) => planList(ctx, opts));
|
|
2123
|
+
});
|
|
2124
|
+
var PLAN_ARGUMENT = "the plan's id, or its root issue's identifier (e.g. FR-1)";
|
|
2125
|
+
plan.command("show").description("a plan's status, budgets, revision, steps, edges and wait conditions").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
|
|
1930
2126
|
await withConfig(opts.json === true, false, (ctx) => {
|
|
1931
|
-
if (id === void 0) throw new UsageError("usage: harness plan show <plan-id>");
|
|
2127
|
+
if (id === void 0) throw new UsageError("usage: harness plan show <plan-id-or-root>");
|
|
1932
2128
|
return planShow(ctx, id);
|
|
1933
2129
|
});
|
|
1934
2130
|
});
|
|
1935
|
-
plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[
|
|
2131
|
+
plan.command("edit").description("change step executors and keys, and budgets, from a JSON or YAML file").argument("[plan]", PLAN_ARGUMENT).option("--file <path>", 'the edit: .json is JSON, anything else YAML ("-" for stdin)').option("--json", "emit JSON").action(async (id, opts) => {
|
|
1936
2132
|
await withConfig(opts.json === true, true, (ctx) => {
|
|
1937
2133
|
if (id === void 0 || opts.file === void 0) {
|
|
1938
|
-
throw new UsageError("usage: harness plan edit <plan-id> --file <edits.json|edits.yaml>");
|
|
2134
|
+
throw new UsageError("usage: harness plan edit <plan-id-or-root> --file <edits.json|edits.yaml>");
|
|
1939
2135
|
}
|
|
1940
|
-
|
|
2136
|
+
requirePlanRef(id);
|
|
1941
2137
|
return planEdit(ctx, id, readPlanFile(opts.file));
|
|
1942
2138
|
});
|
|
1943
2139
|
});
|
|
1944
|
-
plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[
|
|
2140
|
+
plan.command("release").description("release a plan as yourself: freeze its next revision and let its ready steps run").argument("[plan]", PLAN_ARGUMENT).option("--collaborator <who>", "a person of this organization (id or email) for the plan room (repeatable)", collect, []).option("--retry-budget <n>", "retries per failed Operator step", integer).option("--spend-budget-cents <n>", "the plan's spend budget; needed to release Operator steps", integer).option("--json", "emit JSON").action(
|
|
1945
2141
|
async (id, opts) => {
|
|
1946
2142
|
await withConfig(opts.json === true, true, (ctx) => {
|
|
1947
|
-
if (id === void 0) throw new UsageError("usage: harness plan release <plan-id>");
|
|
2143
|
+
if (id === void 0) throw new UsageError("usage: harness plan release <plan-id-or-root>");
|
|
1948
2144
|
return planRelease(ctx, id, { ...opts, collaborators: opts.collaborator });
|
|
1949
2145
|
});
|
|
1950
2146
|
}
|
|
1951
2147
|
);
|
|
1952
|
-
plan.command("hold").description("pause every step of a plan until it is released again").argument("[
|
|
2148
|
+
plan.command("hold").description("pause every step of a plan until it is released again").argument("[plan]", PLAN_ARGUMENT).option("--json", "emit JSON").action(async (id, opts) => {
|
|
1953
2149
|
await withConfig(opts.json === true, true, (ctx) => {
|
|
1954
|
-
if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id>");
|
|
2150
|
+
if (id === void 0) throw new UsageError("usage: harness plan hold <plan-id-or-root>");
|
|
1955
2151
|
return planHold(ctx, id);
|
|
1956
2152
|
});
|
|
1957
2153
|
});
|
|
@@ -2347,15 +2543,15 @@ link.command("rm").argument("[issue]").argument("[id-or-url]").option("--json",
|
|
|
2347
2543
|
});
|
|
2348
2544
|
}
|
|
2349
2545
|
);
|
|
2350
|
-
var
|
|
2351
|
-
|
|
2546
|
+
var label2 = program.command("label").description("labels");
|
|
2547
|
+
label2.command("list").option("--json", "emit JSON").action(async (opts) => {
|
|
2352
2548
|
await withConfig(
|
|
2353
2549
|
opts.json === true,
|
|
2354
2550
|
false,
|
|
2355
2551
|
(ctx) => labelList(ctx)
|
|
2356
2552
|
);
|
|
2357
2553
|
});
|
|
2358
|
-
|
|
2554
|
+
label2.command("create").argument("[name]").option("--color <color>", "label color").option("--json", "emit JSON").action(
|
|
2359
2555
|
async (name, opts) => {
|
|
2360
2556
|
await withConfig(
|
|
2361
2557
|
opts.json === true,
|
|
@@ -2364,10 +2560,10 @@ label.command("create").argument("[name]").option("--color <color>", "label colo
|
|
|
2364
2560
|
);
|
|
2365
2561
|
}
|
|
2366
2562
|
);
|
|
2367
|
-
|
|
2563
|
+
label2.command("update").description("rename or recolor a label").argument("[id]").option("--name <name>", "new name").option("--color <color>", "swatch name (red, blue, \u2026) or a hex code").option("--json", "emit JSON").action(async (id, opts) => {
|
|
2368
2564
|
await withConfig(opts.json === true, true, (ctx) => labelUpdate(ctx, id, opts));
|
|
2369
2565
|
});
|
|
2370
|
-
|
|
2566
|
+
label2.command("delete").description("delete a label (refused while issues carry it)").argument("[id]").option("--yes", "confirm the deletion").option("--json", "emit JSON").action(async (id, opts) => {
|
|
2371
2567
|
await withConfig(opts.json === true, true, (ctx) => labelDelete(ctx, id, opts));
|
|
2372
2568
|
});
|
|
2373
2569
|
var team = program.command("team").description("teams");
|
|
@@ -2478,7 +2674,7 @@ program.command("skills").description("the organization's skills assigned to Ope
|
|
|
2478
2674
|
await withConfig(opts.json === true, false, (ctx) => skillsList(ctx));
|
|
2479
2675
|
});
|
|
2480
2676
|
program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
|
|
2481
|
-
const { startMcpServer } = await import("./server-
|
|
2677
|
+
const { startMcpServer } = await import("./server-F6YRKNC2.js");
|
|
2482
2678
|
await startMcpServer(invokedAs);
|
|
2483
2679
|
});
|
|
2484
2680
|
var keys = program.command("keys").description("the org's provider API keys, handed to tooling without printing them").enablePositionalOptions();
|
|
@@ -2500,6 +2696,17 @@ keys.command("exec").description(
|
|
|
2500
2696
|
(ctx) => exec(ctx, { command, override: opts.override === true, only })
|
|
2501
2697
|
);
|
|
2502
2698
|
});
|
|
2699
|
+
var review = program.command("review").description("a pull request's enforced Review Council gate, and a retry of its latest attempt");
|
|
2700
|
+
review.command("status").description("the gate's enforced and advisory status, and a failed latest attempt with what comes next").argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--json", "emit JSON").action(async (pr, opts) => {
|
|
2701
|
+
const ref = parsePullRequest(pr, opts.repo);
|
|
2702
|
+
await withConfig(opts.json === true, false, (ctx) => status(ctx, ref));
|
|
2703
|
+
});
|
|
2704
|
+
review.command("retry").description(
|
|
2705
|
+
"retry the latest gate attempt of the pull request's current head (needs reviews:write); the head, check and attempt are read from the gate status"
|
|
2706
|
+
).argument("<pr>", "owner/repo#<pr>, the pull request URL, or <pr> with --repo or this checkout's origin").option("--repo <owner/repo>", "the repository a bare pull request number belongs to").option("--reason <text>", "why the attempt is retried, recorded with it").option("--json", "emit JSON").action(async (pr, opts) => {
|
|
2707
|
+
const ref = parsePullRequest(pr, opts.repo);
|
|
2708
|
+
await withConfig(opts.json === true, true, (ctx) => retry(ctx, ref, opts.reason));
|
|
2709
|
+
});
|
|
2503
2710
|
program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
|
|
2504
2711
|
"--timeout <ms>",
|
|
2505
2712
|
"how long to wait for the browser callback",
|
|
@@ -2510,20 +2717,36 @@ program.command("login").description("sign in via the browser (infra.one session
|
|
|
2510
2717
|
for (const warning of repoLookup.warnings)
|
|
2511
2718
|
writeStderr(warning + "\n");
|
|
2512
2719
|
const repoUrl = repoLookup.config?.url;
|
|
2513
|
-
const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? storedLoginUrl() ?? DEFAULT_BASE_URL;
|
|
2720
|
+
const url = opts.url ?? blankToUndefined(process.env["HARNESS_API_URL"]) ?? repoUrl ?? (repoLookup.config?.org === void 0 ? storedLoginUrl() : void 0) ?? DEFAULT_BASE_URL;
|
|
2514
2721
|
if (url === repoUrl && opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0) {
|
|
2515
2722
|
writeStderr(
|
|
2516
2723
|
`harness: host ${url} comes from ${repoLookup.config?.path}
|
|
2517
2724
|
`
|
|
2518
2725
|
);
|
|
2519
2726
|
}
|
|
2727
|
+
const repoSelected = opts.url === void 0 && blankToUndefined(process.env["HARNESS_API_URL"]) === void 0 && repoUrl !== void 0;
|
|
2728
|
+
if (repoSelected && !isHostTrusted(url)) {
|
|
2729
|
+
if (!canConfirmRepoLogin()) {
|
|
2730
|
+
writeStderr("harness: cannot confirm a repo-selected host without a terminal \u2014 pass --url explicitly\n");
|
|
2731
|
+
return exitWith(2);
|
|
2732
|
+
}
|
|
2733
|
+
if (!await confirmRepoLogin(url, "repo-config", repoLookup.config?.path)) return exitWith(2);
|
|
2734
|
+
}
|
|
2520
2735
|
const options = { noBrowser: !opts.browser };
|
|
2736
|
+
if (repoLookup.config?.org !== void 0) options.org = repoLookup.config.org;
|
|
2521
2737
|
if (opts.timeout !== void 0) options.timeoutMs = opts.timeout;
|
|
2522
2738
|
await finishAuth(await login(url, options), opts.json === true);
|
|
2523
2739
|
}
|
|
2524
2740
|
);
|
|
2525
2741
|
program.command("logout").description("revoke and forget the stored login").option("--json", "emit JSON").action(async (opts) => {
|
|
2526
|
-
|
|
2742
|
+
const selected = resolveConfig({ ...process.env, HARNESS_API_TOKEN: void 0 });
|
|
2743
|
+
if (selected.kind === "error") {
|
|
2744
|
+
writeStderr(`harness: ${selected.message}
|
|
2745
|
+
`);
|
|
2746
|
+
return exitWith(2);
|
|
2747
|
+
}
|
|
2748
|
+
const path = selected.kind === "ok" ? selected.config.credentialsPath : selected.credentialsPath;
|
|
2749
|
+
await finishAuth(await logout(path === void 0 ? {} : { credentialsPath: path }), opts.json === true);
|
|
2527
2750
|
});
|
|
2528
2751
|
program.command("whoami").description("show the resolved host and credential source").option("--json", "emit JSON").action(async (opts) => {
|
|
2529
2752
|
const resolved = resolveConfig();
|
|
@@ -2536,6 +2759,8 @@ program.command("whoami").description("show the resolved host and credential sou
|
|
|
2536
2759
|
}
|
|
2537
2760
|
if (resolved.kind === "needs-login") {
|
|
2538
2761
|
writeStderr(loginHelp(resolved.baseUrl));
|
|
2762
|
+
if (resolved.org !== void 0) writeStderr(`Choose organization ${resolved.org} when logging in.
|
|
2763
|
+
`);
|
|
2539
2764
|
return exitWith(2);
|
|
2540
2765
|
}
|
|
2541
2766
|
const source = blankToUndefined(process.env["HARNESS_API_TOKEN"]) !== void 0 ? "environment" : "stored login";
|