@goodtek/vibeops 2.5.2 → 3.0.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 CHANGED
@@ -4,6 +4,37 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.0.0 - 2026-10-03
8
+
9
+ ### Breaking changes
10
+
11
+ - **`task merge` / `task release` refuse to merge a PR/MR with no checks at all** (default `merge.allowNoChecks: false`). Previously such PRs merged.
12
+ **Migration (repos without CI):** add to `.vibeops.json`:
13
+ ```json
14
+ { "merge": { "allowNoChecks": true } }
15
+ ```
16
+ A warning is printed on each such merge.
17
+ - **Requires gh ≥ 2.13.0** (`gh pr merge --match-head-commit`). Check with `gh --version`; upgrade e.g. `brew upgrade gh`.
18
+ - **Invalid `.vibeops.json` `merge` block** (non-object, unknown key, wrong type, out-of-range number) makes `task merge` / `task release` exit 1.
19
+
20
+ ### Fixed
21
+
22
+ - **`task merge` / `task release` (GitHub):** GitHub Actions checks (`CheckRun`) were read from a `state` field they do not have, so a **failed Actions check counted as green** and the PR merged. Checks are now classified from `status` + `conclusion` (CheckRun) and `state` (StatusContext); missing or undocumented values fail closed.
23
+ - **`task merge` / `task release` (GitLab):** `manual` and unknown head-pipeline statuses no longer count as mergeable; `scheduled` / `waiting_for_callback` / `canceling` count as running. A `405` on merge no longer falls back to scheduling auto-merge; it re-runs the gate and retries once with `--auto-merge=false`.
24
+
25
+ ### Added
26
+
27
+ - **Merge gate:** refuse on any failed check (immediately), wait bounded on pending checks then refuse, refuse on merge conflicts. No override flag. Merges are pinned to the checked commit (`gh pr merge --match-head-commit`, `glab mr merge --sha`).
28
+ - **`.vibeops.json` `merge` block:** `requiredChecks`, `releaseRequiredChecks` (exact or `*` glob; absent → `required check "X" never ran`), `waitTimeoutSeconds` (900), `pollIntervalSeconds` (5), `emptyRollupGraceSeconds` (30), `allowNoChecks` (false). Strictly validated; `task release --dry-run` shows the release required checks.
29
+
30
+ ### Behaviour changes
31
+
32
+ - No checks at all: refused after a 30 s grace (`emptyRollupGraceSeconds`) for checks not registered yet — see Breaking changes.
33
+ - **Host output is verified:** missing or empty `state` / `mergeable` / `headRefOid` / `statusCheckRollup` from `gh pr view` (GitLab: `state` / `sha` / `merge_status` / `detailed_merge_status` / `head_pipeline`) refuses the merge instead of being treated as green.
34
+ - **GitLab `manual` head pipelines refuse** (previously merged when the MR was otherwise mergeable).
35
+ - **Pending checks are always waited for** on `task merge` / `task release` (up to `waitTimeoutSeconds`); a PR whose checks are still running is no longer merged or auto-merge-scheduled.
36
+ - `glab mr merge --sha` verified with glab 1.106.
37
+
7
38
  ## 2.5.2 - 2026-07-20
8
39
 
9
40
  ### Fixed
package/README.md CHANGED
@@ -155,13 +155,60 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
155
155
  - **Init** records branch policy in `.vibeops.json` (`integrationBranch`, `productionBranch`, `host`). Interactive remote setup: ask GitHub/GitLab, create or connect; soft-gate when `gh`/`glab` is missing.
156
156
  - **`task add`**: `task/<slug>` from **integration** (e.g. `develop`).
157
157
  - **`task ship`** (state-aware): **no PR** → commit + ship metadata (Status **Shipped**) → push → open MR/PR; **open PR** → commit + push the same branch, CI re-runs, no new PR; **merged PR** → new PR cycle (carries uncommitted work onto the task branch, integrates `develop`, opens a **new** PR). Use `--new-cycle` in non-interactive mode. Commit messages are TASK-id-scoped (`feat(task-001): …`). Refuses when HEAD is not the task branch.
158
- - **`task merge`**: merge MR/PR into integration (CLI or host UI; TASK md unchanged).
158
+ - **`task merge`**: merge MR/PR into integration (TASK md unchanged) — **only when every check is green** (see [Merge gate](#merge-gate)).
159
159
  - **`task sync`**: integration ff-only pull → delete `task/*` branches (TASK md unchanged).
160
160
  - **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command).
161
161
  - **`task release`**: integration → production PR + merge (skipped on trunk policy).
162
162
  - **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
163
163
  - No force-push to shared branches.
164
164
 
165
+ ## Merge gate
166
+
167
+ > **3.0.0 breaking:** a PR/MR with **no checks at all** is refused by default. Repos without CI: set `"merge": {"allowNoChecks": true}` in `.vibeops.json`. Requires **gh ≥ 2.13.0**.
168
+
169
+ `task merge` and `task release` are a client-side CI gate (useful when the host plan has no branch protection / required checks, e.g. private GitHub repos on the free plan). **Where server-side branch protection / required checks are available, enable them — the host remains the final authority;** this gate only stops VibeOps itself from merging red work (a human can still merge in the host UI).
170
+
171
+ Before calling `gh pr merge` / `glab mr merge`:
172
+
173
+ - **GitHub:** every item in the PR `statusCheckRollup` is classified from GitHub's documented enums — `CheckRun` (Actions) by `status` + `conclusion`, `StatusContext` by `state`:
174
+ - **passed:** `SUCCESS`, `NEUTRAL`, `SKIPPED`
175
+ - **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
176
+ - **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
177
+ - **anything missing or undocumented fails closed** (treated as failed). Several checks with the same name: any failure fails.
178
+ - **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
179
+ - **Host output is verified:** if `gh pr view` lacks or has empty `state`, `mergeable`, `headRefOid`, or `statusCheckRollup` is not a list (GitLab: `state`, `sha`, `merge_status`, `detailed_merge_status`, `head_pipeline`), the merge is refused.
180
+ - **Failed check → refuse immediately** (exit code 1, checks named). **Pending → wait** up to `waitTimeoutSeconds`, then refuse. There is no override flag: fix CI (re-run `task ship`) and rerun `task merge`.
181
+ - **No checks at all → refuse** (after `emptyRollupGraceSeconds`, which covers checks not yet registered right after a push) unless `allowNoChecks: true`.
182
+ - **The merge is pinned to the checked commit:** `gh pr merge --match-head-commit <sha>` (requires **gh ≥ 2.13.0**) / `glab mr merge --auto-merge=false --sha <sha>` (verified with glab 1.106). A push after the check makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
183
+
184
+ ### Configuration (`.vibeops.json` → `merge`)
185
+
186
+ ```json
187
+ {
188
+ "merge": {
189
+ "requiredChecks": ["build", "lint*"],
190
+ "releaseRequiredChecks": ["release smoke"],
191
+ "waitTimeoutSeconds": 900,
192
+ "pollIntervalSeconds": 5,
193
+ "emptyRollupGraceSeconds": 30,
194
+ "allowNoChecks": false
195
+ }
196
+ }
197
+ ```
198
+
199
+ | Key | Default | Allowed | Meaning |
200
+ |-----|---------|---------|---------|
201
+ | `requiredChecks` | `[]` | array of non-empty strings | Checks that must be present and green for `task merge`. A missing one refuses: `required check "X" never ran`. |
202
+ | `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
203
+ | `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
204
+ | `pollIntervalSeconds` | `5` | integer 1–300, ≤ timeout | Poll interval while waiting. |
205
+ | `emptyRollupGraceSeconds` | `30` | integer 0–600 | How long an empty check list is re-polled before "no checks" applies. |
206
+ | `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
207
+
208
+ - Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
209
+ - The block is validated strictly: it must be an object, unknown keys (typos) and wrong types are errors, and `task merge` / `task release` exit 1 naming the key.
210
+ - **Recommended:** list your CI jobs in `requiredChecks`. Without it, the gate only sees the checks that happened to start — a workflow that never triggers is invisible.
211
+
165
212
  ## License
166
213
 
167
214
  MIT
@@ -1,12 +1,13 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
3
4
  import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
4
5
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
5
6
  import { gitRemoteUrl } from "../lib/git.js";
6
7
  import { bold, cyan, dim, log } from "../lib/logger.js";
7
8
  import { projectPaths } from "../lib/paths.js";
8
9
  import { taskNotFoundMessage } from "../lib/resolve-task.js";
9
- import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
10
+ import { getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
10
11
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
11
12
  import { relPath } from "../lib/task-context.js";
12
13
  import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
@@ -19,6 +20,12 @@ function resolveMergeMethod(opts) {
19
20
  return "merge";
20
21
  return "squash";
21
22
  }
23
+ function mergeFailureHint(error) {
24
+ if (error instanceof MergeGateError) {
25
+ return "Not merged. Fix the failing checks (re-run `vibeops task ship` to push a fix and re-run CI), then rerun `vibeops task merge`.";
26
+ }
27
+ return "Merge in the host UI if branch protection blocks CLI merge.";
28
+ }
22
29
  export async function taskMergeCommand(taskRef, options = {}) {
23
30
  const cwd = resolve(options.cwd ?? process.cwd());
24
31
  const dryRun = options.dryRun === true;
@@ -35,6 +42,20 @@ export async function taskMergeCommand(taskRef, options = {}) {
35
42
  }
36
43
  throw e;
37
44
  }
45
+ let mergeCfg;
46
+ try {
47
+ mergeCfg = await readMergeConfig(cwd);
48
+ }
49
+ catch (e) {
50
+ if (e instanceof MergeConfigError) {
51
+ log.error(e.message);
52
+ process.exitCode = 1;
53
+ return;
54
+ }
55
+ throw e;
56
+ }
57
+ const requiredChecks = mergeCfg.requiredChecks;
58
+ const gate = { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks };
38
59
  const target = await resolveLifecycleTarget(paths, cwd, taskRef);
39
60
  if (target === null) {
40
61
  if (taskRef?.trim()) {
@@ -74,6 +95,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
74
95
  log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
75
96
  log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
76
97
  log.info(` ${dim("method")} ${method}`);
98
+ log.info(` ${dim("checks")} all green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
77
99
  log.blank();
78
100
  if (dryRun) {
79
101
  await mergeMergeRequest({
@@ -81,6 +103,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
81
103
  host,
82
104
  url: mergeRequestUrl,
83
105
  method,
106
+ requiredChecks,
107
+ gate,
84
108
  dryRun: true,
85
109
  });
86
110
  log.blank();
@@ -112,7 +136,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
112
136
  url: mergeRequestUrl,
113
137
  method,
114
138
  waitForCi: true,
115
- immediate: true,
139
+ requiredChecks,
140
+ gate,
116
141
  });
117
142
  const verified = await assertMergeRequestMerged({
118
143
  cwd,
@@ -130,7 +155,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
130
155
  catch (e) {
131
156
  const msg = e instanceof Error ? e.message : String(e);
132
157
  log.error(`Merge failed: ${msg}`);
133
- log.info(dim("Merge in the host UI if branch protection blocks CLI merge."));
158
+ log.info(dim(mergeFailureHint(e)));
134
159
  process.exitCode = 1;
135
160
  return;
136
161
  }
@@ -149,7 +174,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
149
174
  url: mergeRequestUrl,
150
175
  method,
151
176
  waitForCi: true,
152
- immediate: true,
177
+ requiredChecks,
178
+ gate,
153
179
  });
154
180
  const verified = await assertMergeRequestMerged({
155
181
  cwd,
@@ -167,6 +193,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
167
193
  catch (e) {
168
194
  const msg = e instanceof Error ? e.message : String(e);
169
195
  log.error(`Merge failed: ${msg}`);
196
+ log.info(dim(mergeFailureHint(e)));
170
197
  process.exitCode = 1;
171
198
  return;
172
199
  }
@@ -1,10 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
3
+ import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
3
4
  import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
4
5
  import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
5
6
  import { gitRemoteUrl } from "../lib/git.js";
6
7
  import { bold, dim, log } from "../lib/logger.js";
7
- import { createMergeRequest, getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
8
+ import { createMergeRequest, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
8
9
  import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
9
10
  function resolveReleaseMergeMethod(opts) {
10
11
  if (opts.rebase === true)
@@ -29,6 +30,19 @@ export async function taskReleaseCommand(options = {}) {
29
30
  throw e;
30
31
  }
31
32
  const { integrationBranch, productionBranch, remote } = gitCfg;
33
+ let mergeCfg;
34
+ try {
35
+ mergeCfg = await readMergeConfig(cwd);
36
+ }
37
+ catch (e) {
38
+ if (e instanceof MergeConfigError) {
39
+ log.error(e.message);
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ throw e;
44
+ }
45
+ const requiredChecks = mergeCfg.releaseRequiredChecks;
32
46
  if (integrationBranch === productionBranch) {
33
47
  log.info("Trunk policy: integration and production are the same branch — no release PR step.");
34
48
  return;
@@ -52,7 +66,11 @@ export async function taskReleaseCommand(options = {}) {
52
66
  log.info(bold("dry-run — would:"));
53
67
  log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
54
68
  if (options.noMerge !== true) {
55
- log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}, wait for CI)`);
69
+ log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}) only when all checks are green`);
70
+ log.info(` · required checks: ${requiredChecks.length > 0 ? requiredChecks.join(", ") : "(none configured)"}`);
71
+ if (mergeCfg.allowNoChecks) {
72
+ log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
73
+ }
56
74
  }
57
75
  return;
58
76
  }
@@ -101,7 +119,8 @@ export async function taskReleaseCommand(options = {}) {
101
119
  url: releaseUrl,
102
120
  method,
103
121
  waitForCi: true,
104
- immediate: true,
122
+ requiredChecks,
123
+ gate: { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks },
105
124
  });
106
125
  const verified = await assertMergeRequestMerged({
107
126
  cwd,
@@ -119,7 +138,12 @@ export async function taskReleaseCommand(options = {}) {
119
138
  catch (e) {
120
139
  const msg = e instanceof Error ? e.message : String(e);
121
140
  log.error(`Release merge failed: ${msg}`);
122
- log.info(dim(`Complete merge in the host UI: ${releaseUrl}`));
141
+ if (e instanceof MergeGateError) {
142
+ log.info(dim(`Not merged. Fix the failing checks on ${releaseUrl}, then rerun task release.`));
143
+ }
144
+ else {
145
+ log.info(dim(`Complete merge in the host UI: ${releaseUrl}`));
146
+ }
123
147
  process.exitCode = 1;
124
148
  }
125
149
  }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Classify GitHub `statusCheckRollup` items and evaluate the merge gate
3
+ * (`task merge` / `task release` refuse to merge on red or missing checks).
4
+ *
5
+ * GitHub returns two item shapes:
6
+ * - `CheckRun` (GitHub Actions, check apps): `status` + `conclusion`, no `state`.
7
+ * - `StatusContext` (commit status API): `state`, no `status`/`conclusion`.
8
+ */
9
+ /** GitHub GraphQL `CheckStatusState` values other than COMPLETED. */
10
+ const CHECK_RUN_IN_FLIGHT = new Set(["REQUESTED", "QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
11
+ /** GitHub GraphQL `CheckConclusionState`, split into pass / fail (exhaustive). */
12
+ const CHECK_RUN_PASS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
13
+ const CHECK_RUN_FAIL = new Set([
14
+ "FAILURE",
15
+ "CANCELLED",
16
+ "TIMED_OUT",
17
+ "ACTION_REQUIRED",
18
+ "STARTUP_FAILURE",
19
+ "STALE",
20
+ ]);
21
+ /** GitHub GraphQL `StatusState` (exhaustive): SUCCESS | PENDING | EXPECTED | FAILURE | ERROR. */
22
+ const STATUS_CONTEXT_PENDING = new Set(["PENDING", "EXPECTED"]);
23
+ const STATUS_CONTEXT_FAIL = new Set(["FAILURE", "ERROR"]);
24
+ function upper(raw) {
25
+ return (raw ?? "").trim().toUpperCase();
26
+ }
27
+ function isCheckRun(item) {
28
+ if (item.__typename === "CheckRun")
29
+ return true;
30
+ if (item.__typename === "StatusContext")
31
+ return false;
32
+ // Unknown typename: infer from fields.
33
+ return (item.status ?? null) !== null || (item.conclusion ?? null) !== null;
34
+ }
35
+ export function classifyRollupItem(item) {
36
+ if (isCheckRun(item)) {
37
+ const name = (item.name ?? "").trim() || "(unnamed check run)";
38
+ const status = upper(item.status);
39
+ const conclusion = upper(item.conclusion);
40
+ if (CHECK_RUN_IN_FLIGHT.has(status)) {
41
+ return { name, kind: "CheckRun", verdict: "pending", detail: status };
42
+ }
43
+ if (status !== "COMPLETED") {
44
+ // Missing or undocumented status — fail closed.
45
+ return { name, kind: "CheckRun", verdict: "failed", detail: status || "MISSING_STATUS" };
46
+ }
47
+ if (CHECK_RUN_PASS.has(conclusion)) {
48
+ return { name, kind: "CheckRun", verdict: "passed", detail: conclusion };
49
+ }
50
+ if (CHECK_RUN_FAIL.has(conclusion)) {
51
+ return { name, kind: "CheckRun", verdict: "failed", detail: conclusion };
52
+ }
53
+ // Completed with a missing or undocumented conclusion — fail closed.
54
+ return {
55
+ name,
56
+ kind: "CheckRun",
57
+ verdict: "failed",
58
+ detail: conclusion || "MISSING_CONCLUSION",
59
+ };
60
+ }
61
+ const name = (item.context ?? item.name ?? "").trim() || "(unnamed status)";
62
+ const state = upper(item.state);
63
+ if (state === "SUCCESS") {
64
+ return { name, kind: "StatusContext", verdict: "passed", detail: state };
65
+ }
66
+ if (STATUS_CONTEXT_PENDING.has(state)) {
67
+ return { name, kind: "StatusContext", verdict: "pending", detail: state };
68
+ }
69
+ if (STATUS_CONTEXT_FAIL.has(state)) {
70
+ return { name, kind: "StatusContext", verdict: "failed", detail: state };
71
+ }
72
+ // Missing or undocumented state — fail closed.
73
+ return { name, kind: "StatusContext", verdict: "failed", detail: state || "MISSING_STATE" };
74
+ }
75
+ export function classifyRollup(items) {
76
+ return (items ?? []).map(classifyRollupItem);
77
+ }
78
+ /** Exact name, or a `*` glob (e.g. `lint*`, `* · linux`). */
79
+ export function checkNameMatches(name, pattern) {
80
+ const p = pattern.trim();
81
+ if (p.length === 0)
82
+ return false;
83
+ if (!p.includes("*"))
84
+ return name === p;
85
+ const re = new RegExp(`^${p
86
+ .split("*")
87
+ .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
88
+ .join(".*")}$`);
89
+ return re.test(name);
90
+ }
91
+ /**
92
+ * Evaluate classified checks against optional required patterns.
93
+ * - any failed → red
94
+ * - any pending, or a required check not (yet) present → pending
95
+ * - otherwise green (including "no checks at all" when nothing is required)
96
+ */
97
+ export function evaluateCheckGate(checks, requiredChecks = []) {
98
+ const failed = checks.filter((c) => c.verdict === "failed");
99
+ const pending = checks.filter((c) => c.verdict === "pending");
100
+ const passed = checks.filter((c) => c.verdict === "passed");
101
+ const missingRequired = requiredChecks
102
+ .map((p) => p.trim())
103
+ .filter((p) => p.length > 0)
104
+ .filter((p) => !checks.some((c) => checkNameMatches(c.name, p)));
105
+ let state = "green";
106
+ if (failed.length > 0)
107
+ state = "red";
108
+ else if (pending.length > 0 || missingRequired.length > 0)
109
+ state = "pending";
110
+ return { state, failed, pending, passed, missingRequired };
111
+ }
112
+ function describe(c) {
113
+ return `"${c.name}" (${c.detail})`;
114
+ }
115
+ /** Human-readable lines for a refused merge. */
116
+ export function formatCheckGateProblems(gate) {
117
+ const lines = [];
118
+ if (gate.failed.length > 0) {
119
+ lines.push(`failed checks: ${gate.failed.map(describe).join(", ")}`);
120
+ }
121
+ if (gate.pending.length > 0) {
122
+ lines.push(`checks still pending: ${gate.pending.map(describe).join(", ")}`);
123
+ }
124
+ for (const p of gate.missingRequired) {
125
+ lines.push(`required check "${p}" never ran`);
126
+ }
127
+ return lines;
128
+ }
@@ -64,6 +64,7 @@ export async function readConfig(root) {
64
64
  parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
65
65
  const llm = parseLlmBlock(parsed.llm);
66
66
  const git = parseGitBlock(parsed.git);
67
+ const merge = parsed.merge;
67
68
  const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
68
69
  return {
69
70
  name: parsed.name,
@@ -73,6 +74,7 @@ export async function readConfig(root) {
73
74
  clients,
74
75
  ...(git ? { git } : {}),
75
76
  ...(llm ? { llm } : {}),
77
+ ...(merge !== undefined ? { merge } : {}),
76
78
  };
77
79
  }
78
80
  return null;
@@ -90,6 +92,7 @@ export function buildConfig(name, clients, git, existing, nowIso) {
90
92
  clients,
91
93
  git,
92
94
  llm: existing?.llm ?? { provider: "auto" },
95
+ ...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
93
96
  };
94
97
  }
95
98
  export function configToJson(config) {
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.5.2",
3
+ "version": "3.0.0",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",