@goodtek/vibeops 2.5.1 → 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 +37 -0
- package/README.md +49 -2
- package/dist/commands/task-merge.js +31 -4
- package/dist/commands/task-release.js +28 -4
- package/dist/lib/check-rollup.js +128 -0
- package/dist/lib/config.js +3 -0
- package/dist/lib/git-integration-sync.js +32 -14
- package/dist/lib/git.js +5 -0
- package/dist/lib/merge-config.js +125 -0
- package/dist/lib/merge-request-readiness.js +20 -1
- package/dist/lib/pr-create.js +293 -103
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ 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
|
+
|
|
38
|
+
## 2.5.2 - 2026-07-20
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **`task add` / integration sync:** Governance-only dirt (`.vibeops.json`, `AGENTS.md`, `.vibeops/`, docs, `.cursor/rules/`) no longer blocks preflight. Right after `init`, `task add` works without stash. App-file dirt still fails and lists the blocking paths.
|
|
43
|
+
|
|
7
44
|
## 2.5.1 - 2026-07-20
|
|
8
45
|
|
|
9
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -111,7 +111,7 @@ vibeops task release
|
|
|
111
111
|
|
|
112
112
|
Only one TASK **In Progress** at a time (`task add` blocks otherwise). **Shipped** slices do not block the next add; merge on the host or with `task merge`, then optional `task sync`.
|
|
113
113
|
|
|
114
|
-
`task add` fast-forward-pulls the integration branch first. If that fails (diverged / dirty / local-ahead), it prints the cause and fix commands and **does not** create a TASK file. If a previous run left a TASK file without a branch, rerun `task add` to resume.
|
|
114
|
+
`task add` fast-forward-pulls the integration branch first. If that fails (diverged / **app** dirty / local-ahead), it prints the cause and fix commands and **does not** create a TASK file. VibeOps/governance-only changes (e.g. `.vibeops.json` after `init`) do **not** block. If a previous run left a TASK file without a branch, rerun `task add` to resume.
|
|
115
115
|
|
|
116
116
|
## Status
|
|
117
117
|
|
|
@@ -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 (
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -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) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { gitFetchRemote, gitLeftRightCount, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, readGitInfo, } from "./git.js";
|
|
1
|
+
import { gitFetchRemote, gitGovernanceOnlyDirty, gitLeftRightCount, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitRevParse, gitSwitchToBranch, readGitInfo, restoreGovernanceStashAfterSwitch, stashGovernanceIfBlocking, } from "./git.js";
|
|
2
2
|
import { cyan, dim, log } from "./logger.js";
|
|
3
3
|
function remoteRef(remote, branch) {
|
|
4
4
|
return `${remote}/${branch}`;
|
|
@@ -6,6 +6,9 @@ function remoteRef(remote, branch) {
|
|
|
6
6
|
/**
|
|
7
7
|
* Classify why `git pull --ff-only` would fail for the integration branch.
|
|
8
8
|
* Call after fetch when possible so `origin/branch` is current.
|
|
9
|
+
*
|
|
10
|
+
* Governance-only dirt (`.vibeops.json`, docs/, `.vibeops/`, …) does **not**
|
|
11
|
+
* block — `task add` right after `init` must work.
|
|
9
12
|
*/
|
|
10
13
|
export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
|
|
11
14
|
if ((await gitRemoteUrl(cwd, remote)) === null) {
|
|
@@ -26,19 +29,28 @@ export async function diagnoseIntegrationSync(cwd, remote, integrationBranch) {
|
|
|
26
29
|
}
|
|
27
30
|
const git = await readGitInfo(cwd);
|
|
28
31
|
if (git.dirty === true) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
const gov = await gitGovernanceOnlyDirty(cwd);
|
|
33
|
+
if (!gov.onlyGovernance) {
|
|
34
|
+
const blocking = gov.nonGovernancePaths.slice(0, 8);
|
|
35
|
+
const more = gov.nonGovernancePaths.length > blocking.length
|
|
36
|
+
? ` (+${gov.nonGovernancePaths.length - blocking.length} more)`
|
|
37
|
+
: "";
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
kind: "dirty",
|
|
41
|
+
summary: `Working tree has app changes — cannot fast-forward ${integrationBranch}.`,
|
|
42
|
+
fixes: [
|
|
43
|
+
"git status",
|
|
44
|
+
`# Blocking (non-governance): ${blocking.join(", ")}${more}`,
|
|
45
|
+
"git stash push -u -m \"vibeops: before sync\"",
|
|
46
|
+
`git checkout ${integrationBranch}`,
|
|
47
|
+
`git pull --ff-only ${remote} ${integrationBranch}`,
|
|
48
|
+
"git stash pop # if you stashed",
|
|
49
|
+
"vibeops task add",
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Governance-only dirty (.vibeops.json after init, docs, …) — proceed.
|
|
42
54
|
}
|
|
43
55
|
const localSha = await gitRevParse(cwd, integrationBranch);
|
|
44
56
|
const remoteSha = await gitRevParse(cwd, remoteRef(remote, integrationBranch));
|
|
@@ -129,6 +141,7 @@ export function printIntegrationSyncDiagnosis(d) {
|
|
|
129
141
|
/**
|
|
130
142
|
* Fetch + ensure local integration can be used as the base for a new task branch.
|
|
131
143
|
* Switches to the integration branch, then `--ff-only` pull when needed. Never force-resets.
|
|
144
|
+
* Governance-only dirt is stashed around switch/pull so post-`init` `.vibeops.json` edits do not block.
|
|
132
145
|
*/
|
|
133
146
|
export async function ensureIntegrationSynced(opts) {
|
|
134
147
|
const { cwd, remote, integrationBranch } = opts;
|
|
@@ -184,6 +197,8 @@ export async function ensureIntegrationSynced(opts) {
|
|
|
184
197
|
if (localSha !== null && remoteSha !== null && localSha === remoteSha) {
|
|
185
198
|
return { ok: true, diagnosis, pulled: false };
|
|
186
199
|
}
|
|
200
|
+
// Stash governance dirt so ff-only pull cannot be blocked by `.vibeops.json` etc.
|
|
201
|
+
const stashed = await stashGovernanceIfBlocking(cwd);
|
|
187
202
|
try {
|
|
188
203
|
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
189
204
|
diagnosis = {
|
|
@@ -211,4 +226,7 @@ export async function ensureIntegrationSynced(opts) {
|
|
|
211
226
|
}
|
|
212
227
|
return { ok: false, diagnosis, pulled: false };
|
|
213
228
|
}
|
|
229
|
+
finally {
|
|
230
|
+
await restoreGovernanceStashAfterSwitch(cwd, stashed);
|
|
231
|
+
}
|
|
214
232
|
}
|
package/dist/lib/git.js
CHANGED
|
@@ -299,12 +299,17 @@ const GOVERNANCE_DOC_REL_PREFIXES = [
|
|
|
299
299
|
"docs/project/",
|
|
300
300
|
"docs/logs/",
|
|
301
301
|
".vibeops/",
|
|
302
|
+
".cursor/rules/",
|
|
302
303
|
];
|
|
304
|
+
/** Root files owned by vibeops init / config (not under `.vibeops/`). */
|
|
305
|
+
const GOVERNANCE_DOC_EXACT = new Set([".vibeops.json", "AGENTS.md"]);
|
|
303
306
|
function normalizeRepoRelPath(p) {
|
|
304
307
|
return p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
305
308
|
}
|
|
306
309
|
export function isGovernanceDocumentationPath(repoRelativePath) {
|
|
307
310
|
const n = normalizeRepoRelPath(repoRelativePath);
|
|
311
|
+
if (GOVERNANCE_DOC_EXACT.has(n))
|
|
312
|
+
return true;
|
|
308
313
|
for (const prefix of GOVERNANCE_DOC_REL_PREFIXES) {
|
|
309
314
|
if (n.startsWith(prefix))
|
|
310
315
|
return true;
|
|
@@ -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 (
|
|
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";
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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 (
|
|
221
|
-
|
|
217
|
+
else if (typeof v !== "string" || v.trim().length === 0) {
|
|
218
|
+
out.push(`\`${field}\` is missing or empty`);
|
|
222
219
|
}
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
259
|
+
const state = normalizeGlabMergeRequestState(parsed.state ?? "");
|
|
227
260
|
return {
|
|
228
|
-
state
|
|
229
|
-
mergedAt: parsed.
|
|
230
|
-
mergeCommitSha: parsed.
|
|
231
|
-
squashCommitSha: null,
|
|
232
|
-
mergeStatus:
|
|
233
|
-
detailedMergeStatus:
|
|
234
|
-
pipelineStatus,
|
|
235
|
-
hasConflicts:
|
|
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
|
-
|
|
239
|
-
|
|
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
|
-
/**
|
|
268
|
-
export
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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}
|
|
466
|
+
log.info(dim(`Waiting for ${label} checks: ${problems.join("; ")}…`));
|
|
282
467
|
loggedWait = true;
|
|
283
468
|
}
|
|
284
|
-
await
|
|
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
|
-
|
|
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
|
|
508
|
+
log.info(`would gh ${gitHubMergeArgs(ref, method, "<head sha>").join(" ")}`);
|
|
333
509
|
}
|
|
334
510
|
return;
|
|
335
511
|
}
|
|
336
|
-
|
|
337
|
-
await
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
361
|
-
cwd: input.cwd,
|
|
362
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
363
|
-
});
|
|
526
|
+
return gate.details;
|
|
364
527
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
await
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
}
|