@indigoai-us/hq-cli 5.256.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.
- package/CHANGELOG.md +10 -0
- package/dist/command-catalog.generated.d.ts +96 -0
- package/dist/command-catalog.generated.js +124 -0
- package/dist/commands/lanes-hold.d.ts +41 -0
- package/dist/commands/lanes-hold.js +805 -0
- package/dist/commands/lanes.js +7 -0
- package/dist/lib/lanes/registry-flag.d.ts +1 -0
- package/dist/lib/lanes/registry-flag.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.257.0] — 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add `hq lanes hold` to record company-scoped repository merge holds and
|
|
10
|
+
refuse recognized merge commands while the `lanes.repo-merge-hold` gate is
|
|
11
|
+
enabled. Hold history and release reasons stay in the local record. Checks
|
|
12
|
+
fail open with a named error when they cannot parse a command or read a hold,
|
|
13
|
+
and recognize pull-request URLs plus assignment, `env`, and `timeout` forms.
|
|
14
|
+
|
|
5
15
|
## [5.256.0] — 2026-09-28
|
|
6
16
|
|
|
7
17
|
## [5.255.0] — 2026-09-28
|
|
@@ -7375,6 +7375,102 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
7375
7375
|
readonly description: "Emit JSON instead of the team page text";
|
|
7376
7376
|
}];
|
|
7377
7377
|
readonly subcommands: readonly [];
|
|
7378
|
+
}, {
|
|
7379
|
+
readonly name: "hold";
|
|
7380
|
+
readonly description: "Set and enforce repository merge holds";
|
|
7381
|
+
readonly aliases: readonly [];
|
|
7382
|
+
readonly hidden: false;
|
|
7383
|
+
readonly usage: "[options] [command]";
|
|
7384
|
+
readonly arguments: readonly [];
|
|
7385
|
+
readonly options: readonly [];
|
|
7386
|
+
readonly subcommands: readonly [{
|
|
7387
|
+
readonly name: "set";
|
|
7388
|
+
readonly description: "Set or replace a repository merge hold";
|
|
7389
|
+
readonly aliases: readonly [];
|
|
7390
|
+
readonly hidden: false;
|
|
7391
|
+
readonly usage: "[options] <repo>";
|
|
7392
|
+
readonly arguments: readonly [{
|
|
7393
|
+
readonly name: "repo";
|
|
7394
|
+
readonly required: true;
|
|
7395
|
+
readonly variadic: false;
|
|
7396
|
+
}];
|
|
7397
|
+
readonly options: readonly [{
|
|
7398
|
+
readonly flags: "--company <slug>";
|
|
7399
|
+
readonly description: "Company that owns this repository hold";
|
|
7400
|
+
}, {
|
|
7401
|
+
readonly flags: "--reason <text>";
|
|
7402
|
+
readonly description: "Why merges are held";
|
|
7403
|
+
}, {
|
|
7404
|
+
readonly flags: "--until <time>";
|
|
7405
|
+
readonly description: "Expiry as an ISO timestamp or duration such as 4h";
|
|
7406
|
+
}, {
|
|
7407
|
+
readonly flags: "--json";
|
|
7408
|
+
readonly description: "JSON on stdout (always)";
|
|
7409
|
+
}];
|
|
7410
|
+
readonly subcommands: readonly [];
|
|
7411
|
+
}, {
|
|
7412
|
+
readonly name: "list";
|
|
7413
|
+
readonly description: "List active and expired repository merge holds";
|
|
7414
|
+
readonly aliases: readonly [];
|
|
7415
|
+
readonly hidden: false;
|
|
7416
|
+
readonly usage: "[options]";
|
|
7417
|
+
readonly arguments: readonly [];
|
|
7418
|
+
readonly options: readonly [{
|
|
7419
|
+
readonly flags: "--json";
|
|
7420
|
+
readonly description: "Print JSON";
|
|
7421
|
+
}];
|
|
7422
|
+
readonly subcommands: readonly [];
|
|
7423
|
+
}, {
|
|
7424
|
+
readonly name: "show";
|
|
7425
|
+
readonly description: "Show a repository merge hold";
|
|
7426
|
+
readonly aliases: readonly [];
|
|
7427
|
+
readonly hidden: false;
|
|
7428
|
+
readonly usage: "[options] <repo>";
|
|
7429
|
+
readonly arguments: readonly [{
|
|
7430
|
+
readonly name: "repo";
|
|
7431
|
+
readonly required: true;
|
|
7432
|
+
readonly variadic: false;
|
|
7433
|
+
}];
|
|
7434
|
+
readonly options: readonly [{
|
|
7435
|
+
readonly flags: "--json";
|
|
7436
|
+
readonly description: "JSON on stdout (always)";
|
|
7437
|
+
}];
|
|
7438
|
+
readonly subcommands: readonly [];
|
|
7439
|
+
}, {
|
|
7440
|
+
readonly name: "release";
|
|
7441
|
+
readonly description: "Release an existing repository merge hold";
|
|
7442
|
+
readonly aliases: readonly [];
|
|
7443
|
+
readonly hidden: false;
|
|
7444
|
+
readonly usage: "[options] <repo>";
|
|
7445
|
+
readonly arguments: readonly [{
|
|
7446
|
+
readonly name: "repo";
|
|
7447
|
+
readonly required: true;
|
|
7448
|
+
readonly variadic: false;
|
|
7449
|
+
}];
|
|
7450
|
+
readonly options: readonly [{
|
|
7451
|
+
readonly flags: "--reason <text>";
|
|
7452
|
+
readonly description: "Why the hold is being released";
|
|
7453
|
+
}, {
|
|
7454
|
+
readonly flags: "--json";
|
|
7455
|
+
readonly description: "JSON on stdout (always)";
|
|
7456
|
+
}];
|
|
7457
|
+
readonly subcommands: readonly [];
|
|
7458
|
+
}, {
|
|
7459
|
+
readonly name: "check";
|
|
7460
|
+
readonly description: "Refuse merge commands targeting an actively held repository";
|
|
7461
|
+
readonly aliases: readonly [];
|
|
7462
|
+
readonly hidden: false;
|
|
7463
|
+
readonly usage: "[options]";
|
|
7464
|
+
readonly arguments: readonly [];
|
|
7465
|
+
readonly options: readonly [{
|
|
7466
|
+
readonly flags: "--command <shell-command>";
|
|
7467
|
+
readonly description: "Command to inspect without executing it";
|
|
7468
|
+
}, {
|
|
7469
|
+
readonly flags: "--json";
|
|
7470
|
+
readonly description: "Print JSON";
|
|
7471
|
+
}];
|
|
7472
|
+
readonly subcommands: readonly [];
|
|
7473
|
+
}];
|
|
7378
7474
|
}, {
|
|
7379
7475
|
readonly name: "outpost-pruner";
|
|
7380
7476
|
readonly description: "Inspect, install, and run safe outpost worktree pruning";
|
|
@@ -9534,6 +9534,130 @@ export const COMMAND_CATALOG = [
|
|
|
9534
9534
|
],
|
|
9535
9535
|
"subcommands": []
|
|
9536
9536
|
},
|
|
9537
|
+
{
|
|
9538
|
+
"name": "hold",
|
|
9539
|
+
"description": "Set and enforce repository merge holds",
|
|
9540
|
+
"aliases": [],
|
|
9541
|
+
"hidden": false,
|
|
9542
|
+
"usage": "[options] [command]",
|
|
9543
|
+
"arguments": [],
|
|
9544
|
+
"options": [],
|
|
9545
|
+
"subcommands": [
|
|
9546
|
+
{
|
|
9547
|
+
"name": "set",
|
|
9548
|
+
"description": "Set or replace a repository merge hold",
|
|
9549
|
+
"aliases": [],
|
|
9550
|
+
"hidden": false,
|
|
9551
|
+
"usage": "[options] <repo>",
|
|
9552
|
+
"arguments": [
|
|
9553
|
+
{
|
|
9554
|
+
"name": "repo",
|
|
9555
|
+
"required": true,
|
|
9556
|
+
"variadic": false
|
|
9557
|
+
}
|
|
9558
|
+
],
|
|
9559
|
+
"options": [
|
|
9560
|
+
{
|
|
9561
|
+
"flags": "--company <slug>",
|
|
9562
|
+
"description": "Company that owns this repository hold"
|
|
9563
|
+
},
|
|
9564
|
+
{
|
|
9565
|
+
"flags": "--reason <text>",
|
|
9566
|
+
"description": "Why merges are held"
|
|
9567
|
+
},
|
|
9568
|
+
{
|
|
9569
|
+
"flags": "--until <time>",
|
|
9570
|
+
"description": "Expiry as an ISO timestamp or duration such as 4h"
|
|
9571
|
+
},
|
|
9572
|
+
{
|
|
9573
|
+
"flags": "--json",
|
|
9574
|
+
"description": "JSON on stdout (always)"
|
|
9575
|
+
}
|
|
9576
|
+
],
|
|
9577
|
+
"subcommands": []
|
|
9578
|
+
},
|
|
9579
|
+
{
|
|
9580
|
+
"name": "list",
|
|
9581
|
+
"description": "List active and expired repository merge holds",
|
|
9582
|
+
"aliases": [],
|
|
9583
|
+
"hidden": false,
|
|
9584
|
+
"usage": "[options]",
|
|
9585
|
+
"arguments": [],
|
|
9586
|
+
"options": [
|
|
9587
|
+
{
|
|
9588
|
+
"flags": "--json",
|
|
9589
|
+
"description": "Print JSON"
|
|
9590
|
+
}
|
|
9591
|
+
],
|
|
9592
|
+
"subcommands": []
|
|
9593
|
+
},
|
|
9594
|
+
{
|
|
9595
|
+
"name": "show",
|
|
9596
|
+
"description": "Show a repository merge hold",
|
|
9597
|
+
"aliases": [],
|
|
9598
|
+
"hidden": false,
|
|
9599
|
+
"usage": "[options] <repo>",
|
|
9600
|
+
"arguments": [
|
|
9601
|
+
{
|
|
9602
|
+
"name": "repo",
|
|
9603
|
+
"required": true,
|
|
9604
|
+
"variadic": false
|
|
9605
|
+
}
|
|
9606
|
+
],
|
|
9607
|
+
"options": [
|
|
9608
|
+
{
|
|
9609
|
+
"flags": "--json",
|
|
9610
|
+
"description": "JSON on stdout (always)"
|
|
9611
|
+
}
|
|
9612
|
+
],
|
|
9613
|
+
"subcommands": []
|
|
9614
|
+
},
|
|
9615
|
+
{
|
|
9616
|
+
"name": "release",
|
|
9617
|
+
"description": "Release an existing repository merge hold",
|
|
9618
|
+
"aliases": [],
|
|
9619
|
+
"hidden": false,
|
|
9620
|
+
"usage": "[options] <repo>",
|
|
9621
|
+
"arguments": [
|
|
9622
|
+
{
|
|
9623
|
+
"name": "repo",
|
|
9624
|
+
"required": true,
|
|
9625
|
+
"variadic": false
|
|
9626
|
+
}
|
|
9627
|
+
],
|
|
9628
|
+
"options": [
|
|
9629
|
+
{
|
|
9630
|
+
"flags": "--reason <text>",
|
|
9631
|
+
"description": "Why the hold is being released"
|
|
9632
|
+
},
|
|
9633
|
+
{
|
|
9634
|
+
"flags": "--json",
|
|
9635
|
+
"description": "JSON on stdout (always)"
|
|
9636
|
+
}
|
|
9637
|
+
],
|
|
9638
|
+
"subcommands": []
|
|
9639
|
+
},
|
|
9640
|
+
{
|
|
9641
|
+
"name": "check",
|
|
9642
|
+
"description": "Refuse merge commands targeting an actively held repository",
|
|
9643
|
+
"aliases": [],
|
|
9644
|
+
"hidden": false,
|
|
9645
|
+
"usage": "[options]",
|
|
9646
|
+
"arguments": [],
|
|
9647
|
+
"options": [
|
|
9648
|
+
{
|
|
9649
|
+
"flags": "--command <shell-command>",
|
|
9650
|
+
"description": "Command to inspect without executing it"
|
|
9651
|
+
},
|
|
9652
|
+
{
|
|
9653
|
+
"flags": "--json",
|
|
9654
|
+
"description": "Print JSON"
|
|
9655
|
+
}
|
|
9656
|
+
],
|
|
9657
|
+
"subcommands": []
|
|
9658
|
+
}
|
|
9659
|
+
]
|
|
9660
|
+
},
|
|
9537
9661
|
{
|
|
9538
9662
|
"name": "outpost-pruner",
|
|
9539
9663
|
"description": "Inspect, install, and run safe outpost worktree pruning",
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { LaneCompanyRef } from "../lib/lanes/types.js";
|
|
3
|
+
export declare const LANES_REPO_MERGE_HOLD_FLAG = "lanes.repo-merge-hold";
|
|
4
|
+
export declare const REPO_MERGE_HOLDS_REL: string;
|
|
5
|
+
declare const HOLD_SCHEMA_VERSION = "hq-lanes-repo-merge-hold.v1";
|
|
6
|
+
interface HoldHistoryEntry {
|
|
7
|
+
set_at: string;
|
|
8
|
+
until: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
set_by_lane: string | null;
|
|
11
|
+
set_by_session: string | null;
|
|
12
|
+
released_at?: string;
|
|
13
|
+
released_reason?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface RepoMergeHold {
|
|
16
|
+
schema_version: typeof HOLD_SCHEMA_VERSION;
|
|
17
|
+
repo: string;
|
|
18
|
+
company: LaneCompanyRef;
|
|
19
|
+
reason: string;
|
|
20
|
+
set_at: string;
|
|
21
|
+
until: string;
|
|
22
|
+
set_by_lane: string | null;
|
|
23
|
+
set_by_session: string | null;
|
|
24
|
+
released_at?: string;
|
|
25
|
+
released_reason?: string;
|
|
26
|
+
history: HoldHistoryEntry[];
|
|
27
|
+
}
|
|
28
|
+
export interface LaneRepoMergeHoldDeps {
|
|
29
|
+
hqRoot: () => string;
|
|
30
|
+
resolveCompany: (company?: string) => Promise<LaneCompanyRef>;
|
|
31
|
+
now: () => Date;
|
|
32
|
+
env: NodeJS.ProcessEnv;
|
|
33
|
+
mergeHoldEnabled?: (company: LaneCompanyRef) => boolean | Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
type WrapHandler = <Args extends unknown[]>(action: (...args: Args) => Promise<void>) => (...args: Args) => Promise<void>;
|
|
36
|
+
export declare function repoMergeHoldPath(hqRoot: string, repo: string): string;
|
|
37
|
+
/** Serialize the read-modify-write cycle so concurrent set/release calls cannot overwrite each other. */
|
|
38
|
+
export declare function withRepoMergeHoldRecordLock<T>(recordPath: string, action: () => T | Promise<T>): Promise<T>;
|
|
39
|
+
export declare function registerLaneRepoMergeHoldCommand(lanes: Command, deps: LaneRepoMergeHoldDeps, wrap: WrapHandler): void;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=lanes-hold.d.ts.map
|
|
@@ -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
|
package/dist/commands/lanes.js
CHANGED
|
@@ -21,6 +21,7 @@ import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
|
|
|
21
21
|
import { registerLaneChecksCommand } from "./lanes-checks.js";
|
|
22
22
|
import { registerLaneReportCommands } from "./lanes-report.js";
|
|
23
23
|
import { registerLaneTrackerImportCommand } from "./lanes-import.js";
|
|
24
|
+
import { registerLaneRepoMergeHoldCommand } from "./lanes-hold.js";
|
|
24
25
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
25
26
|
import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
|
|
26
27
|
import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
|
|
@@ -6892,6 +6893,12 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
6892
6893
|
return body;
|
|
6893
6894
|
},
|
|
6894
6895
|
}, wrap);
|
|
6896
|
+
registerLaneRepoMergeHoldCommand(lanes, {
|
|
6897
|
+
hqRoot: ctx.hqRoot,
|
|
6898
|
+
resolveCompany: ctx.resolveCompany,
|
|
6899
|
+
now: ctx.now,
|
|
6900
|
+
env: ctx.env,
|
|
6901
|
+
}, wrap);
|
|
6895
6902
|
registerOutpostPrunerCommand(lanes);
|
|
6896
6903
|
const noStoryOption = new Option("--no-story <reason>", "Create a lane without a story and record why");
|
|
6897
6904
|
noStoryOption.attributeName = () => "noStory";
|
|
@@ -17,6 +17,7 @@ export interface ReadLanesFlagOptions {
|
|
|
17
17
|
getToken?: () => string | Promise<string>;
|
|
18
18
|
reportFailure?: (error: unknown) => void;
|
|
19
19
|
}
|
|
20
|
+
export declare function readLanesFlagLocalOverride(flagKey: string, env?: NodeJS.ProcessEnv): boolean | null;
|
|
20
21
|
/**
|
|
21
22
|
* Read a lanes flag once for each company identity in this CLI process. The
|
|
22
23
|
* cache contains only an in-flight or settled boolean promise; it is never
|
|
@@ -3,6 +3,9 @@ import { ensureCognitoIdToken } from "../../utils/cognito-session.js";
|
|
|
3
3
|
import { FLAG_REGISTRY_DEFAULT_ENDPOINT, resolveFlagRegistryEndpoint, } from "../flag-registry-endpoint.js";
|
|
4
4
|
export const LANES_FLAG_API_DEFAULT_ENDPOINT = FLAG_REGISTRY_DEFAULT_ENDPOINT;
|
|
5
5
|
export const LANES_FLAG_READ_TIMEOUT_MS = 5_000;
|
|
6
|
+
export function readLanesFlagLocalOverride(flagKey, env = process.env) {
|
|
7
|
+
return readLocalOverride(flagKey, env);
|
|
8
|
+
}
|
|
6
9
|
const registryReads = new Map();
|
|
7
10
|
class MissingFlagTokenError extends Error {
|
|
8
11
|
constructor() {
|
|
@@ -37,7 +40,7 @@ function cacheKey(flagKey, company, endpoint) {
|
|
|
37
40
|
*/
|
|
38
41
|
export async function readLanesRegistryFlag(flagKey, options = {}) {
|
|
39
42
|
const env = options.env ?? process.env;
|
|
40
|
-
const localOverride =
|
|
43
|
+
const localOverride = readLanesFlagLocalOverride(flagKey, env);
|
|
41
44
|
if (localOverride !== null)
|
|
42
45
|
return localOverride;
|
|
43
46
|
const endpoint = resolveFlagRegistryEndpoint(undefined, env);
|