@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.
@@ -23,6 +23,7 @@ import { registerLaneChecksCommand } from "./lanes-checks.js";
23
23
  import { registerLaneReportCommands } from "./lanes-report.js";
24
24
  import { registerLaneTrackerImportCommand } from "./lanes-import.js";
25
25
  import { registerLaneRepoMergeHoldCommand } from "./lanes-hold.js";
26
+ import { registerLaneReviewCommand } from "./lanes-review.js";
26
27
  import { ensureCognitoToken } from "../utils/cognito-session.js";
27
28
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
28
29
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
@@ -47,6 +48,7 @@ import { CompanySelectionError } from "../utils/company-selection-error.js";
47
48
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
48
49
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
49
50
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
51
+ import { listLaneReviews } from "../lib/lanes/reviews.js";
50
52
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
51
53
  import { AccountResolutionError, compareLaneQueueOrder, LANE_PROVIDERS, LANE_STATES, QUESTION_STATUSES, SENIOR_KINDS, accountStatus, appendCappedHistory, canonicalizeHome, createLane, decideAdmission, defaultLivenessDeps, defaultSystemdUserDir, defaultThrottleDeps, installReconcileTimer, providerPathEnv, probeDrainHookRegistration, isLanesError, isLaneVersionConflict, isTerminalLaneState, aliveDirectChildren, assignedChildHireGrants, hireGrantCapacityError, planHireGrantBackfill, terminalChildResumeHireGrantError, withHireGrantAdmission, laneStoryLedgerEnabled, validateProjectStories, buildLaneStatusSnapshot, formatLaneStatusHuman, listLaneIds, listQuestionIds, loadLanesConfig, laneRunsHere, MAX_LANE_DEPTH, MONITOR_ERROR_EXIT_CODE, readLiveMonitorEvidence, resolveMachineIdentity as lookupMachineIdentity, loadLivenessIndex, parseLaneProvider, parseLaneRecord, parseLaneState, parseQuestionRecord, parseSeniorKind, readLane, retryUpdateLane, syncLaneRecordFromOwner, readQuestion, resolveAccount, resolveLaneWorker, ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithRegistry, registryWorkerRecord, runReconcilePass, sessionLiveness, tryReadLane, uninstallReconcileTimer, updateLane, updateQuestion, dropboxDir, laneDeliveryMechanism, hasActiveAdmissionReservation, hasActivePendingResume, hasRecordedSessionId, hasEarlierEligibleQueuedLane, laneAdmissionCapReason, providerSupportsSessionResume, providerSessionPortable, sessionNotPortableMessage, withLaneAdmissionLock, withAllLaneAdmissionLocks, withLaneHireLocks, raiseLaneQuestion, sha256Text, } from "../lib/lanes/index.js";
52
54
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
@@ -291,6 +293,7 @@ function resolveDeps(deps = {}) {
291
293
  isSsmOwnerDispatch: deps.isSsmOwnerDispatch ?? (() => isSsmRunCommandProcess()),
292
294
  withGrantHireLocks: deps.withGrantHireLocks ?? withLaneHireLocks,
293
295
  laneTrackerEnabled: deps.laneTrackerEnabled ?? defaultLaneTrackerEnabled,
296
+ mergeReviewEnabled: deps.mergeReviewEnabled,
294
297
  trackerActor: deps.trackerActor ?? defaultTrackerActor,
295
298
  };
296
299
  }
@@ -4541,10 +4544,26 @@ async function runShow(ctx, laneId) {
4541
4544
  env: ctx.env,
4542
4545
  now: ctx.now().getTime(),
4543
4546
  });
4547
+ const laneIsSenior = allLanes.some((item) => item.senior.kind === "lane" && item.senior.id === laneId);
4548
+ const now = ctx.now().getTime();
4549
+ const reviewListing = laneIsSenior
4550
+ ? listLaneReviews(hqRoot, laneId)
4551
+ : { reviews: [], skipped: 0 };
4552
+ if (reviewListing.skipped > 0) {
4553
+ console.error(`hq lanes show: skipped ${reviewListing.skipped} invalid or unreadable review item(s)`);
4554
+ }
4555
+ const recentReviews = reviewListing.reviews
4556
+ .filter((item) => {
4557
+ const recordedAt = Date.parse(item.recorded_at);
4558
+ return recordedAt >= now - 24 * 60 * 60 * 1000 && recordedAt <= now;
4559
+ })
4560
+ .slice(0, 20)
4561
+ .map(({ repo, pr, head_sha, recorded_at }) => ({ repo, pr, head_sha, recorded_at }));
4544
4562
  printJson({
4545
4563
  ok: true,
4546
4564
  action: "show",
4547
4565
  lane,
4566
+ ...(laneIsSenior ? { reviews: recentReviews, reviews_skipped: reviewListing.skipped } : {}),
4548
4567
  ...hire,
4549
4568
  overdue: Boolean(lane.overdue) || isOverdue(hqRoot, lane.lane_id, ctx.now()),
4550
4569
  stalled: Boolean(lane.stalled),
@@ -4741,7 +4760,7 @@ async function runKeepAlive(ctx, laneId, opts) {
4741
4760
  const initialLane = readLane(hqRoot, laneId);
4742
4761
  assertKeepAliveChangeAllowed(ctx, initialLane, laneId);
4743
4762
  lineageAware = await readLanesRegistryFlag(LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, {
4744
- company: initialLane.company,
4763
+ company: resolveLaneCompany(hqRoot, initialLane).company ?? undefined,
4745
4764
  env: ctx.env,
4746
4765
  });
4747
4766
  }
@@ -6927,6 +6946,12 @@ export function registerLanesCommand(parent, deps = {}) {
6927
6946
  resolveCompany: ctx.resolveCompany,
6928
6947
  now: ctx.now,
6929
6948
  env: ctx.env,
6949
+ mergeReviewEnabled: ctx.mergeReviewEnabled,
6950
+ }, wrap);
6951
+ registerLaneReviewCommand(lanes, {
6952
+ hqRoot: ctx.hqRoot,
6953
+ env: ctx.env,
6954
+ now: ctx.now,
6930
6955
  }, wrap);
6931
6956
  registerOutpostPrunerCommand(lanes);
6932
6957
  registerLaneKpisCommand(lanes, { hqRoot: ctx.hqRoot, now: ctx.now, wrap });
@@ -4,6 +4,7 @@ import { LanesError } from "./errors.js";
4
4
  import { DEFAULT_BLOCKER_FRESHNESS_HOURS, loadLanesConfig } from "./config.js";
5
5
  import { resolveManagerStatusTarget } from "./manager-status-gate.js";
6
6
  import { readLanesRegistryFlag } from "./registry-flag.js";
7
+ import { resolveLaneCompany } from "./lane-company.js";
7
8
  import { assertNoSecretText } from "./text-scan.js";
8
9
  export const BLOCKER_FRESHNESS_FLAG = "lanes.blocker-freshness";
9
10
  export const BLOCKER_FRESHNESS_DEFAULT_HOURS = DEFAULT_BLOCKER_FRESHNESS_HOURS;
@@ -100,7 +101,7 @@ export async function resolveBlockerFreshnessForLane(root, lane, options = {}) {
100
101
  let flagEnabled;
101
102
  try {
102
103
  flagEnabled = await (options.readFlag ?? readLanesRegistryFlag)(BLOCKER_FRESHNESS_FLAG, {
103
- company: lane.company ?? lane.mesh_company ?? undefined,
104
+ company: resolveLaneCompany(root, lane).company ?? undefined,
104
105
  env: options.env,
105
106
  });
106
107
  }
@@ -206,20 +206,23 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
206
206
  const companyOf = companyForLane ?? ((candidate) => candidate.company);
207
207
  const laneCompany = companyOf(lane);
208
208
  const lanesById = new Map(lanes.map((candidate) => [candidate.lane_id, candidate]));
209
- const targetAncestorIds = new Set();
210
- if (lineageAware) {
211
- let parentLaneId = lane.senior.kind === "lane" ? lane.senior.id : undefined;
212
- while (parentLaneId) {
213
- if (targetAncestorIds.has(parentLaneId))
214
- break;
215
- targetAncestorIds.add(parentLaneId);
216
- const parent = lanesById.get(parentLaneId);
217
- parentLaneId = parent?.senior.kind === "lane" ? parent.senior.id : undefined;
209
+ lanesById.set(lane.lane_id, lane);
210
+ const sessionOwners = new Map();
211
+ for (const owner of lanesById.values()) {
212
+ for (const sessionId of recordedLaneSessionIds(owner)) {
213
+ const owners = sessionOwners.get(sessionId) ?? new Set();
214
+ owners.add(owner.lane_id);
215
+ sessionOwners.set(sessionId, owners);
218
216
  }
219
217
  }
218
+ const targetAncestorIds = lineageAware
219
+ ? laneAncestorsFrom(lane.senior, lanesById, sessionOwners)
220
+ : new Set();
220
221
  return lanes.find((candidate) => {
221
222
  if (candidate.lane_id === lane.lane_id || Date.parse(candidate.created_at) <= createdAt)
222
223
  return false;
224
+ if (candidate.state === "cancelled" || candidate.state === "killed" || candidate.state === "failed")
225
+ return false;
223
226
  const candidateCompany = companyOf(candidate);
224
227
  const sameCompany = laneCompany === null
225
228
  ? candidateCompany === null
@@ -229,17 +232,8 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
229
232
  if (lineageAware && targetAncestorIds.has(candidate.lane_id))
230
233
  return false;
231
234
  // Any descendant is part of this lead's work tree, not a replacement lead.
232
- let parentLaneId = candidate.senior.kind === "lane" ? candidate.senior.id : undefined;
233
- const visitedLaneIds = new Set();
234
- while (parentLaneId) {
235
- if (parentLaneId === lane.lane_id)
236
- return false;
237
- if (visitedLaneIds.has(parentLaneId))
238
- break;
239
- visitedLaneIds.add(parentLaneId);
240
- const parent = lanesById.get(parentLaneId);
241
- parentLaneId = parent?.senior.kind === "lane" ? parent.senior.id : undefined;
242
- }
235
+ if (laneAncestorsFrom(candidate.senior, lanesById, sessionOwners).has(lane.lane_id))
236
+ return false;
243
237
  const sameLaneSenior = lane.senior.kind === "lane" &&
244
238
  candidate.senior.kind === "lane" &&
245
239
  candidate.senior.id === lane.senior.id;
@@ -252,6 +246,41 @@ export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESS
252
246
  return sameProfile || projectLead;
253
247
  });
254
248
  }
249
+ /** Session ids durable lane records still identify as spawn/resume sessions. */
250
+ function recordedLaneSessionIds(lane) {
251
+ return [lane.provider_session_id, lane.last_resume?.session_id].filter((sessionId) => typeof sessionId === "string" && sessionId.trim().length > 0);
252
+ }
253
+ /** Follow both lane seniors and session seniors back to the lane that owns each session. */
254
+ function laneAncestorsFrom(senior, lanesById, sessionOwners) {
255
+ const laneIds = new Set();
256
+ const sessionIds = new Set();
257
+ const pending = [senior];
258
+ while (pending.length > 0) {
259
+ const current = pending.pop();
260
+ if (current === undefined)
261
+ break;
262
+ if (current.kind === "lane") {
263
+ const laneId = current.id;
264
+ if (typeof laneId !== "string" || laneId.trim().length === 0 || laneIds.has(laneId))
265
+ continue;
266
+ laneIds.add(laneId);
267
+ const parent = lanesById.get(laneId);
268
+ if (parent !== undefined)
269
+ pending.push(parent.senior);
270
+ continue;
271
+ }
272
+ if (current.kind !== "session")
273
+ continue;
274
+ const sessionId = current.id;
275
+ if (typeof sessionId !== "string" || sessionId.trim().length === 0 || sessionIds.has(sessionId))
276
+ continue;
277
+ sessionIds.add(sessionId);
278
+ for (const ownerLaneId of sessionOwners.get(sessionId) ?? []) {
279
+ pending.push({ kind: "lane", id: ownerLaneId });
280
+ }
281
+ }
282
+ return laneIds;
283
+ }
255
284
  export function keepAliveExclusion(lane, lanes, retiredLaneIds, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED, companyForLane) {
256
285
  if (isRetiredLaneId(lane.lane_id, retiredLaneIds))
257
286
  return "retired";
@@ -5,6 +5,7 @@ import { DEFAULT_KR_FRESHNESS_HOURS, loadLanesConfig } from "./config.js";
5
5
  import { LanesError } from "./errors.js";
6
6
  import { resolveManagerStatusTarget } from "./manager-status-gate.js";
7
7
  import { readLanesRegistryFlag } from "./registry-flag.js";
8
+ import { resolveLaneCompany } from "./lane-company.js";
8
9
  import { assertNoSecretText } from "./text-scan.js";
9
10
  export const KR_FRESHNESS_FLAG = "lanes.kr-freshness";
10
11
  export const KR_FRESHNESS_DEFAULT_HOURS = DEFAULT_KR_FRESHNESS_HOURS;
@@ -327,7 +328,7 @@ export async function resolveKrFreshnessForLane(root, lane, options = {}) {
327
328
  let flagEnabled;
328
329
  try {
329
330
  flagEnabled = await (options.readFlag ?? readLanesRegistryFlag)(KR_FRESHNESS_FLAG, {
330
- company: lane.company ?? lane.mesh_company ?? undefined,
331
+ company: resolveLaneCompany(root, lane).company ?? undefined,
331
332
  env: options.env,
332
333
  });
333
334
  }
@@ -378,7 +379,7 @@ export async function resolveKrFreshnessForShow(root, lane, options = {}) {
378
379
  let enabled;
379
380
  try {
380
381
  enabled = await (options.readFlag ?? readLanesRegistryFlag)(KR_FRESHNESS_FLAG, {
381
- company: lane.company ?? lane.mesh_company ?? undefined,
382
+ company: resolveLaneCompany(root, lane).company ?? undefined,
382
383
  env: options.env,
383
384
  });
384
385
  }
@@ -1,9 +1,10 @@
1
1
  import type { LaneCompanyRef, LaneRecord } from "./types.js";
2
- export type LaneCompanySource = "lane" | "project" | "unresolved";
2
+ export type LaneCompanySource = "lane" | "mesh_company" | "project" | "unresolved";
3
3
  export interface LaneCompanyResolution {
4
4
  company: LaneCompanyRef | null;
5
5
  source: LaneCompanySource;
6
6
  }
7
7
  /** Resolve a lane's company without inferring ownership from its worker or caller. */
8
- export declare function resolveLaneCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "project_id">): LaneCompanyResolution;
8
+ export declare function resolveLaneCompany(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company" | "project_id">): LaneCompanyResolution;
9
+ export declare function laneCompanyUidSource(source: LaneCompanySource): string | undefined;
9
10
  //# sourceMappingURL=lane-company.d.ts.map
@@ -2,10 +2,15 @@ import { resolveProjectQuestionCompany } from "./questions.js";
2
2
  import { LanesError } from "./errors.js";
3
3
  /** Resolve a lane's company without inferring ownership from its worker or caller. */
4
4
  export function resolveLaneCompany(hqRoot, lane) {
5
- const slug = typeof lane.company?.slug === "string" ? lane.company.slug.trim() : "";
6
- const uid = typeof lane.company?.uid === "string" ? lane.company.uid.trim() : "";
7
- if (slug && uid) {
8
- return { company: { slug, uid }, source: "lane" };
5
+ const laneCompanySlug = typeof lane.company?.slug === "string" ? lane.company.slug.trim() : "";
6
+ const laneCompanyUid = typeof lane.company?.uid === "string" ? lane.company.uid.trim() : "";
7
+ if (laneCompanySlug && laneCompanyUid) {
8
+ return { company: { slug: laneCompanySlug, uid: laneCompanyUid }, source: "lane" };
9
+ }
10
+ const meshCompanySlug = typeof lane.mesh_company?.slug === "string" ? lane.mesh_company.slug.trim() : "";
11
+ const meshCompanyUid = typeof lane.mesh_company?.uid === "string" ? lane.mesh_company.uid.trim() : "";
12
+ if (meshCompanySlug && meshCompanyUid) {
13
+ return { company: { slug: meshCompanySlug, uid: meshCompanyUid }, source: "mesh_company" };
9
14
  }
10
15
  let projectCompany;
11
16
  try {
@@ -21,4 +26,16 @@ export function resolveLaneCompany(hqRoot, lane) {
21
26
  ? { company: projectCompany, source: "project" }
22
27
  : { company: null, source: "unresolved" };
23
28
  }
29
+ export function laneCompanyUidSource(source) {
30
+ switch (source) {
31
+ case "lane":
32
+ return "lane.company.uid";
33
+ case "mesh_company":
34
+ return "lane.mesh_company.uid";
35
+ case "project":
36
+ return "project.cloud_uid";
37
+ case "unresolved":
38
+ return undefined;
39
+ }
40
+ }
24
41
  //# sourceMappingURL=lane-company.js.map
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { checkCompanyPath } from "./checked-company-path.js";
4
4
  import { LanesError } from "./errors.js";
5
+ import { laneCompanyUidSource, resolveLaneCompany } from "./lane-company.js";
5
6
  import { readLanesRegistryFlag } from "./registry-flag.js";
6
7
  export const MANAGER_STATUS_GATE_FLAG = "lanes.manager-status-gate";
7
8
  function isObject(value) {
@@ -84,7 +85,7 @@ export function resolveManagerStatusTarget(root, lane) {
84
85
  invalidMapping(`Matched juniors row needs a non-empty ${field}`, registry.filePath, { lane_id: lane.lane_id, field });
85
86
  }
86
87
  }
87
- const company = lane.company?.slug ?? lane.mesh_company?.slug ?? "";
88
+ const company = resolveLaneCompany(root, lane).company?.slug ?? "";
88
89
  const statusPath = checkCompanyPath(root, company, row.status, registry.filePath);
89
90
  return {
90
91
  manager: true,
@@ -96,11 +97,12 @@ export async function resolveManagerStatusGateForLane(root, lane, options = {})
96
97
  const reportError = options.onError ?? ((err) => logManagerStatusGateFailure(lane, err));
97
98
  let enabled;
98
99
  try {
99
- const company = lane.company ?? lane.mesh_company ?? undefined;
100
+ const resolution = resolveLaneCompany(root, lane);
101
+ const company = resolution.company ?? undefined;
100
102
  enabled = await (options.readFlag ?? readLanesRegistryFlag)(MANAGER_STATUS_GATE_FLAG, {
101
103
  company,
102
104
  ...(company ? {
103
- companyUidSource: lane.company ? "lane.company.uid" : "lane.mesh_company.uid",
105
+ companyUidSource: laneCompanyUidSource(resolution.source),
104
106
  } : {}),
105
107
  env: options.env,
106
108
  });
@@ -0,0 +1,14 @@
1
+ export interface MergeCommandTarget {
2
+ kind: "pr" | "api" | "graphql" | "git";
3
+ repo: string | null;
4
+ pr: number | null;
5
+ pinnedHead: string | null;
6
+ unresolvedReason: string | null;
7
+ }
8
+ export interface MergeCommandParseResult {
9
+ targets: MergeCommandTarget[];
10
+ unresolved: boolean;
11
+ }
12
+ export declare function canonicalRepo(raw: string): string;
13
+ export declare function parseMergeCommands(command: string): MergeCommandParseResult;
14
+ //# sourceMappingURL=merge-command.d.ts.map
@@ -0,0 +1,363 @@
1
+ import { LanesError } from "./errors.js";
2
+ function fail(error, message, details = {}) {
3
+ throw new LanesError(error, message, details);
4
+ }
5
+ export function canonicalRepo(raw) {
6
+ const value = raw.trim();
7
+ const parts = value.split("/");
8
+ if (parts.length !== 2) {
9
+ return fail("repo_hold_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
10
+ }
11
+ const [owner, repository] = parts;
12
+ if (!owner || !repository ||
13
+ !/^[A-Za-z0-9][A-Za-z0-9-]{0,38}$/.test(owner) ||
14
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(repository) ||
15
+ owner === "." || owner === ".." || repository === "." || repository === "..") {
16
+ return fail("repo_hold_repo_invalid", "Repository must be a safe owner/repo pair", { field: "repo" });
17
+ }
18
+ return `${owner.toLowerCase()}/${repository.toLowerCase()}`;
19
+ }
20
+ function splitCommandChain(command) {
21
+ const segments = [];
22
+ let current = "";
23
+ let quote = null;
24
+ let escaped = false;
25
+ for (let index = 0; index < command.length; index += 1) {
26
+ const character = command[index] ?? "";
27
+ if (escaped) {
28
+ current += character;
29
+ escaped = false;
30
+ continue;
31
+ }
32
+ if (character === "\\" && quote !== "'") {
33
+ current += character;
34
+ escaped = true;
35
+ continue;
36
+ }
37
+ if (quote) {
38
+ current += character;
39
+ if (character === quote)
40
+ quote = null;
41
+ continue;
42
+ }
43
+ if (character === "'" || character === "\"") {
44
+ quote = character;
45
+ current += character;
46
+ continue;
47
+ }
48
+ if (character === ";" || character === "|") {
49
+ segments.push(current.trim());
50
+ current = "";
51
+ if (character === "|" && command[index + 1] === "|")
52
+ index += 1;
53
+ continue;
54
+ }
55
+ if (character === "&" && command[index + 1] === "&") {
56
+ segments.push(current.trim());
57
+ current = "";
58
+ index += 1;
59
+ continue;
60
+ }
61
+ current += character;
62
+ }
63
+ if (quote !== null || escaped) {
64
+ return fail("repo_hold_command_invalid", "Command has an unclosed quote or escape");
65
+ }
66
+ segments.push(current.trim());
67
+ return segments.filter(Boolean);
68
+ }
69
+ function shellWords(segment) {
70
+ const words = [];
71
+ let current = "";
72
+ let quote = null;
73
+ let escaped = false;
74
+ for (const character of segment) {
75
+ if (escaped) {
76
+ current += character;
77
+ escaped = false;
78
+ continue;
79
+ }
80
+ if (character === "\\" && quote !== "'") {
81
+ escaped = true;
82
+ continue;
83
+ }
84
+ if (quote) {
85
+ if (character === quote)
86
+ quote = null;
87
+ else
88
+ current += character;
89
+ continue;
90
+ }
91
+ if (character === "'" || character === "\"") {
92
+ quote = character;
93
+ continue;
94
+ }
95
+ if (/\s/.test(character)) {
96
+ if (current)
97
+ words.push(current);
98
+ current = "";
99
+ continue;
100
+ }
101
+ current += character;
102
+ }
103
+ if (escaped)
104
+ current += "\\";
105
+ if (current)
106
+ words.push(current);
107
+ return words;
108
+ }
109
+ function optionRepo(words) {
110
+ for (let index = 0; index < words.length; index += 1) {
111
+ const word = words[index] ?? "";
112
+ if ((word === "-R" || word === "--repo") && words[index + 1])
113
+ return words[index + 1] ?? null;
114
+ if (word.startsWith("--repo="))
115
+ return word.slice("--repo=".length);
116
+ if (word.startsWith("-R") && word.length > 2)
117
+ return word.slice(2);
118
+ }
119
+ return null;
120
+ }
121
+ function ghPrSubcommand(words) {
122
+ const groupIndex = words.indexOf("pr", 1);
123
+ if (groupIndex < 0)
124
+ return null;
125
+ for (let index = groupIndex + 1; index < words.length; index += 1) {
126
+ const word = words[index] ?? "";
127
+ if (word === "-R" || word === "--repo" || word === "--hostname") {
128
+ index += 1;
129
+ continue;
130
+ }
131
+ if (word.startsWith("--repo=") || (word.startsWith("-R") && word.length > 2))
132
+ continue;
133
+ if (word.startsWith("-"))
134
+ continue;
135
+ return { subcommandIndex: index, subcommand: word };
136
+ }
137
+ return { subcommandIndex: words.length, subcommand: null };
138
+ }
139
+ const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
140
+ const PR_MERGE_VALUE_OPTIONS = new Set([
141
+ "-A", "--author-email", "-b", "--body", "-F", "--body-file",
142
+ "--match-head-commit", "-t", "--subject",
143
+ ]);
144
+ function skipEnvOptions(words, start) {
145
+ let index = start + 1;
146
+ while (index < words.length) {
147
+ const word = words[index] ?? "";
148
+ if (SHELL_ASSIGNMENT.test(word)) {
149
+ index += 1;
150
+ continue;
151
+ }
152
+ if (word === "-i" || word === "--ignore-environment") {
153
+ index += 1;
154
+ continue;
155
+ }
156
+ if (word === "-u" || word === "--unset" || word === "-C" || word === "--chdir") {
157
+ index += 2;
158
+ continue;
159
+ }
160
+ if (word.startsWith("--unset=") || word.startsWith("--chdir=")) {
161
+ index += 1;
162
+ continue;
163
+ }
164
+ if (word === "--")
165
+ return index + 1;
166
+ if (word.startsWith("-")) {
167
+ index += 1;
168
+ continue;
169
+ }
170
+ break;
171
+ }
172
+ return index;
173
+ }
174
+ function skipTimeoutOptions(words, start) {
175
+ let index = start + 1;
176
+ while (index < words.length) {
177
+ const word = words[index] ?? "";
178
+ if (word === "--") {
179
+ index += 1;
180
+ break;
181
+ }
182
+ if (word === "-k" || word === "--kill-after" || word === "-s" || word === "--signal") {
183
+ index += 2;
184
+ continue;
185
+ }
186
+ if (word.startsWith("--kill-after=") || word.startsWith("--signal=")) {
187
+ index += 1;
188
+ continue;
189
+ }
190
+ if (word === "--preserve-status" || word === "--foreground" || word === "--verbose" || word === "-v") {
191
+ index += 1;
192
+ continue;
193
+ }
194
+ if (word.startsWith("-")) {
195
+ index += 1;
196
+ continue;
197
+ }
198
+ break;
199
+ }
200
+ return Math.min(index + 1, words.length);
201
+ }
202
+ function unwrapCommand(words) {
203
+ let index = 0;
204
+ while (index < words.length) {
205
+ if (SHELL_ASSIGNMENT.test(words[index] ?? "")) {
206
+ index += 1;
207
+ continue;
208
+ }
209
+ if (words[index] === "env") {
210
+ index = skipEnvOptions(words, index);
211
+ continue;
212
+ }
213
+ if (words[index] === "timeout") {
214
+ index = skipTimeoutOptions(words, index);
215
+ continue;
216
+ }
217
+ break;
218
+ }
219
+ return words.slice(index);
220
+ }
221
+ function parsePullUrl(raw) {
222
+ if (!/^https?:\/\//i.test(raw))
223
+ return null;
224
+ let url;
225
+ try {
226
+ url = new URL(raw);
227
+ }
228
+ catch {
229
+ return null;
230
+ }
231
+ const match = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(url.pathname);
232
+ if (!match)
233
+ return null;
234
+ try {
235
+ const pr = Number(match[3]);
236
+ if (!Number.isSafeInteger(pr) || pr <= 0)
237
+ return null;
238
+ return { repo: canonicalRepo(`${decodeURIComponent(match[1] ?? "")}/${decodeURIComponent(match[2] ?? "")}`), pr };
239
+ }
240
+ catch (error) {
241
+ if (error instanceof LanesError && error.error === "repo_hold_repo_invalid") {
242
+ return fail("repo_hold_repo_invalid", "Pull request URL has an invalid repository", { field: "repo" });
243
+ }
244
+ throw error;
245
+ }
246
+ }
247
+ function pinnedHead(words, start = 0) {
248
+ for (let index = start; index < words.length; index += 1) {
249
+ const word = words[index] ?? "";
250
+ if (word === "--match-head-commit")
251
+ return words[index + 1]?.trim().toLowerCase() || null;
252
+ if (word.startsWith("--match-head-commit="))
253
+ return word.slice("--match-head-commit=".length).trim().toLowerCase() || null;
254
+ if (word === "-f" || word === "-F" || word === "--field" || word === "--raw-field") {
255
+ const value = words[index + 1] ?? "";
256
+ if (value.startsWith("sha="))
257
+ return value.slice(4).trim().toLowerCase() || null;
258
+ index += 1;
259
+ continue;
260
+ }
261
+ for (const prefix of ["-fsha=", "-Fsha=", "--field=sha=", "--raw-field=sha="]) {
262
+ if (word.startsWith(prefix))
263
+ return word.slice(prefix.length).trim().toLowerCase() || null;
264
+ }
265
+ }
266
+ return null;
267
+ }
268
+ function prMergeTarget(words, subcommandIndex) {
269
+ const rawRepo = optionRepo(words);
270
+ let rawNumber = null;
271
+ let urlTarget = null;
272
+ const start = subcommandIndex + 1;
273
+ for (let index = start; index < words.length; index += 1) {
274
+ const word = words[index] ?? "";
275
+ if (PR_MERGE_VALUE_OPTIONS.has(word)) {
276
+ index += 1;
277
+ continue;
278
+ }
279
+ if (["-R", "--repo", "--hostname"].includes(word)) {
280
+ index += 1;
281
+ continue;
282
+ }
283
+ if (word.startsWith("--repo=") || (word.startsWith("-R") && word.length > 2) || word.startsWith("--match-head-commit="))
284
+ continue;
285
+ if (word.startsWith("-"))
286
+ continue;
287
+ urlTarget = parsePullUrl(word);
288
+ if (urlTarget)
289
+ break;
290
+ rawNumber = word;
291
+ break;
292
+ }
293
+ const repo = rawRepo ? canonicalRepo(rawRepo) : urlTarget?.repo ?? null;
294
+ const pr = urlTarget?.pr ?? (rawNumber && /^\d+$/.test(rawNumber) && Number.isSafeInteger(Number(rawNumber)) && Number(rawNumber) > 0
295
+ ? Number(rawNumber)
296
+ : null);
297
+ const reason = repo === null
298
+ ? "repository could not be resolved without a pull request URL or --repo"
299
+ : pr === null
300
+ ? "pull request number could not be resolved"
301
+ : null;
302
+ return { kind: "pr", repo, pr, pinnedHead: pinnedHead(words, start), unresolvedReason: reason };
303
+ }
304
+ function apiMergeTarget(words, apiIndex) {
305
+ const rest = words.slice(apiIndex + 1);
306
+ const graphql = rest.includes("graphql");
307
+ if (graphql && rest.some((word) => word.includes("mergePullRequest"))) {
308
+ return {
309
+ kind: "graphql",
310
+ repo: null,
311
+ pr: null,
312
+ pinnedHead: pinnedHead(rest),
313
+ unresolvedReason: "GraphQL mergePullRequest identifies a node id, not a pull request number",
314
+ };
315
+ }
316
+ for (const word of rest) {
317
+ const match = /^(?:https?:\/\/api\.github\.com)?\/?repos\/([^/]+)\/([^/]+)\/pulls\/(\d+)\/merge\/?$/.exec(word);
318
+ if (!match)
319
+ continue;
320
+ const pr = Number(match[3]);
321
+ if (!Number.isSafeInteger(pr) || pr <= 0)
322
+ break;
323
+ return {
324
+ kind: "api",
325
+ repo: canonicalRepo(`${match[1]}/${match[2]}`),
326
+ pr,
327
+ pinnedHead: pinnedHead(rest),
328
+ unresolvedReason: null,
329
+ };
330
+ }
331
+ return null;
332
+ }
333
+ function parseSegment(segment) {
334
+ const words = unwrapCommand(shellWords(segment));
335
+ if (words[0] === "git" && words.slice(1).includes("merge")) {
336
+ return {
337
+ kind: "git",
338
+ repo: null,
339
+ pr: null,
340
+ pinnedHead: null,
341
+ unresolvedReason: "git merge does not identify a pull request",
342
+ };
343
+ }
344
+ if (words[0] !== "gh")
345
+ return null;
346
+ const pr = ghPrSubcommand(words);
347
+ if (pr?.subcommand === "merge")
348
+ return prMergeTarget(words, pr.subcommandIndex);
349
+ const apiIndex = words.indexOf("api", 1);
350
+ if (apiIndex < 0)
351
+ return null;
352
+ return apiMergeTarget(words, apiIndex);
353
+ }
354
+ export function parseMergeCommands(command) {
355
+ const targets = splitCommandChain(command)
356
+ .map(parseSegment)
357
+ .filter((target) => target !== null);
358
+ return {
359
+ targets,
360
+ unresolved: targets.some((target) => target.unresolvedReason !== null),
361
+ };
362
+ }
363
+ //# sourceMappingURL=merge-command.js.map