@indigoai-us/hq-cli 5.268.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.
@@ -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 splitCommandChain(command) {
384
- const segments = [];
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 ghPrSubcommand(words) {
485
- const groupIndex = words.indexOf("pr", 1);
486
- if (groupIndex < 0)
375
+ async function reviewCheckContext(hqRoot, deps) {
376
+ const laneId = deps.env.HQ_LANE_ID?.trim();
377
+ if (!laneId)
487
378
  return null;
488
- for (let index = groupIndex + 1; index < words.length; index += 1) {
489
- const word = words[index] ?? "";
490
- if (word === "-R" || word === "--repo" || word === "--hostname") {
491
- index += 1;
492
- continue;
493
- }
494
- if (word.startsWith("--repo=") || (word.startsWith("-R") && word.length > 2))
495
- continue;
496
- if (word.startsWith("-"))
497
- continue;
498
- return { groupIndex, subcommand: word };
499
- }
500
- return { groupIndex, subcommand: null };
501
- }
502
- const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
503
- const PR_MERGE_VALUE_OPTIONS = new Set([
504
- "-A", "--author-email", "-b", "--body", "-F", "--body-file",
505
- "--match-head-commit", "-t", "--subject",
506
- ]);
507
- const PR_MERGE_INLINE_VALUE_OPTIONS = [
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
- if (word === "--preserve-status" || word === "--foreground" || word === "--verbose" || word === "-v") {
557
- index += 1;
558
- continue;
400
+ catch (error) {
401
+ readFailure = error;
402
+ failed = true;
559
403
  }
560
- if (word.startsWith("-")) {
561
- index += 1;
562
- continue;
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
- // timeout always takes a duration before the wrapped command.
567
- return Math.min(index + 1, words.length);
568
- }
569
- function unwrapCommand(words) {
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 words.slice(index);
420
+ return null;
587
421
  }
588
- function pullRequestUrlRepo(words, start) {
589
- for (let index = start; index < words.length; index += 1) {
590
- const word = words[index] ?? "";
591
- if (PR_MERGE_VALUE_OPTIONS.has(word)) {
592
- index += 1;
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
- catch {
606
- continue;
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
- const match = /^\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/.exec(url.pathname);
609
- if (!match)
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
- catch {
615
- return fail("repo_hold_repo_invalid", "Pull request URL has an invalid repository", { field: "repo" });
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 null;
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
- if (readLanesFlagLocalOverride(LANES_REPO_MERGE_HOLD_FLAG, deps.env) === false) {
745
- printJson({ ok: true, hold_enforcement: "off" });
746
- return;
747
- }
748
- let unresolved = false;
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
- const repos = [];
752
- for (const segment of splitCommandChain(options.command)) {
753
- const checked = checkedMerge(segment);
754
- if (!checked)
755
- continue;
756
- if ("repo_unresolved" in checked) {
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
- const activeHolds = [...new Set(repos)]
764
- .map((repo) => readHold(deps.hqRoot(), repo))
765
- .filter((existing) => existing !== null && holdView(existing, now).active);
766
- let deniedHold;
767
- for (const existing of activeHolds) {
768
- const enabled = await (deps.mergeHoldEnabled ?? ((company) => readLanesRegistryFlag(LANES_REPO_MERGE_HOLD_FLAG, { company, env: deps.env })))(existing.company);
769
- if (!enabled) {
770
- gateWasOff = true;
771
- continue;
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
- if (deniedHold) {
776
- printJson({
777
- ok: false,
778
- error: "repo_merge_held",
779
- repo: deniedHold.repo,
780
- reason: deniedHold.reason,
781
- until: deniedHold.until,
782
- });
783
- process.exitCode = 3;
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
- ...(unresolved ? { repo_unresolved: true } : {}),
597
+ ...(repoUnresolved ? { repo_unresolved: true } : {}),
598
+ ...(reviewUnresolved ? { review_unresolved: reviewUnresolved } : {}),
790
599
  });
791
600
  }
792
601
  catch (error) {
793
- if (!(error instanceof LanesError))
794
- throw error;
795
- console.error(`hq lanes hold check: ${LANES_REPO_MERGE_HOLD_FLAG} could not verify this command (${error.error}); allowing it`);
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: error.error,
609
+ check_error: checkError,
799
610
  ...(gateWasOff ? { hold_enforcement: "off" } : {}),
800
- ...(unresolved ? { repo_unresolved: true } : {}),
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(ctx.hqRoot(), lane.lane_id);
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(ctx.hqRoot());
31
- const retiredLaneIds = loadRetiredLaneIds(ctx.hqRoot());
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 {
@@ -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
  }