@indigoai-us/hq-cli 5.267.0 → 5.269.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/CHANGELOG.md +23 -0
- package/dist/command-catalog.generated.d.ts +73 -0
- package/dist/command-catalog.generated.js +94 -0
- package/dist/commands/lanes-hold.d.ts +2 -0
- package/dist/commands/lanes-hold.js +122 -310
- package/dist/commands/lanes-message.js +6 -4
- package/dist/commands/lanes-review.d.ts +10 -0
- package/dist/commands/lanes-review.js +56 -0
- package/dist/commands/lanes-shared.d.ts +1 -0
- package/dist/commands/lanes.d.ts +2 -0
- package/dist/commands/lanes.js +26 -1
- package/dist/lib/checkpoint-ontology/multi-writer.d.ts +1 -1
- package/dist/lib/checkpoint-ontology/multi-writer.js +3 -0
- package/dist/lib/lanes/blocker-freshness.js +2 -1
- package/dist/lib/lanes/keep-alive.js +49 -20
- package/dist/lib/lanes/kr-freshness.js +3 -2
- package/dist/lib/lanes/lane-company.d.ts +3 -2
- package/dist/lib/lanes/lane-company.js +21 -4
- package/dist/lib/lanes/manager-status-gate.js +5 -3
- package/dist/lib/lanes/merge-command.d.ts +14 -0
- package/dist/lib/lanes/merge-command.js +363 -0
- package/dist/lib/lanes/reviews.d.ts +38 -0
- package/dist/lib/lanes/reviews.js +395 -0
- package/dist/lib/lanes/watcher.js +4 -2
- package/package.json +1 -1
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
4
|
+
import { canonicalRepo, parseMergeCommands } from "../lib/lanes/merge-command.js";
|
|
4
5
|
import { listLaneIds, readLane } from "../lib/lanes/store.js";
|
|
5
6
|
import { appendDropboxMessage } from "../lib/lanes/dropbox.js";
|
|
6
7
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
7
8
|
import { readLanesFlagLocalOverride, readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
8
9
|
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
10
|
+
import { readLaneReview } from "../lib/lanes/reviews.js";
|
|
11
|
+
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
9
12
|
export const LANES_REPO_MERGE_HOLD_FLAG = "lanes.repo-merge-hold";
|
|
13
|
+
export const LANES_REVIEW_BEFORE_MERGE_FLAG = "lanes.review-before-merge";
|
|
10
14
|
export const REPO_MERGE_HOLDS_REL = path.join("workspace", "lanes", "holds");
|
|
11
15
|
const HOLD_SCHEMA_VERSION = "hq-lanes-repo-merge-hold.v1";
|
|
12
16
|
const HISTORY_CAP = 50;
|
|
@@ -15,21 +19,6 @@ const HOLD_LOCK_STALE_MS = 5 * 60_000;
|
|
|
15
19
|
function fail(error, message, details = {}) {
|
|
16
20
|
throw new LanesError(error, message, details);
|
|
17
21
|
}
|
|
18
|
-
function canonicalRepo(raw) {
|
|
19
|
-
const value = raw.trim();
|
|
20
|
-
const parts = value.split("/");
|
|
21
|
-
if (parts.length !== 2) {
|
|
22
|
-
return fail("repo_hold_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
|
|
23
|
-
}
|
|
24
|
-
const [owner, repository] = parts;
|
|
25
|
-
if (!owner || !repository ||
|
|
26
|
-
!/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner) ||
|
|
27
|
-
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
|
|
28
|
-
owner === "." || owner === ".." || repository === "." || repository === "..") {
|
|
29
|
-
return fail("repo_hold_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
|
|
30
|
-
}
|
|
31
|
-
return `${owner.toLowerCase()}/${repository.toLowerCase()}`;
|
|
32
|
-
}
|
|
33
22
|
function holdFilePath(hqRoot, repo) {
|
|
34
23
|
const [owner, repository] = canonicalRepo(repo).split("/");
|
|
35
24
|
return path.join(hqRoot, REPO_MERGE_HOLDS_REL, `${owner}__${repository}.json`);
|
|
@@ -380,276 +369,84 @@ function reportNotificationWarnings(warnings) {
|
|
|
380
369
|
for (const warning of warnings)
|
|
381
370
|
console.error(`hq lanes hold: ${warning}`);
|
|
382
371
|
}
|
|
383
|
-
function
|
|
384
|
-
|
|
385
|
-
let current = "";
|
|
386
|
-
let quote = null;
|
|
387
|
-
let escaped = false;
|
|
388
|
-
for (let index = 0; index < command.length; index += 1) {
|
|
389
|
-
const character = command[index] ?? "";
|
|
390
|
-
if (escaped) {
|
|
391
|
-
current += character;
|
|
392
|
-
escaped = false;
|
|
393
|
-
continue;
|
|
394
|
-
}
|
|
395
|
-
if (character === "\\" && quote !== "'") {
|
|
396
|
-
current += character;
|
|
397
|
-
escaped = true;
|
|
398
|
-
continue;
|
|
399
|
-
}
|
|
400
|
-
if (quote) {
|
|
401
|
-
current += character;
|
|
402
|
-
if (character === quote)
|
|
403
|
-
quote = null;
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (character === "'" || character === "\"") {
|
|
407
|
-
quote = character;
|
|
408
|
-
current += character;
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
if (character === ";" || character === "|") {
|
|
412
|
-
segments.push(current.trim());
|
|
413
|
-
current = "";
|
|
414
|
-
if (character === "|" && command[index + 1] === "|")
|
|
415
|
-
index += 1;
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
if (character === "&" && command[index + 1] === "&") {
|
|
419
|
-
segments.push(current.trim());
|
|
420
|
-
current = "";
|
|
421
|
-
index += 1;
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
current += character;
|
|
425
|
-
}
|
|
426
|
-
if (quote !== null || escaped) {
|
|
427
|
-
return fail("repo_hold_command_invalid", "Command has an unclosed quote or escape");
|
|
428
|
-
}
|
|
429
|
-
segments.push(current.trim());
|
|
430
|
-
return segments.filter(Boolean);
|
|
431
|
-
}
|
|
432
|
-
function shellWords(segment) {
|
|
433
|
-
const words = [];
|
|
434
|
-
let current = "";
|
|
435
|
-
let quote = null;
|
|
436
|
-
let escaped = false;
|
|
437
|
-
for (const character of segment) {
|
|
438
|
-
if (escaped) {
|
|
439
|
-
current += character;
|
|
440
|
-
escaped = false;
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
if (character === "\\" && quote !== "'") {
|
|
444
|
-
escaped = true;
|
|
445
|
-
continue;
|
|
446
|
-
}
|
|
447
|
-
if (quote) {
|
|
448
|
-
if (character === quote)
|
|
449
|
-
quote = null;
|
|
450
|
-
else
|
|
451
|
-
current += character;
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
if (character === "'" || character === "\"") {
|
|
455
|
-
quote = character;
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
if (/\s/.test(character)) {
|
|
459
|
-
if (current)
|
|
460
|
-
words.push(current);
|
|
461
|
-
current = "";
|
|
462
|
-
continue;
|
|
463
|
-
}
|
|
464
|
-
current += character;
|
|
465
|
-
}
|
|
466
|
-
if (escaped)
|
|
467
|
-
current += "\\";
|
|
468
|
-
if (current)
|
|
469
|
-
words.push(current);
|
|
470
|
-
return words;
|
|
471
|
-
}
|
|
472
|
-
function optionRepo(words) {
|
|
473
|
-
for (let index = 0; index < words.length; index += 1) {
|
|
474
|
-
const word = words[index] ?? "";
|
|
475
|
-
if ((word === "-R" || word === "--repo") && words[index + 1])
|
|
476
|
-
return words[index + 1] ?? null;
|
|
477
|
-
if (word.startsWith("--repo="))
|
|
478
|
-
return word.slice("--repo=".length);
|
|
479
|
-
if (word.startsWith("-R") && word.length > 2)
|
|
480
|
-
return word.slice(2);
|
|
481
|
-
}
|
|
482
|
-
return null;
|
|
372
|
+
function printJson(value) {
|
|
373
|
+
console.log(JSON.stringify(value));
|
|
483
374
|
}
|
|
484
|
-
function
|
|
485
|
-
const
|
|
486
|
-
if (
|
|
375
|
+
async function reviewCheckContext(hqRoot, deps) {
|
|
376
|
+
const laneId = deps.env.HQ_LANE_ID?.trim();
|
|
377
|
+
if (!laneId)
|
|
487
378
|
return null;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
"--author-email=", "--body=", "--body-file=", "--match-head-commit=", "--subject=",
|
|
509
|
-
];
|
|
510
|
-
function skipEnvOptions(words, start) {
|
|
511
|
-
let index = start + 1;
|
|
512
|
-
while (index < words.length) {
|
|
513
|
-
const word = words[index] ?? "";
|
|
514
|
-
if (SHELL_ASSIGNMENT.test(word)) {
|
|
515
|
-
index += 1;
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
if (word === "-i" || word === "--ignore-environment") {
|
|
519
|
-
index += 1;
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
if (word === "-u" || word === "--unset" || word === "-C" || word === "--chdir") {
|
|
523
|
-
index += 2;
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
if (word.startsWith("--unset=") || word.startsWith("--chdir=")) {
|
|
527
|
-
index += 1;
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
if (word === "--")
|
|
531
|
-
return index + 1;
|
|
532
|
-
if (word.startsWith("-")) {
|
|
533
|
-
index += 1;
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
return index;
|
|
539
|
-
}
|
|
540
|
-
function skipTimeoutOptions(words, start) {
|
|
541
|
-
let index = start + 1;
|
|
542
|
-
while (index < words.length) {
|
|
543
|
-
const word = words[index] ?? "";
|
|
544
|
-
if (word === "--") {
|
|
545
|
-
index += 1;
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
if (word === "-k" || word === "--kill-after" || word === "-s" || word === "--signal") {
|
|
549
|
-
index += 2;
|
|
550
|
-
continue;
|
|
551
|
-
}
|
|
552
|
-
if (word.startsWith("--kill-after=") || word.startsWith("--signal=")) {
|
|
553
|
-
index += 1;
|
|
554
|
-
continue;
|
|
379
|
+
const localOverride = readLanesFlagLocalOverride(LANES_REVIEW_BEFORE_MERGE_FLAG, deps.env);
|
|
380
|
+
if (localOverride === false)
|
|
381
|
+
return null;
|
|
382
|
+
const caller = readLane(hqRoot, laneId);
|
|
383
|
+
const company = resolveLaneCompany(hqRoot, caller).company;
|
|
384
|
+
if (!company)
|
|
385
|
+
return null;
|
|
386
|
+
let enabled = localOverride;
|
|
387
|
+
if (enabled === null) {
|
|
388
|
+
let readFailure;
|
|
389
|
+
let failed = false;
|
|
390
|
+
try {
|
|
391
|
+
enabled = await (deps.mergeReviewEnabled ?? ((company) => readLanesRegistryFlag(LANES_REVIEW_BEFORE_MERGE_FLAG, {
|
|
392
|
+
company,
|
|
393
|
+
env: deps.env,
|
|
394
|
+
reportFailure: (error) => {
|
|
395
|
+
readFailure = error;
|
|
396
|
+
failed = true;
|
|
397
|
+
},
|
|
398
|
+
})))(company);
|
|
555
399
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
400
|
+
catch (error) {
|
|
401
|
+
readFailure = error;
|
|
402
|
+
failed = true;
|
|
559
403
|
}
|
|
560
|
-
if (
|
|
561
|
-
|
|
562
|
-
|
|
404
|
+
if (failed) {
|
|
405
|
+
return fail("review_flag_unreadable", "Review enforcement flag could not be read", {
|
|
406
|
+
error: readFailure instanceof Error ? readFailure.name : "UnknownError",
|
|
407
|
+
});
|
|
563
408
|
}
|
|
564
|
-
break;
|
|
565
409
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
let index = 0;
|
|
571
|
-
while (index < words.length) {
|
|
572
|
-
if (SHELL_ASSIGNMENT.test(words[index] ?? "")) {
|
|
573
|
-
index += 1;
|
|
574
|
-
continue;
|
|
575
|
-
}
|
|
576
|
-
if (words[index] === "env") {
|
|
577
|
-
index = skipEnvOptions(words, index);
|
|
578
|
-
continue;
|
|
579
|
-
}
|
|
580
|
-
if (words[index] === "timeout") {
|
|
581
|
-
index = skipTimeoutOptions(words, index);
|
|
410
|
+
if (!enabled)
|
|
411
|
+
return null;
|
|
412
|
+
for (const juniorId of listLaneIds(hqRoot)) {
|
|
413
|
+
if (juniorId === laneId)
|
|
582
414
|
continue;
|
|
415
|
+
const junior = readLane(hqRoot, juniorId);
|
|
416
|
+
if (junior.senior.kind === "lane" && junior.senior.id === laneId) {
|
|
417
|
+
return { laneId, company };
|
|
583
418
|
}
|
|
584
|
-
break;
|
|
585
419
|
}
|
|
586
|
-
return
|
|
420
|
+
return null;
|
|
587
421
|
}
|
|
588
|
-
function
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
595
|
-
if (PR_MERGE_INLINE_VALUE_OPTIONS.some((option) => word.startsWith(option)))
|
|
596
|
-
continue;
|
|
597
|
-
if (word.startsWith("-"))
|
|
422
|
+
function checkReviewTargets(hqRoot, context, targets) {
|
|
423
|
+
let unresolved;
|
|
424
|
+
for (const target of targets) {
|
|
425
|
+
if (target.unresolvedReason || target.repo === null || target.pr === null) {
|
|
426
|
+
unresolved ??= target.unresolvedReason ?? "pull request could not be resolved";
|
|
598
427
|
continue;
|
|
599
|
-
if (!/^https?:\/\//i.test(word))
|
|
600
|
-
return null;
|
|
601
|
-
let url;
|
|
602
|
-
try {
|
|
603
|
-
url = new URL(word);
|
|
604
428
|
}
|
|
605
|
-
|
|
606
|
-
|
|
429
|
+
const record = readLaneReview(hqRoot, target.repo, target.pr);
|
|
430
|
+
if (!record) {
|
|
431
|
+
return { denial: { ok: false, error: "review_record_missing", repo: target.repo, pr: target.pr } };
|
|
607
432
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
continue;
|
|
611
|
-
try {
|
|
612
|
-
return canonicalRepo(`${decodeURIComponent(match[1] ?? "")}/${decodeURIComponent(match[2] ?? "")}`);
|
|
433
|
+
if (!target.pinnedHead) {
|
|
434
|
+
return { denial: { ok: false, error: "review_head_unpinned", repo: target.repo, pr: target.pr } };
|
|
613
435
|
}
|
|
614
|
-
|
|
615
|
-
return
|
|
436
|
+
if (target.pinnedHead !== record.head_sha) {
|
|
437
|
+
return {
|
|
438
|
+
denial: {
|
|
439
|
+
ok: false,
|
|
440
|
+
error: "review_head_mismatch",
|
|
441
|
+
repo: target.repo,
|
|
442
|
+
pr: target.pr,
|
|
443
|
+
reviewed_head: record.head_sha,
|
|
444
|
+
merge_head: target.pinnedHead,
|
|
445
|
+
},
|
|
446
|
+
};
|
|
616
447
|
}
|
|
617
448
|
}
|
|
618
|
-
return
|
|
619
|
-
}
|
|
620
|
-
function checkedMerge(segment) {
|
|
621
|
-
const words = unwrapCommand(shellWords(segment));
|
|
622
|
-
if (words[0] === "git" && words.slice(1).includes("merge"))
|
|
623
|
-
return { repo_unresolved: true };
|
|
624
|
-
if (words[0] !== "gh")
|
|
625
|
-
return null;
|
|
626
|
-
const pr = ghPrSubcommand(words);
|
|
627
|
-
if (pr?.subcommand === "merge") {
|
|
628
|
-
const rawRepo = optionRepo(words) ?? pullRequestUrlRepo(words, pr.groupIndex + 2);
|
|
629
|
-
if (!rawRepo)
|
|
630
|
-
return { repo_unresolved: true };
|
|
631
|
-
return { repo: canonicalRepo(rawRepo) };
|
|
632
|
-
}
|
|
633
|
-
const apiIndex = words.indexOf("api", 1);
|
|
634
|
-
if (apiIndex < 0)
|
|
635
|
-
return null;
|
|
636
|
-
const rest = words.slice(apiIndex + 1);
|
|
637
|
-
const graphql = rest.includes("graphql");
|
|
638
|
-
if (graphql && rest.some((word) => word.includes("mergePullRequest"))) {
|
|
639
|
-
// gh api does not accept gh's inherited -R/--repo flag. GraphQL merge input
|
|
640
|
-
// identifies the target by pull-request node ID, which cannot be resolved
|
|
641
|
-
// to a repository without a network read, so this command stays unresolved.
|
|
642
|
-
return { repo_unresolved: true };
|
|
643
|
-
}
|
|
644
|
-
for (const word of rest) {
|
|
645
|
-
const match = /^(?:https?:\/\/api\.github\.com)?\/?repos\/([^/]+)\/([^/]+)\/pulls\/\d+\/merge\/?$/.exec(word);
|
|
646
|
-
if (match)
|
|
647
|
-
return { repo: canonicalRepo(`${match[1]}/${match[2]}`) };
|
|
648
|
-
}
|
|
649
|
-
return null;
|
|
650
|
-
}
|
|
651
|
-
function printJson(value) {
|
|
652
|
-
console.log(JSON.stringify(value));
|
|
449
|
+
return unresolved === undefined ? {} : { unresolved };
|
|
653
450
|
}
|
|
654
451
|
export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
|
|
655
452
|
const hold = lanes.command("hold").description("Set and enforce repository merge holds");
|
|
@@ -741,63 +538,78 @@ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
|
|
|
741
538
|
.action(wrap(async (options) => {
|
|
742
539
|
if (!options.command.trim())
|
|
743
540
|
fail("repo_hold_command_empty", "Command to check must not be empty");
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
let
|
|
749
|
-
let gateWasOff = false;
|
|
541
|
+
const hqRoot = deps.hqRoot();
|
|
542
|
+
const holdFlagOff = readLanesFlagLocalOverride(LANES_REPO_MERGE_HOLD_FLAG, deps.env) === false;
|
|
543
|
+
let gateWasOff = holdFlagOff;
|
|
544
|
+
let repoUnresolved = false;
|
|
545
|
+
let reviewUnresolved;
|
|
750
546
|
try {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (!
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
unresolved = true;
|
|
758
|
-
continue;
|
|
547
|
+
let reviewContext = null;
|
|
548
|
+
if (holdFlagOff) {
|
|
549
|
+
reviewContext = await reviewCheckContext(hqRoot, deps);
|
|
550
|
+
if (!reviewContext) {
|
|
551
|
+
printJson({ ok: true, hold_enforcement: "off" });
|
|
552
|
+
return;
|
|
759
553
|
}
|
|
760
|
-
repos.push(checked.repo);
|
|
761
554
|
}
|
|
555
|
+
const parsed = parseMergeCommands(options.command);
|
|
556
|
+
repoUnresolved = parsed.targets.some((target) => target.repo === null);
|
|
557
|
+
const repos = parsed.targets.flatMap((target) => target.repo === null ? [] : [target.repo]);
|
|
762
558
|
const now = deps.now();
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
559
|
+
if (!holdFlagOff) {
|
|
560
|
+
const activeHolds = [...new Set(repos)]
|
|
561
|
+
.map((repo) => readHold(hqRoot, repo))
|
|
562
|
+
.filter((existing) => existing !== null && holdView(existing, now).active);
|
|
563
|
+
let deniedHold;
|
|
564
|
+
for (const existing of activeHolds) {
|
|
565
|
+
const enabled = await (deps.mergeHoldEnabled ?? ((company) => readLanesRegistryFlag(LANES_REPO_MERGE_HOLD_FLAG, { company, env: deps.env })))(existing.company);
|
|
566
|
+
if (!enabled) {
|
|
567
|
+
gateWasOff = true;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
deniedHold ??= existing;
|
|
571
|
+
}
|
|
572
|
+
if (deniedHold) {
|
|
573
|
+
printJson({
|
|
574
|
+
ok: false,
|
|
575
|
+
error: "repo_merge_held",
|
|
576
|
+
repo: deniedHold.repo,
|
|
577
|
+
reason: deniedHold.reason,
|
|
578
|
+
until: deniedHold.until,
|
|
579
|
+
});
|
|
580
|
+
process.exitCode = 3;
|
|
581
|
+
return;
|
|
772
582
|
}
|
|
773
|
-
deniedHold ??= existing;
|
|
774
583
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
return;
|
|
584
|
+
reviewContext ??= await reviewCheckContext(hqRoot, deps);
|
|
585
|
+
if (reviewContext) {
|
|
586
|
+
const reviewCheck = checkReviewTargets(hqRoot, reviewContext, parsed.targets);
|
|
587
|
+
if (reviewCheck.denial) {
|
|
588
|
+
printJson(reviewCheck.denial);
|
|
589
|
+
process.exitCode = 3;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
reviewUnresolved = reviewCheck.unresolved;
|
|
785
593
|
}
|
|
786
594
|
printJson({
|
|
787
595
|
ok: true,
|
|
788
596
|
...(gateWasOff ? { hold_enforcement: "off" } : {}),
|
|
789
|
-
...(
|
|
597
|
+
...(repoUnresolved ? { repo_unresolved: true } : {}),
|
|
598
|
+
...(reviewUnresolved ? { review_unresolved: reviewUnresolved } : {}),
|
|
790
599
|
});
|
|
791
600
|
}
|
|
792
601
|
catch (error) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
602
|
+
const checkError = error instanceof LanesError ? error.error : "review_check_failed";
|
|
603
|
+
const failedGate = checkError.startsWith("review_")
|
|
604
|
+
? LANES_REVIEW_BEFORE_MERGE_FLAG
|
|
605
|
+
: LANES_REPO_MERGE_HOLD_FLAG;
|
|
606
|
+
console.error(`hq lanes hold check: ${failedGate} could not verify this command (${checkError}); allowing it`);
|
|
796
607
|
printJson({
|
|
797
608
|
ok: true,
|
|
798
|
-
check_error:
|
|
609
|
+
check_error: checkError,
|
|
799
610
|
...(gateWasOff ? { hold_enforcement: "off" } : {}),
|
|
800
|
-
...(
|
|
611
|
+
...(repoUnresolved ? { repo_unresolved: true } : {}),
|
|
612
|
+
...(reviewUnresolved ? { review_unresolved: reviewUnresolved } : {}),
|
|
801
613
|
});
|
|
802
614
|
}
|
|
803
615
|
}));
|
|
@@ -12,6 +12,7 @@ import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLane
|
|
|
12
12
|
import { keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
|
|
13
13
|
import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
|
|
14
14
|
import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
15
|
+
import { resolveLaneCompany } from "../lib/lanes/lane-company.js";
|
|
15
16
|
import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
|
|
16
17
|
export function createLaneMessageHandlers(deps) {
|
|
17
18
|
const { archiveResumeMessage, assertLaneRunsHereForAdmission, assertLaneRunsHereForOperatorMutation, assessLaneAdmission, casUpdateLane, dispatchOutpostLaneCommand, drainHookStatusFor, laneIsHostedHere, laneWithRecoveredProviderSession, loadAllLanes, mintMessageId, persistClaudeAccountSelection, prepareAccountMove, printJson, recordResumeSetup, resolveActiveQuestionCompany, resolveResumeProfile, selectAutomaticClaudeResumeAccount, validateResumeProfileModel, withResumeHireAdmission } = deps;
|
|
@@ -19,16 +20,17 @@ export function createLaneMessageHandlers(deps) {
|
|
|
19
20
|
if (lane.state === "killed" || lane.state === "cancelled") {
|
|
20
21
|
return { lane, exclusion: lane.state };
|
|
21
22
|
}
|
|
23
|
+
const hqRoot = ctx.hqRoot();
|
|
22
24
|
const lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
|
|
23
|
-
company: lane.company,
|
|
25
|
+
company: resolveLaneCompany(hqRoot, lane).company ?? undefined,
|
|
24
26
|
env: ctx.env,
|
|
25
27
|
});
|
|
26
|
-
const currentLane = readLane(
|
|
28
|
+
const currentLane = readLane(hqRoot, lane.lane_id);
|
|
27
29
|
if (currentLane.state === "killed" || currentLane.state === "cancelled") {
|
|
28
30
|
return { lane: currentLane, exclusion: currentLane.state };
|
|
29
31
|
}
|
|
30
|
-
const lanes = loadAllLanes(
|
|
31
|
-
const retiredLaneIds = loadRetiredLaneIds(
|
|
32
|
+
const lanes = loadAllLanes(hqRoot);
|
|
33
|
+
const retiredLaneIds = loadRetiredLaneIds(hqRoot);
|
|
32
34
|
return {
|
|
33
35
|
lane: currentLane,
|
|
34
36
|
exclusion: keepAliveExclusion(currentLane, lanes, retiredLaneIds, lineageAware),
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
3
|
+
export interface LaneReviewCommandDeps {
|
|
4
|
+
hqRoot: () => string;
|
|
5
|
+
env: NodeJS.ProcessEnv;
|
|
6
|
+
now: () => Date;
|
|
7
|
+
}
|
|
8
|
+
export declare function registerLaneReviewCommand(lanes: Command, deps: LaneReviewCommandDeps, wrap: WrapHandler): void;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=lanes-review.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LanesError } from "../lib/lanes/errors.js";
|
|
2
|
+
import { canonicalLaneReviewRepo, listLaneReviews, parseLaneReviewPr, readLaneReview, recordLaneReview, } from "../lib/lanes/reviews.js";
|
|
3
|
+
import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
4
|
+
function fail(error, message, details = {}) {
|
|
5
|
+
throw new LanesError(error, message, details);
|
|
6
|
+
}
|
|
7
|
+
function printJson(value) {
|
|
8
|
+
console.log(JSON.stringify(value));
|
|
9
|
+
}
|
|
10
|
+
export function registerLaneReviewCommand(lanes, deps, wrap) {
|
|
11
|
+
const review = lanes.command("review").description("Record manager review of pull-request heads");
|
|
12
|
+
review.command("record <repo> <pr-number>")
|
|
13
|
+
.description("Record a review against an exact pull-request head")
|
|
14
|
+
.requiredOption("--head <sha>", "40-character commit SHA that was reviewed")
|
|
15
|
+
.option("--worker <lane-id>", "Worker lane whose output was reviewed")
|
|
16
|
+
.option("--note <text>", "Short note about the review")
|
|
17
|
+
.option("--json", "Print JSON")
|
|
18
|
+
.action(wrap(async (repoArg, prArg, options) => {
|
|
19
|
+
const reviewRecord = await recordLaneReview(deps.hqRoot(), {
|
|
20
|
+
repo: repoArg,
|
|
21
|
+
pr: prArg,
|
|
22
|
+
headSha: options.head,
|
|
23
|
+
reviewerLane: deps.env.HQ_LANE_ID?.trim() || null,
|
|
24
|
+
reviewerSession: sessionIdFromEnv(deps.env) ?? null,
|
|
25
|
+
...(options.worker === undefined ? {} : { workerLane: options.worker }),
|
|
26
|
+
...(options.note === undefined ? {} : { note: options.note }),
|
|
27
|
+
recordedAt: deps.now(),
|
|
28
|
+
});
|
|
29
|
+
printJson({ ok: true, action: "lanes.review.record", review: reviewRecord });
|
|
30
|
+
}));
|
|
31
|
+
review.command("show <repo> <pr-number>")
|
|
32
|
+
.description("Show the review record for one pull request")
|
|
33
|
+
.option("--json", "Print JSON")
|
|
34
|
+
.action(wrap(async (repoArg, prArg) => {
|
|
35
|
+
const repo = canonicalLaneReviewRepo(repoArg);
|
|
36
|
+
const pr = parseLaneReviewPr(prArg);
|
|
37
|
+
const record = readLaneReview(deps.hqRoot(), repo, pr);
|
|
38
|
+
if (!record)
|
|
39
|
+
fail("review_record_missing", `No review record exists for ${repo} PR ${pr}`, { repo, pr });
|
|
40
|
+
printJson({ ok: true, action: "lanes.review.show", review: record });
|
|
41
|
+
}));
|
|
42
|
+
review.command("list")
|
|
43
|
+
.description("List recorded pull-request reviews")
|
|
44
|
+
.option("--repo <owner/repo>", "Limit results to one repository")
|
|
45
|
+
.option("--json", "Print JSON")
|
|
46
|
+
.action(wrap(async (options) => {
|
|
47
|
+
const repo = options.repo === undefined ? undefined : canonicalLaneReviewRepo(options.repo);
|
|
48
|
+
const listing = listLaneReviews(deps.hqRoot());
|
|
49
|
+
const reviews = listing.reviews.filter((item) => repo === undefined || item.repo === repo);
|
|
50
|
+
if (listing.skipped > 0) {
|
|
51
|
+
console.error(`hq lanes review list: skipped ${listing.skipped} invalid or unreadable review item(s)`);
|
|
52
|
+
}
|
|
53
|
+
printJson({ ok: true, action: "lanes.review.list", reviews, reviews_skipped: listing.skipped });
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=lanes-review.js.map
|
|
@@ -222,6 +222,7 @@ export interface Ctx {
|
|
|
222
222
|
isSsmOwnerDispatch: () => boolean;
|
|
223
223
|
withGrantHireLocks: typeof withLaneHireLocks;
|
|
224
224
|
laneTrackerEnabled: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
225
|
+
mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
225
226
|
trackerActor: () => string | null;
|
|
226
227
|
}
|
|
227
228
|
export interface LaneWaitCommandOptions {
|
package/dist/commands/lanes.d.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface LanesCommandDeps {
|
|
|
92
92
|
withGrantHireLocks?: typeof withLaneHireLocks;
|
|
93
93
|
/** Test seam for the hq-flags gated tracker commands. */
|
|
94
94
|
laneTrackerEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
95
|
+
/** Test seam for the review-before-merge registry flag. */
|
|
96
|
+
mergeReviewEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
95
97
|
/** Test seam for recording approvals against the active identity. */
|
|
96
98
|
trackerActor?: () => string | null;
|
|
97
99
|
}
|