@indigoai-us/hq-cli 5.255.0 → 5.257.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.
@@ -0,0 +1,805 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { LanesError } from "../lib/lanes/errors.js";
4
+ import { listLaneIds, readLane } from "../lib/lanes/store.js";
5
+ import { appendDropboxMessage } from "../lib/lanes/dropbox.js";
6
+ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
+ import { readLanesFlagLocalOverride, readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
8
+ import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
9
+ export const LANES_REPO_MERGE_HOLD_FLAG = "lanes.repo-merge-hold";
10
+ export const REPO_MERGE_HOLDS_REL = path.join("workspace", "lanes", "holds");
11
+ const HOLD_SCHEMA_VERSION = "hq-lanes-repo-merge-hold.v1";
12
+ const HISTORY_CAP = 50;
13
+ const HOLD_LOCK_WAIT_MS = 5_000;
14
+ const HOLD_LOCK_STALE_MS = 5 * 60_000;
15
+ function fail(error, message, details = {}) {
16
+ throw new LanesError(error, message, details);
17
+ }
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
+ function holdFilePath(hqRoot, repo) {
34
+ const [owner, repository] = canonicalRepo(repo).split("/");
35
+ return path.join(hqRoot, REPO_MERGE_HOLDS_REL, `${owner}__${repository}.json`);
36
+ }
37
+ export function repoMergeHoldPath(hqRoot, repo) {
38
+ return holdFilePath(hqRoot, repo);
39
+ }
40
+ function nonEmptyText(field, raw) {
41
+ const value = raw.trim();
42
+ if (!value)
43
+ return fail("repo_hold_text_empty", `${field} must not be empty`, { field });
44
+ assertNoSecretText(field, value);
45
+ return value;
46
+ }
47
+ function parseUntil(raw, now) {
48
+ const value = raw.trim();
49
+ let timestamp;
50
+ const duration = /^(\d+)([smhd])$/i.exec(value);
51
+ if (duration) {
52
+ const amount = Number(duration[1]);
53
+ const unit = duration[2]?.toLowerCase();
54
+ const milliseconds = unit === "s" ? 1_000 : unit === "m" ? 60_000 : unit === "h" ? 3_600_000 : 86_400_000;
55
+ timestamp = now.getTime() + amount * milliseconds;
56
+ }
57
+ else {
58
+ const iso = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
59
+ if (!iso) {
60
+ return fail("repo_hold_until_invalid", "Until must be an ISO timestamp or a duration such as 4h", { field: "until" });
61
+ }
62
+ const year = Number(iso[1]);
63
+ const month = Number(iso[2]);
64
+ const day = Number(iso[3]);
65
+ const hour = Number(iso[4]);
66
+ const minute = Number(iso[5]);
67
+ const second = Number(iso[6] ?? "0");
68
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
69
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth ||
70
+ hour > 23 || minute > 59 || second > 59) {
71
+ return fail("repo_hold_until_invalid", "Until must be a valid ISO timestamp", { field: "until" });
72
+ }
73
+ timestamp = Date.parse(value);
74
+ }
75
+ if (!Number.isFinite(timestamp)) {
76
+ return fail("repo_hold_until_invalid", "Until must be an ISO timestamp or a duration such as 4h", { field: "until" });
77
+ }
78
+ if (timestamp <= now.getTime()) {
79
+ return fail("repo_hold_until_past", "Hold expiry must be in the future", { field: "until" });
80
+ }
81
+ return new Date(timestamp).toISOString();
82
+ }
83
+ function isObject(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value);
85
+ }
86
+ function isCanonicalTimestamp(value) {
87
+ if (typeof value !== "string")
88
+ return false;
89
+ const timestamp = Date.parse(value);
90
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
91
+ }
92
+ function parseHold(raw, file) {
93
+ if (!isObject(raw))
94
+ return fail("repo_hold_invalid", "Merge hold record must be an object", { path: file });
95
+ const company = raw.company;
96
+ const history = raw.history;
97
+ if (raw.schema_version !== HOLD_SCHEMA_VERSION || typeof raw.repo !== "string" ||
98
+ !isObject(company) || typeof company.slug !== "string" || !company.slug.trim() ||
99
+ typeof company.uid !== "string" || !company.uid.trim() ||
100
+ typeof raw.reason !== "string" || !raw.reason.trim() ||
101
+ !isCanonicalTimestamp(raw.set_at) || !isCanonicalTimestamp(raw.until) ||
102
+ !(raw.set_by_lane === null || typeof raw.set_by_lane === "string") ||
103
+ !(raw.set_by_session === null || typeof raw.set_by_session === "string") ||
104
+ !Array.isArray(history) || history.length > HISTORY_CAP ||
105
+ (raw.released_at !== undefined && !isCanonicalTimestamp(raw.released_at)) ||
106
+ (raw.released_reason !== undefined && typeof raw.released_reason !== "string")) {
107
+ return fail("repo_hold_invalid", "Merge hold record has invalid fields", { path: file });
108
+ }
109
+ let repo;
110
+ try {
111
+ repo = canonicalRepo(raw.repo);
112
+ }
113
+ catch {
114
+ return fail("repo_hold_invalid", "Merge hold record has an invalid repository", { path: file });
115
+ }
116
+ if (repo !== raw.repo)
117
+ return fail("repo_hold_invalid", "Merge hold repository is not canonical", { path: file });
118
+ if ((raw.released_at === undefined) !== (raw.released_reason === undefined)) {
119
+ return fail("repo_hold_invalid", "Released merge hold must include both release fields", { path: file });
120
+ }
121
+ if (raw.released_at !== undefined && (!raw.released_reason || !raw.released_reason.trim())) {
122
+ return fail("repo_hold_invalid", "Released merge hold is missing its reason", { path: file });
123
+ }
124
+ assertNoSecretText("hold.reason", raw.reason);
125
+ if (typeof raw.released_reason === "string")
126
+ assertNoSecretText("hold.released_reason", raw.released_reason);
127
+ for (const entry of history) {
128
+ if (!isObject(entry) || !isCanonicalTimestamp(entry.set_at) || !isCanonicalTimestamp(entry.until) ||
129
+ typeof entry.reason !== "string" || !entry.reason.trim() ||
130
+ !(entry.set_by_lane === null || typeof entry.set_by_lane === "string") ||
131
+ !(entry.set_by_session === null || typeof entry.set_by_session === "string") ||
132
+ (entry.released_at !== undefined && !isCanonicalTimestamp(entry.released_at)) ||
133
+ (entry.released_reason !== undefined && typeof entry.released_reason !== "string")) {
134
+ return fail("repo_hold_invalid", "Merge hold history has invalid entries", { path: file });
135
+ }
136
+ if ((entry.released_at === undefined) !== (entry.released_reason === undefined)) {
137
+ return fail("repo_hold_invalid", "Merge hold history release fields are incomplete", { path: file });
138
+ }
139
+ if (entry.released_reason !== undefined && !entry.released_reason.trim()) {
140
+ return fail("repo_hold_invalid", "Merge hold history release reason is empty", { path: file });
141
+ }
142
+ assertNoSecretText("hold.history.reason", entry.reason);
143
+ if (typeof entry.released_reason === "string")
144
+ assertNoSecretText("hold.history.released_reason", entry.released_reason);
145
+ }
146
+ return raw;
147
+ }
148
+ function readHold(hqRoot, repo) {
149
+ const file = holdFilePath(hqRoot, repo);
150
+ let contents;
151
+ try {
152
+ contents = fs.readFileSync(file, "utf8");
153
+ }
154
+ catch (error) {
155
+ if (error.code === "ENOENT")
156
+ return null;
157
+ return fail("repo_hold_unreadable", "Merge hold record could not be read", { path: file });
158
+ }
159
+ let raw;
160
+ try {
161
+ raw = JSON.parse(contents);
162
+ }
163
+ catch {
164
+ return fail("repo_hold_invalid", "Merge hold record is not valid JSON", { path: file });
165
+ }
166
+ const hold = parseHold(raw, file);
167
+ if (holdFilePath(hqRoot, hold.repo) !== file) {
168
+ return fail("repo_hold_invalid", "Merge hold filename does not match its repository", { path: file });
169
+ }
170
+ return hold;
171
+ }
172
+ function listHolds(hqRoot) {
173
+ const directory = path.join(hqRoot, REPO_MERGE_HOLDS_REL);
174
+ let names;
175
+ try {
176
+ names = fs.readdirSync(directory).filter((name) => name.endsWith(".json")).sort();
177
+ }
178
+ catch (error) {
179
+ if (error.code === "ENOENT")
180
+ return [];
181
+ return fail("repo_hold_unreadable", "Merge hold directory could not be read", { path: directory });
182
+ }
183
+ return names.map((name) => {
184
+ const file = path.join(directory, name);
185
+ let raw;
186
+ try {
187
+ raw = JSON.parse(fs.readFileSync(file, "utf8"));
188
+ }
189
+ catch {
190
+ return fail("repo_hold_invalid", "Merge hold record could not be parsed", { path: file });
191
+ }
192
+ const hold = parseHold(raw, file);
193
+ if (holdFilePath(hqRoot, hold.repo) !== file) {
194
+ return fail("repo_hold_invalid", "Merge hold filename does not match its repository", { path: file });
195
+ }
196
+ return hold;
197
+ });
198
+ }
199
+ function writeHold(hqRoot, hold) {
200
+ const file = holdFilePath(hqRoot, hold.repo);
201
+ const directory = path.dirname(file);
202
+ fs.mkdirSync(directory, { recursive: true });
203
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
204
+ try {
205
+ fs.writeFileSync(temp, `${JSON.stringify(hold, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
206
+ fs.renameSync(temp, file);
207
+ }
208
+ catch (error) {
209
+ try {
210
+ fs.rmSync(temp, { force: true });
211
+ }
212
+ catch (cleanupError) {
213
+ process.stderr.write(`hq lanes hold: temporary hold cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`);
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ function holdLockOwnerAlive(pid) {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ }
223
+ catch (error) {
224
+ return error.code !== "ESRCH";
225
+ }
226
+ }
227
+ function removeStaleHoldLock(lockPath) {
228
+ let first;
229
+ try {
230
+ first = fs.lstatSync(lockPath);
231
+ }
232
+ catch (error) {
233
+ if (error.code === "ENOENT")
234
+ return true;
235
+ throw error;
236
+ }
237
+ if (Date.now() - first.mtimeMs < HOLD_LOCK_STALE_MS)
238
+ return false;
239
+ let pid;
240
+ try {
241
+ pid = Number(fs.readFileSync(lockPath, "utf8").split("\n", 1)[0]);
242
+ }
243
+ catch (error) {
244
+ if (error.code === "ENOENT")
245
+ return true;
246
+ throw error;
247
+ }
248
+ if (!Number.isSafeInteger(pid) || pid <= 0 || holdLockOwnerAlive(pid))
249
+ return false;
250
+ let second;
251
+ try {
252
+ second = fs.lstatSync(lockPath);
253
+ }
254
+ catch (error) {
255
+ if (error.code === "ENOENT")
256
+ return true;
257
+ throw error;
258
+ }
259
+ if (second.dev !== first.dev || second.ino !== first.ino || second.mtimeMs !== first.mtimeMs)
260
+ return false;
261
+ try {
262
+ fs.unlinkSync(lockPath);
263
+ return true;
264
+ }
265
+ catch (error) {
266
+ if (error.code === "ENOENT")
267
+ return true;
268
+ throw error;
269
+ }
270
+ }
271
+ /** Serialize the read-modify-write cycle so concurrent set/release calls cannot overwrite each other. */
272
+ export async function withRepoMergeHoldRecordLock(recordPath, action) {
273
+ const lockPath = `${recordPath}.lock`;
274
+ fs.mkdirSync(path.dirname(recordPath), { recursive: true });
275
+ const started = Date.now();
276
+ let fd;
277
+ let ownedStat;
278
+ while (fd === undefined) {
279
+ try {
280
+ fd = fs.openSync(lockPath, "wx", 0o600);
281
+ }
282
+ catch (error) {
283
+ if (error.code !== "EEXIST")
284
+ throw error;
285
+ if (Date.now() - started >= HOLD_LOCK_WAIT_MS) {
286
+ throw new LanesError("repo_hold_write_busy", "This repository hold is being updated; retry the command", { path: recordPath });
287
+ }
288
+ if (!removeStaleHoldLock(lockPath)) {
289
+ await new Promise((resolve) => setTimeout(resolve, 10));
290
+ }
291
+ continue;
292
+ }
293
+ try {
294
+ ownedStat = fs.fstatSync(fd);
295
+ fs.writeSync(fd, `${process.pid}\n${Date.now()}\n`);
296
+ }
297
+ catch (error) {
298
+ try {
299
+ fs.closeSync(fd);
300
+ }
301
+ catch (closeError) {
302
+ process.stderr.write(`hq lanes hold: lock close failed for ${lockPath}: ${closeError instanceof Error ? closeError.message : String(closeError)}\n`);
303
+ }
304
+ try {
305
+ const current = fs.lstatSync(lockPath);
306
+ if (!ownedStat || (current.dev === ownedStat.dev && current.ino === ownedStat.ino))
307
+ fs.unlinkSync(lockPath);
308
+ }
309
+ catch (cleanupError) {
310
+ if (cleanupError.code !== "ENOENT") {
311
+ process.stderr.write(`hq lanes hold: lock cleanup failed for ${lockPath}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`);
312
+ }
313
+ }
314
+ throw error;
315
+ }
316
+ }
317
+ try {
318
+ return await action();
319
+ }
320
+ finally {
321
+ try {
322
+ fs.closeSync(fd);
323
+ }
324
+ catch (error) {
325
+ process.stderr.write(`hq lanes hold: lock close failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
326
+ }
327
+ try {
328
+ const current = fs.lstatSync(lockPath);
329
+ if (ownedStat && current.dev === ownedStat.dev && current.ino === ownedStat.ino)
330
+ fs.unlinkSync(lockPath);
331
+ }
332
+ catch (error) {
333
+ if (error.code !== "ENOENT") {
334
+ process.stderr.write(`hq lanes hold: lock cleanup failed for ${lockPath}: ${error instanceof Error ? error.message : String(error)}\n`);
335
+ }
336
+ }
337
+ }
338
+ }
339
+ function historyEntry(hold) {
340
+ return {
341
+ set_at: hold.set_at,
342
+ until: hold.until,
343
+ reason: hold.reason,
344
+ set_by_lane: hold.set_by_lane,
345
+ set_by_session: hold.set_by_session,
346
+ ...(hold.released_at ? { released_at: hold.released_at } : {}),
347
+ ...(hold.released_reason ? { released_reason: hold.released_reason } : {}),
348
+ };
349
+ }
350
+ function holdView(hold, now) {
351
+ const expired = Date.parse(hold.until) <= now.getTime();
352
+ return { ...hold, expired, active: !expired && hold.released_at === undefined };
353
+ }
354
+ function sameCompany(left, right) {
355
+ return left !== null && left.uid === right.uid && left.slug === right.slug;
356
+ }
357
+ function notifyRunningLocalLanes(hqRoot, company, text) {
358
+ const warnings = [];
359
+ let laneIds;
360
+ try {
361
+ laneIds = listLaneIds(hqRoot);
362
+ }
363
+ catch (error) {
364
+ return [`Could not list running lanes for dropbox notification: ${error instanceof Error ? error.message : String(error)}`];
365
+ }
366
+ for (const laneId of laneIds) {
367
+ try {
368
+ const lane = readLane(hqRoot, laneId);
369
+ if (lane.state !== "running" || lane.host.kind !== "local" || !sameCompany(lane.company, company))
370
+ continue;
371
+ appendDropboxMessage(hqRoot, lane.lane_id, { text });
372
+ }
373
+ catch (error) {
374
+ warnings.push(`Could not notify lane ${laneId}: ${error instanceof Error ? error.message : String(error)}`);
375
+ }
376
+ }
377
+ return warnings;
378
+ }
379
+ function reportNotificationWarnings(warnings) {
380
+ for (const warning of warnings)
381
+ console.error(`hq lanes hold: ${warning}`);
382
+ }
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;
483
+ }
484
+ function ghPrSubcommand(words) {
485
+ const groupIndex = words.indexOf("pr", 1);
486
+ if (groupIndex < 0)
487
+ 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;
555
+ }
556
+ if (word === "--preserve-status" || word === "--foreground" || word === "--verbose" || word === "-v") {
557
+ index += 1;
558
+ continue;
559
+ }
560
+ if (word.startsWith("-")) {
561
+ index += 1;
562
+ continue;
563
+ }
564
+ break;
565
+ }
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);
582
+ continue;
583
+ }
584
+ break;
585
+ }
586
+ return words.slice(index);
587
+ }
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("-"))
598
+ continue;
599
+ if (!/^https?:\/\//i.test(word))
600
+ return null;
601
+ let url;
602
+ try {
603
+ url = new URL(word);
604
+ }
605
+ catch {
606
+ continue;
607
+ }
608
+ const match = /^\/([^/]+)\/([^/]+)\/pull\/\d+\/?$/.exec(url.pathname);
609
+ if (!match)
610
+ continue;
611
+ try {
612
+ return canonicalRepo(`${decodeURIComponent(match[1] ?? "")}/${decodeURIComponent(match[2] ?? "")}`);
613
+ }
614
+ catch {
615
+ return fail("repo_hold_repo_invalid", "Pull request URL has an invalid repository", { field: "repo" });
616
+ }
617
+ }
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));
653
+ }
654
+ export function registerLaneRepoMergeHoldCommand(lanes, deps, wrap) {
655
+ const hold = lanes.command("hold").description("Set and enforce repository merge holds");
656
+ hold.command("set <repo>")
657
+ .description("Set or replace a repository merge hold")
658
+ .requiredOption("--company <slug>", "Company that owns this repository hold")
659
+ .requiredOption("--reason <text>", "Why merges are held")
660
+ .requiredOption("--until <time>", "Expiry as an ISO timestamp or duration such as 4h")
661
+ .action(wrap(async (repoArg, options) => {
662
+ const repo = canonicalRepo(repoArg);
663
+ const company = await deps.resolveCompany(options.company);
664
+ const reason = nonEmptyText("reason", options.reason);
665
+ const env = deps.env;
666
+ const hqRoot = deps.hqRoot();
667
+ const hold = await withRepoMergeHoldRecordLock(holdFilePath(hqRoot, repo), () => {
668
+ const now = deps.now();
669
+ const until = parseUntil(options.until, now);
670
+ const previous = readHold(hqRoot, repo);
671
+ if (previous && (previous.company.uid !== company.uid || previous.company.slug !== company.slug)) {
672
+ fail("repo_hold_company_mismatch", `A merge hold for ${repo} belongs to another company`, { repo });
673
+ }
674
+ const next = {
675
+ schema_version: HOLD_SCHEMA_VERSION,
676
+ repo,
677
+ company,
678
+ reason,
679
+ set_at: now.toISOString(),
680
+ until,
681
+ set_by_lane: env.HQ_LANE_ID?.trim() || null,
682
+ set_by_session: sessionIdFromEnv(env) ?? null,
683
+ history: [...(previous?.history ?? []), ...(previous ? [historyEntry(previous)] : [])].slice(-HISTORY_CAP),
684
+ };
685
+ writeHold(hqRoot, next);
686
+ return next;
687
+ });
688
+ const notificationWarnings = notifyRunningLocalLanes(hqRoot, company, `Merge hold on ${repo} until ${hold.until}: ${reason}. \`hq lanes hold check\` can refuse merges when the \`${LANES_REPO_MERGE_HOLD_FLAG}\` gate is enabled.`);
689
+ reportNotificationWarnings(notificationWarnings);
690
+ printJson({ action: "lanes.hold.set", hold, ...(notificationWarnings.length ? { notification_warnings: notificationWarnings } : {}) });
691
+ }));
692
+ hold.command("list")
693
+ .description("List active and expired repository merge holds")
694
+ .option("--json", "Print JSON")
695
+ .action(wrap(async () => {
696
+ const now = deps.now();
697
+ printJson({ action: "lanes.hold.list", holds: listHolds(deps.hqRoot()).map((item) => holdView(item, now)) });
698
+ }));
699
+ hold.command("show <repo>")
700
+ .description("Show a repository merge hold")
701
+ .action(wrap(async (repoArg) => {
702
+ const repo = canonicalRepo(repoArg);
703
+ const record = readHold(deps.hqRoot(), repo);
704
+ if (!record)
705
+ fail("repo_hold_not_found", `No merge hold exists for ${repo}`, { repo });
706
+ printJson({ action: "lanes.hold.show", hold: holdView(record, deps.now()) });
707
+ }));
708
+ hold.command("release <repo>")
709
+ .description("Release an existing repository merge hold")
710
+ .requiredOption("--reason <text>", "Why the hold is being released")
711
+ .action(wrap(async (repoArg, options) => {
712
+ const repo = canonicalRepo(repoArg);
713
+ const reason = nonEmptyText("reason", options.reason);
714
+ const hqRoot = deps.hqRoot();
715
+ const released = await withRepoMergeHoldRecordLock(holdFilePath(hqRoot, repo), () => {
716
+ const existing = readHold(hqRoot, repo);
717
+ if (!existing)
718
+ fail("repo_hold_not_found", `No merge hold exists for ${repo}`, { repo });
719
+ if (existing.released_at)
720
+ fail("repo_hold_already_released", `Merge hold for ${repo} is already released`, { repo });
721
+ const next = {
722
+ ...existing,
723
+ released_at: deps.now().toISOString(),
724
+ released_reason: reason,
725
+ };
726
+ writeHold(hqRoot, next);
727
+ return next;
728
+ });
729
+ const notificationWarnings = notifyRunningLocalLanes(hqRoot, released.company, `Merge hold on ${repo} released: ${reason}.`);
730
+ reportNotificationWarnings(notificationWarnings);
731
+ printJson({
732
+ action: "lanes.hold.release",
733
+ hold: holdView(released, deps.now()),
734
+ ...(notificationWarnings.length ? { notification_warnings: notificationWarnings } : {}),
735
+ });
736
+ }));
737
+ hold.command("check")
738
+ .description("Refuse merge commands targeting an actively held repository")
739
+ .requiredOption("--command <shell-command>", "Command to inspect without executing it")
740
+ .option("--json", "Print JSON")
741
+ .action(wrap(async (options) => {
742
+ if (!options.command.trim())
743
+ 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;
750
+ 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;
759
+ }
760
+ repos.push(checked.repo);
761
+ }
762
+ 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;
772
+ }
773
+ deniedHold ??= existing;
774
+ }
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;
785
+ }
786
+ printJson({
787
+ ok: true,
788
+ ...(gateWasOff ? { hold_enforcement: "off" } : {}),
789
+ ...(unresolved ? { repo_unresolved: true } : {}),
790
+ });
791
+ }
792
+ 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`);
796
+ printJson({
797
+ ok: true,
798
+ check_error: error.error,
799
+ ...(gateWasOff ? { hold_enforcement: "off" } : {}),
800
+ ...(unresolved ? { repo_unresolved: true } : {}),
801
+ });
802
+ }
803
+ }));
804
+ }
805
+ //# sourceMappingURL=lanes-hold.js.map