@goodtek/vibeops 2.5.2 → 3.1.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,125 @@
1
+ import { join } from "node:path";
2
+ import { readTextOrNull } from "./filesystem.js";
3
+ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
+ export const MERGE_GATE_DEFAULTS = {
5
+ requiredChecks: [],
6
+ releaseRequiredChecks: [],
7
+ waitTimeoutSeconds: 900,
8
+ pollIntervalSeconds: 5,
9
+ emptyRollupGraceSeconds: 30,
10
+ allowNoChecks: false,
11
+ };
12
+ /** Inclusive bounds for the numeric settings (seconds). */
13
+ export const MERGE_GATE_LIMITS = {
14
+ waitTimeoutSeconds: { min: 1, max: 7_200 },
15
+ pollIntervalSeconds: { min: 1, max: 300 },
16
+ emptyRollupGraceSeconds: { min: 0, max: 600 },
17
+ };
18
+ const KNOWN_KEYS = new Set([
19
+ "requiredChecks",
20
+ "releaseRequiredChecks",
21
+ "waitTimeoutSeconds",
22
+ "pollIntervalSeconds",
23
+ "emptyRollupGraceSeconds",
24
+ "allowNoChecks",
25
+ ]);
26
+ /** Invalid `merge` block in `.vibeops.json`. Merge commands refuse to run. */
27
+ export class MergeConfigError extends Error {
28
+ constructor(message) {
29
+ super(`${VIBEOPS_CONFIG_FILE} merge: ${message}`);
30
+ this.name = "MergeConfigError";
31
+ }
32
+ }
33
+ function checkList(key, raw) {
34
+ if (!Array.isArray(raw)) {
35
+ throw new MergeConfigError(`\`${key}\` must be an array of check names.`);
36
+ }
37
+ return raw.map((item, i) => {
38
+ if (typeof item !== "string" || item.trim().length === 0) {
39
+ throw new MergeConfigError(`\`${key}[${i}]\` must be a non-empty string.`);
40
+ }
41
+ return item.trim();
42
+ });
43
+ }
44
+ function seconds(key, raw) {
45
+ const { min, max } = MERGE_GATE_LIMITS[key];
46
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < min || raw > max) {
47
+ throw new MergeConfigError(`\`${key}\` must be an integer between ${min} and ${max}.`);
48
+ }
49
+ return raw;
50
+ }
51
+ /**
52
+ * Validate the raw `merge` block (fail closed): must be an object, only known
53
+ * keys, lists of non-empty strings, bounded integer seconds, boolean flags.
54
+ * `undefined` → defaults.
55
+ */
56
+ export function parseMergeConfig(raw) {
57
+ if (raw === undefined)
58
+ return MERGE_GATE_DEFAULTS;
59
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
60
+ throw new MergeConfigError("must be an object.");
61
+ }
62
+ const o = raw;
63
+ const unknown = Object.keys(o).filter((k) => !KNOWN_KEYS.has(k));
64
+ if (unknown.length > 0) {
65
+ throw new MergeConfigError(`unknown key${unknown.length > 1 ? "s" : ""} ${unknown
66
+ .map((k) => `\`${k}\``)
67
+ .join(", ")} (allowed: ${[...KNOWN_KEYS].join(", ")}).`);
68
+ }
69
+ const cfg = {
70
+ requiredChecks: o.requiredChecks === undefined
71
+ ? MERGE_GATE_DEFAULTS.requiredChecks
72
+ : checkList("requiredChecks", o.requiredChecks),
73
+ releaseRequiredChecks: o.releaseRequiredChecks === undefined
74
+ ? MERGE_GATE_DEFAULTS.releaseRequiredChecks
75
+ : checkList("releaseRequiredChecks", o.releaseRequiredChecks),
76
+ waitTimeoutSeconds: o.waitTimeoutSeconds === undefined
77
+ ? MERGE_GATE_DEFAULTS.waitTimeoutSeconds
78
+ : seconds("waitTimeoutSeconds", o.waitTimeoutSeconds),
79
+ pollIntervalSeconds: o.pollIntervalSeconds === undefined
80
+ ? MERGE_GATE_DEFAULTS.pollIntervalSeconds
81
+ : seconds("pollIntervalSeconds", o.pollIntervalSeconds),
82
+ emptyRollupGraceSeconds: o.emptyRollupGraceSeconds === undefined
83
+ ? MERGE_GATE_DEFAULTS.emptyRollupGraceSeconds
84
+ : seconds("emptyRollupGraceSeconds", o.emptyRollupGraceSeconds),
85
+ allowNoChecks: MERGE_GATE_DEFAULTS.allowNoChecks,
86
+ };
87
+ if (o.allowNoChecks !== undefined) {
88
+ if (typeof o.allowNoChecks !== "boolean") {
89
+ throw new MergeConfigError("`allowNoChecks` must be true or false.");
90
+ }
91
+ cfg.allowNoChecks = o.allowNoChecks;
92
+ }
93
+ if (cfg.pollIntervalSeconds > cfg.waitTimeoutSeconds) {
94
+ throw new MergeConfigError("`pollIntervalSeconds` must not exceed `waitTimeoutSeconds`.");
95
+ }
96
+ return cfg;
97
+ }
98
+ /**
99
+ * Read and validate `.vibeops.json` `merge`. Throws `MergeConfigError` on an
100
+ * invalid block; a missing file or block yields the defaults.
101
+ */
102
+ export async function readMergeConfig(root) {
103
+ const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
104
+ if (text === null)
105
+ return MERGE_GATE_DEFAULTS;
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(text);
109
+ }
110
+ catch {
111
+ throw new MergeConfigError("file is not valid JSON.");
112
+ }
113
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
114
+ return MERGE_GATE_DEFAULTS;
115
+ }
116
+ return parseMergeConfig(parsed.merge);
117
+ }
118
+ /** Gate options (milliseconds) derived from resolved config. */
119
+ export function mergeGateTiming(cfg) {
120
+ return {
121
+ timeoutMs: cfg.waitTimeoutSeconds * 1000,
122
+ intervalMs: cfg.pollIntervalSeconds * 1000,
123
+ emptyRollupGraceMs: cfg.emptyRollupGraceSeconds * 1000,
124
+ };
125
+ }
@@ -4,8 +4,13 @@ const ACTIVE_PIPELINE_STATUSES = new Set([
4
4
  "created",
5
5
  "waiting_for_resource",
6
6
  "preparing",
7
+ "scheduled",
8
+ "waiting_for_callback",
9
+ "canceling",
7
10
  ]);
8
11
  const TERMINAL_PIPELINE_FAILURES = new Set(["failed", "canceled"]);
12
+ /** Pipeline statuses that count as green for the merge gate (`none` = no pipeline). */
13
+ const GREEN_PIPELINE_STATUSES = new Set(["success", "skipped", "none"]);
9
14
  function normalizePipelineStatus(raw) {
10
15
  if (raw === null || raw === undefined || raw.trim().length === 0)
11
16
  return "none";
@@ -18,6 +23,8 @@ function normalizePipelineStatus(raw) {
18
23
  return "failed";
19
24
  if (status === "canceled" || status === "cancelled")
20
25
  return "canceled";
26
+ if (status === "manual")
27
+ return "manual";
21
28
  if (ACTIVE_PIPELINE_STATUSES.has(status))
22
29
  return "running";
23
30
  return "unknown";
@@ -25,6 +32,18 @@ function normalizePipelineStatus(raw) {
25
32
  export function pipelineStatusFromHost(raw) {
26
33
  return normalizePipelineStatus(raw);
27
34
  }
35
+ /**
36
+ * Merge-gate verdict for a single pipeline status (GitLab head pipeline, or the
37
+ * aggregated GitHub rollup). Unknown / manual / failed / canceled are red —
38
+ * never merge on a status we cannot prove is green.
39
+ */
40
+ export function pipelineGateState(status) {
41
+ if (GREEN_PIPELINE_STATUSES.has(status))
42
+ return "green";
43
+ if (isPipelineActive(status))
44
+ return "pending";
45
+ return "red";
46
+ }
28
47
  export function isPipelineActive(status) {
29
48
  return status === "running" || status === "pending";
30
49
  }
@@ -50,7 +69,7 @@ export function isMergeRequestReadyToMerge(readiness) {
50
69
  }
51
70
  if (TERMINAL_PIPELINE_FAILURES.has(readiness.pipelineStatus))
52
71
  return false;
53
- if (isPipelineActive(readiness.pipelineStatus))
72
+ if (pipelineGateState(readiness.pipelineStatus) !== "green")
54
73
  return false;
55
74
  const mergeStatus = readiness.mergeStatus?.trim().toLowerCase() ?? "";
56
75
  return mergeStatus === "can_be_merged" || detailed === "mergeable";
@@ -1,8 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { mergeRequestLabel } from "./git-host.js";
4
- import { isMergeRequestReadyToMerge, isPipelineActive, pipelineStatusFromHost, } from "./merge-request-readiness.js";
4
+ import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
5
+ import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
5
6
  import { dim, log } from "./logger.js";
7
+ import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
6
8
  const execFileAsync = promisify(execFile);
7
9
  async function ghPrCreate(opts) {
8
10
  const { stdout } = await execFileAsync("gh", [
@@ -192,52 +194,125 @@ export async function getMergeRequestState(cwd, host, url) {
192
194
  const details = await getMergeRequestDetails(cwd, host, url);
193
195
  return details?.state ?? "unknown";
194
196
  }
195
- export async function getMergeRequestDetails(cwd, host, url) {
196
- const ref = prRefFromUrl(url);
197
- try {
198
- if (host === "gitlab") {
199
- const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "-F", "json"], { cwd, maxBuffer: 1024 * 1024 });
200
- const parsed = JSON.parse(stdout.trim());
201
- const state = normalizeGlabMergeRequestState(parsed.state ?? "");
202
- return {
203
- state,
204
- mergedAt: parsed.merged_at ?? null,
205
- mergeCommitSha: parsed.merge_commit_sha ?? null,
206
- squashCommitSha: parsed.squash_commit_sha ?? null,
207
- mergeStatus: parsed.merge_status ?? null,
208
- detailedMergeStatus: parsed.detailed_merge_status ?? null,
209
- pipelineStatus: pipelineStatusFromHost(parsed.head_pipeline?.status),
210
- hasConflicts: parsed.has_conflicts ?? null,
211
- };
197
+ /** Host CLI returned JSON without a field the merge gate needs — fail closed. */
198
+ export class MergeRequestShapeError extends Error {
199
+ constructor(tool, problems) {
200
+ super(`${tool} output cannot be trusted for the merge gate: ${problems.join("; ")}.`);
201
+ this.name = "MergeRequestShapeError";
202
+ }
203
+ }
204
+ /** Describe every field that violates its rule (`undefined`, `null`, `""`, wrong type). */
205
+ function shapeProblems(obj, rules) {
206
+ const out = [];
207
+ for (const [field, rule] of Object.entries(rules)) {
208
+ const v = obj[field];
209
+ if (rule === "present") {
210
+ if (!(field in obj) || v === undefined)
211
+ out.push(`\`${field}\` is missing`);
212
212
  }
213
- const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit,mergeable,statusCheckRollup"], { cwd, maxBuffer: 1024 * 1024 });
214
- const parsed = JSON.parse(stdout.trim());
215
- const checks = parsed.statusCheckRollup ?? [];
216
- let pipelineStatus = "none";
217
- if (checks.some((c) => (c.state ?? "").toUpperCase() === "PENDING")) {
218
- pipelineStatus = "running";
213
+ else if (rule === "array") {
214
+ if (!Array.isArray(v))
215
+ out.push(`\`${field}\` is missing or not a list`);
219
216
  }
220
- else if (checks.some((c) => (c.state ?? "").toUpperCase() === "FAILURE")) {
221
- pipelineStatus = "failed";
217
+ else if (typeof v !== "string" || v.trim().length === 0) {
218
+ out.push(`\`${field}\` is missing or empty`);
222
219
  }
223
- else if (checks.length > 0) {
224
- pipelineStatus = "success";
220
+ }
221
+ return out;
222
+ }
223
+ export async function getMergeRequestDetails(cwd, host, url) {
224
+ try {
225
+ return await readMergeRequestDetails(cwd, host, url, { strict: false });
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ /**
232
+ * Read MR/PR details from the host CLI. Throws on CLI / JSON errors. With
233
+ * `strict`, also throws `MergeRequestShapeError` when a field the merge gate
234
+ * relies on is absent (never treat missing data as green).
235
+ */
236
+ export async function readMergeRequestDetails(cwd, host, url, options) {
237
+ const ref = prRefFromUrl(url);
238
+ if (host === "gitlab") {
239
+ const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "-F", "json"], { cwd, maxBuffer: 1024 * 1024 });
240
+ const parsed = JSON.parse(stdout.trim());
241
+ if (options.strict) {
242
+ const problems = shapeProblems(parsed, {
243
+ state: "nonEmptyString",
244
+ merge_status: "present",
245
+ detailed_merge_status: "present",
246
+ // `null` = no pipeline; the key itself must be there.
247
+ head_pipeline: "present",
248
+ sha: "nonEmptyString",
249
+ });
250
+ const pipeline = parsed.head_pipeline;
251
+ if (pipeline !== null &&
252
+ pipeline !== undefined &&
253
+ (typeof pipeline.status !== "string" || pipeline.status.trim().length === 0)) {
254
+ problems.push("`head_pipeline.status` is missing or empty");
255
+ }
256
+ if (problems.length > 0)
257
+ throw new MergeRequestShapeError("glab", problems);
225
258
  }
226
- const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
259
+ const state = normalizeGlabMergeRequestState(parsed.state ?? "");
227
260
  return {
228
- state: normalizeGhMergeRequestState(parsed.state ?? ""),
229
- mergedAt: parsed.mergedAt ?? null,
230
- mergeCommitSha: parsed.mergeCommit?.oid ?? null,
231
- squashCommitSha: null,
232
- mergeStatus: mergeable === "MERGEABLE" ? "can_be_merged" : mergeable.toLowerCase(),
233
- detailedMergeStatus: mergeable.toLowerCase(),
234
- pipelineStatus,
235
- hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
261
+ state,
262
+ mergedAt: parsed.merged_at ?? null,
263
+ mergeCommitSha: parsed.merge_commit_sha ?? null,
264
+ squashCommitSha: parsed.squash_commit_sha ?? null,
265
+ mergeStatus: parsed.merge_status ?? null,
266
+ detailedMergeStatus: parsed.detailed_merge_status ?? null,
267
+ pipelineStatus: pipelineStatusFromHost(parsed.head_pipeline?.status),
268
+ hasConflicts: parsed.has_conflicts ?? null,
269
+ checks: null,
270
+ headSha: parsed.sha ?? null,
236
271
  };
237
272
  }
238
- catch {
239
- return null;
273
+ const { stdout } = await execFileAsync("gh", [
274
+ "pr",
275
+ "view",
276
+ ref,
277
+ "--json",
278
+ "state,mergedAt,mergeCommit,mergeable,headRefOid,statusCheckRollup",
279
+ ], { cwd, maxBuffer: 4 * 1024 * 1024 });
280
+ const parsed = JSON.parse(stdout.trim());
281
+ if (options.strict) {
282
+ const problems = shapeProblems(parsed, {
283
+ state: "nonEmptyString",
284
+ mergeable: "nonEmptyString",
285
+ headRefOid: "nonEmptyString",
286
+ statusCheckRollup: "array",
287
+ });
288
+ if (problems.length > 0)
289
+ throw new MergeRequestShapeError("gh", problems);
290
+ }
291
+ const checks = classifyRollup(parsed.statusCheckRollup);
292
+ const gate = evaluateCheckGate(checks);
293
+ let pipelineStatus = "none";
294
+ if (gate.state === "red") {
295
+ pipelineStatus = "failed";
296
+ }
297
+ else if (gate.state === "pending") {
298
+ pipelineStatus = "running";
240
299
  }
300
+ else if (checks.length > 0) {
301
+ pipelineStatus = "success";
302
+ }
303
+ const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
304
+ return {
305
+ state: normalizeGhMergeRequestState(parsed.state ?? ""),
306
+ mergedAt: parsed.mergedAt ?? null,
307
+ mergeCommitSha: parsed.mergeCommit?.oid ?? null,
308
+ squashCommitSha: null,
309
+ mergeStatus: mergeable === "MERGEABLE" ? "can_be_merged" : mergeable.toLowerCase(),
310
+ detailedMergeStatus: mergeable.toLowerCase(),
311
+ pipelineStatus,
312
+ hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
313
+ checks,
314
+ headSha: parsed.headRefOid ?? null,
315
+ };
241
316
  }
242
317
  /** Poll host until MR/PR state is merged (or timeout). */
243
318
  export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
@@ -264,26 +339,135 @@ function mergeRequestReadinessFromDetails(details) {
264
339
  hasConflicts: details.hasConflicts,
265
340
  };
266
341
  }
267
- /** Poll until MR/PR CI finished and host reports mergeable (or already merged). */
268
- export async function waitForMergeRequestReadyToMerge(cwd, host, url, options = {}) {
269
- const timeoutMs = options.timeoutMs ?? 900_000;
270
- const intervalMs = options.intervalMs ?? 5_000;
271
- const deadline = Date.now() + timeoutMs;
342
+ /** Thrown by `mergeMergeRequest` when the merge gate refuses (red / pending / missing checks). */
343
+ export class MergeGateError extends Error {
344
+ problems;
345
+ constructor(label, problems) {
346
+ super(`Refusing to merge ${label}:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
347
+ this.name = "MergeGateError";
348
+ this.problems = problems;
349
+ }
350
+ }
351
+ function evaluateDetailsGate(details, requiredChecks) {
352
+ if (details.checks !== null) {
353
+ const gate = evaluateCheckGate(details.checks, requiredChecks);
354
+ return {
355
+ state: gate.state,
356
+ problems: formatCheckGateProblems(gate),
357
+ noChecks: details.checks.length === 0,
358
+ };
359
+ }
360
+ const state = pipelineGateState(details.pipelineStatus);
361
+ return {
362
+ state,
363
+ problems: state === "green" ? [] : [`head pipeline is ${details.pipelineStatus}`],
364
+ noChecks: details.pipelineStatus === "none",
365
+ };
366
+ }
367
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
368
+ /**
369
+ * Merge gate: poll the MR/PR until every check is green and the host reports it
370
+ * mergeable. Refuses immediately on any failed check, on host output it cannot
371
+ * trust, and on "no checks at all" (unless allowed); refuses after the timeout
372
+ * when checks stay pending or a required check never appears.
373
+ */
374
+ export async function waitForMergeGate(cwd, host, url, options = {}) {
375
+ const defaults = mergeGateTiming(MERGE_GATE_DEFAULTS);
376
+ const timeoutMs = options.timeoutMs ?? defaults.timeoutMs;
377
+ const intervalMs = options.intervalMs ?? defaults.intervalMs;
378
+ const graceMs = options.emptyRollupGraceMs ?? defaults.emptyRollupGraceMs;
379
+ const allowNoChecks = options.allowNoChecks ?? MERGE_GATE_DEFAULTS.allowNoChecks;
380
+ const waitForPending = options.waitForPending !== false;
381
+ const required = options.requiredChecks ?? [];
272
382
  const label = mergeRequestLabel(host);
383
+ const start = Date.now();
384
+ const deadline = start + timeoutMs;
273
385
  let loggedWait = false;
274
- while (Date.now() < deadline) {
275
- const details = await getMergeRequestDetails(cwd, host, url);
276
- const readiness = mergeRequestReadinessFromDetails(details);
277
- if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
278
- return true;
386
+ let warnedGitLabRequired = false;
387
+ for (;;) {
388
+ let problems = [];
389
+ let details = null;
390
+ try {
391
+ details = await readMergeRequestDetails(cwd, host, url, { strict: true });
392
+ }
393
+ catch (error) {
394
+ if (error instanceof MergeRequestShapeError) {
395
+ return { ok: false, problems: [error.message] };
396
+ }
397
+ const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
398
+ problems = [`could not read ${label} details from the host: ${msg}`];
399
+ }
400
+ if (details === null) {
401
+ // problems set in catch; keep polling (transient CLI / network error).
402
+ }
403
+ else if (details.state === "merged") {
404
+ return { ok: true, alreadyMerged: true, details };
405
+ }
406
+ else if (details.state !== "open") {
407
+ return { ok: false, problems: [`${label} is ${details.state}, not open`] };
408
+ }
409
+ else {
410
+ const gitlab = details.checks === null;
411
+ if (gitlab && required.length > 0 && !warnedGitLabRequired) {
412
+ log.warn("merge.requiredChecks is GitHub-only; GitLab uses the head pipeline status.");
413
+ warnedGitLabRequired = true;
414
+ }
415
+ const ev = evaluateDetailsGate(details, required);
416
+ if (ev.state === "red") {
417
+ return { ok: false, problems: ev.problems };
418
+ }
419
+ if (details.hasConflicts === true) {
420
+ return { ok: false, problems: [`${label} has merge conflicts`] };
421
+ }
422
+ // "No checks at all" is judged here only when no required check could
423
+ // report it instead (GitHub with requiredChecks → "never ran" path).
424
+ const noChecksRule = ev.noChecks && (gitlab || required.length === 0);
425
+ const inGrace = waitForPending && noChecksRule && Date.now() - start < graceMs;
426
+ if (ev.state === "pending") {
427
+ problems = ev.problems;
428
+ }
429
+ else if (inGrace) {
430
+ problems = ["no status checks reported yet"];
431
+ }
432
+ else if (noChecksRule && !allowNoChecks) {
433
+ return {
434
+ ok: false,
435
+ problems: [
436
+ `the host reports no checks for this ${label} — refusing (fail closed). ` +
437
+ "Configure CI and `merge.requiredChecks`, or set `merge.allowNoChecks: true` in .vibeops.json for a repo without CI",
438
+ ],
439
+ };
440
+ }
441
+ else {
442
+ const readiness = mergeRequestReadinessFromDetails(details);
443
+ if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
444
+ if (noChecksRule) {
445
+ // Once, after the gate has decided to merge — not on every poll.
446
+ log.warn(`WARNING: merging ${label} with NO checks (merge.allowNoChecks is true). Nothing verified this change.`);
447
+ }
448
+ return { ok: true, alreadyMerged: false, details };
449
+ }
450
+ problems = [
451
+ `host does not report the ${label} as mergeable yet (${details.detailedMergeStatus || details.mergeStatus || "unknown"})`,
452
+ ];
453
+ }
454
+ }
455
+ const now = Date.now();
456
+ if (!waitForPending || now >= deadline) {
457
+ const suffix = waitForPending
458
+ ? ` (gave up after ${Math.round(timeoutMs / 1000)}s)`
459
+ : "";
460
+ return {
461
+ ok: false,
462
+ problems: problems.map((p, i) => (i === problems.length - 1 ? `${p}${suffix}` : p)),
463
+ };
279
464
  }
280
465
  if (!loggedWait) {
281
- log.info(dim(`Waiting for ${label} pipeline / merge checks…`));
466
+ log.info(dim(`Waiting for ${label} checks: ${problems.join("; ")}…`));
282
467
  loggedWait = true;
283
468
  }
284
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
469
+ await sleep(Math.min(intervalMs, deadline - now));
285
470
  }
286
- return false;
287
471
  }
288
472
  function isMergeHttp405(error) {
289
473
  const msg = error instanceof Error ? error.message : String(error);
@@ -312,68 +496,74 @@ export async function mergeMergeRequest(opts) {
312
496
  const ref = prRefFromUrl(opts.url);
313
497
  const method = opts.method ?? "squash";
314
498
  const label = mergeRequestLabel(opts.host);
315
- if (opts.waitForCi === true && opts.dryRun !== true) {
316
- const ready = await waitForMergeRequestReadyToMerge(opts.cwd, opts.host, opts.url);
317
- if (!ready) {
318
- throw new Error(`${label} is not ready to merge yet (pipeline running, checks pending, or timeout).`);
319
- }
320
- }
321
- const details = await getMergeRequestDetails(opts.cwd, opts.host, opts.url);
322
- const readiness = mergeRequestReadinessFromDetails(details);
323
- const pipelineActive = readiness !== null ? isPipelineActive(readiness.pipelineStatus) : false;
324
499
  if (opts.dryRun === true) {
500
+ const required = opts.requiredChecks && opts.requiredChecks.length > 0
501
+ ? ` + required: ${opts.requiredChecks.join(", ")}`
502
+ : "";
503
+ log.info(`would check ${label} status checks (all green${required}) before merging`);
325
504
  if (opts.host === "gitlab") {
326
- const immediate = opts.immediate !== false && !pipelineActive;
327
- const flags = immediate ? " --auto-merge=false" : " (auto-merge when pipeline succeeds)";
328
- const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
329
- log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
505
+ log.info(`would ${label} merge ${ref} (glab ${gitLabMergeArgs(ref, method, "<head sha>").join(" ")})`);
330
506
  }
331
507
  else {
332
- log.info(`would gh pr merge ${ref} --${method}`);
508
+ log.info(`would gh ${gitHubMergeArgs(ref, method, "<head sha>").join(" ")}`);
333
509
  }
334
510
  return;
335
511
  }
336
- if (opts.host === "gitlab") {
337
- await mergeGitLabMergeRequest({
338
- cwd: opts.cwd,
339
- ref,
340
- method,
341
- immediate: opts.immediate !== false && !pipelineActive,
512
+ const runGate = async () => {
513
+ const gate = await waitForMergeGate(opts.cwd, opts.host, opts.url, {
514
+ ...opts.gate,
515
+ waitForPending: opts.waitForCi === true,
516
+ requiredChecks: opts.requiredChecks ?? [],
342
517
  });
343
- return;
344
- }
345
- const args = ["pr", "merge", ref, `--${method}`];
346
- await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
347
- }
348
- async function mergeGitLabMergeRequest(input) {
349
- const attempt = async (immediate) => {
350
- const args = ["mr", "merge", input.ref];
351
- if (immediate) {
352
- args.push("--auto-merge=false");
353
- }
354
- if (input.method === "squash") {
355
- args.push("--squash");
356
- }
357
- else if (input.method === "rebase") {
358
- args.push("--rebase");
518
+ if (!gate.ok)
519
+ throw new MergeGateError(label, gate.problems);
520
+ if (gate.alreadyMerged)
521
+ return null;
522
+ if (gate.details.headSha === null || gate.details.headSha.trim().length === 0) {
523
+ // Strict read already guarantees this; never merge unpinned.
524
+ throw new MergeGateError(label, ["host did not report the head commit SHA"]);
359
525
  }
360
- await execFileAsync("glab", args, {
361
- cwd: input.cwd,
362
- maxBuffer: 4 * 1024 * 1024,
363
- });
526
+ return gate.details;
364
527
  };
365
- try {
366
- await attempt(input.immediate);
367
- }
368
- catch (error) {
369
- if (!input.immediate && isMergeHttp405(error)) {
370
- await attempt(false);
371
- return;
528
+ const details = await runGate();
529
+ if (details === null)
530
+ return;
531
+ if (opts.host === "gitlab") {
532
+ try {
533
+ await execGlabMerge(opts.cwd, gitLabMergeArgs(ref, method, details.headSha));
372
534
  }
373
- if (input.immediate && isMergeHttp405(error)) {
374
- await attempt(false);
375
- return;
535
+ catch (error) {
536
+ if (!isMergeHttp405(error))
537
+ throw error;
538
+ // 405: GitLab not ready to accept the merge yet (e.g. mergeability still
539
+ // being computed). Re-run the gate, then retry once with the same flags
540
+ // (still `--auto-merge=false`, pinned to the re-checked SHA).
541
+ const again = await runGate();
542
+ if (again === null)
543
+ return;
544
+ await execGlabMerge(opts.cwd, gitLabMergeArgs(ref, method, again.headSha));
376
545
  }
377
- throw error;
546
+ return;
378
547
  }
548
+ // Pinned to the commit the gate checked: a push after the gate makes GitHub refuse.
549
+ await execFileAsync("gh", gitHubMergeArgs(ref, method, details.headSha), {
550
+ cwd: opts.cwd,
551
+ maxBuffer: 4 * 1024 * 1024,
552
+ });
553
+ }
554
+ /** `gh pr merge` argv. `--match-head-commit` needs gh ≥ 2.13.0. */
555
+ export function gitHubMergeArgs(ref, method, headSha) {
556
+ return ["pr", "merge", ref, `--${method}`, "--match-head-commit", headSha];
557
+ }
558
+ /** `glab mr merge` argv: immediate (never schedule auto-merge) and pinned with `--sha`. */
559
+ export function gitLabMergeArgs(ref, method, headSha) {
560
+ const args = ["mr", "merge", ref, "--auto-merge=false", "--sha", headSha];
561
+ if (method === "squash")
562
+ args.push("--squash");
563
+ else if (method === "rebase")
564
+ args.push("--rebase");
565
+ return args;
566
+ }
567
+ async function execGlabMerge(cwd, args) {
568
+ await execFileAsync("glab", [...args], { cwd, maxBuffer: 4 * 1024 * 1024 });
379
569
  }
@@ -2,7 +2,8 @@ import { resolve } from "node:path";
2
2
  import { deleteFile } from "./filesystem.js";
3
3
  import { GitConfigError, requireGitConfig } from "./git-config.js";
4
4
  import { mergeRequestLabel } from "./git-host.js";
5
- import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, gitSwitchToBranch, readGitInfo, } from "./git.js";
5
+ import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, readGitInfo, } from "./git.js";
6
+ import { leaveTaskBranch } from "./git-worktree.js";
6
7
  import { dim, log } from "./logger.js";
7
8
  import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
8
9
  import { normalizeTaskRef } from "./resolve-task.js";
@@ -83,12 +84,13 @@ export async function deleteTaskBranches(opts) {
83
84
  }
84
85
  const git = await readGitInfo(cwd);
85
86
  if (git.branch === opts.taskBranch) {
86
- const switched = await gitSwitchToBranch(cwd, opts.integrationBranch, opts.remote);
87
- if (!switched) {
88
- log.error(`Integration branch "${opts.integrationBranch}" not found.`);
87
+ // Worktree-safe (D-006): detach when another worktree owns the integration branch.
88
+ const left = await leaveTaskBranch(cwd, opts.remote, opts.integrationBranch);
89
+ if (!left.ok) {
90
+ log.error(left.message);
89
91
  return false;
90
92
  }
91
- log.ok(`On ${opts.integrationBranch}`);
93
+ log.ok(left.on === "integration" ? `On ${opts.integrationBranch}` : `Detached at ${left.ref}`);
92
94
  }
93
95
  if (await gitBranchExists(cwd, opts.taskBranch)) {
94
96
  try {