@goodtek/vibeops 2.4.3 → 2.4.4
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 +8 -0
- package/dist/cli.js +5 -3
- package/dist/commands/task-merge.js +16 -2
- package/dist/commands/task-release.js +14 -7
- package/dist/commands/task-sync.js +5 -3
- package/dist/lib/merge-request-readiness.js +57 -0
- package/dist/lib/pr-create.js +103 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.4.4 - 2026-06-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`task sync`:** After merge verification passes, delete the local `task/*` branch with `-D`. Squash-merged TASK branches no longer require `--force` for cleanup.
|
|
12
|
+
- **`task merge` / `task release`:** Wait for MR/PR CI to finish before calling the host merge API. GitLab no longer gets an immediate `--auto-merge=false` merge while the pipeline is still running (405). Falls back to auto-merge when needed, then polls until `merged_at` is set.
|
|
13
|
+
- **`task release`:** Default merge strategy is now **merge commit** (integration → production). Use `--squash` to squash.
|
|
14
|
+
|
|
7
15
|
## 2.4.3 - 2026-06-25
|
|
8
16
|
|
|
9
17
|
### Fixed
|
package/dist/cli.js
CHANGED
|
@@ -165,7 +165,7 @@ task
|
|
|
165
165
|
.description("After MR merge: integration pull and delete task branch (TASK md unchanged)")
|
|
166
166
|
.option("--dry-run", "Plan only")
|
|
167
167
|
.option("--no-remote-delete", "Keep the task branch on the remote")
|
|
168
|
-
.option("--force", "Bypass MR merge
|
|
168
|
+
.option("--force", "Bypass MR merge verification before branch cleanup")
|
|
169
169
|
.option("--cwd <path>", "Target directory")
|
|
170
170
|
.action(async (taskRef, opts) => {
|
|
171
171
|
await taskSyncCommand(taskRef, {
|
|
@@ -177,10 +177,11 @@ task
|
|
|
177
177
|
});
|
|
178
178
|
task
|
|
179
179
|
.command("release")
|
|
180
|
-
.description("Open (and merge) release MR/PR: integration branch → production")
|
|
180
|
+
.description("Open (and merge) release MR/PR: integration branch → production (default: merge commit)")
|
|
181
181
|
.option("--dry-run", "Plan only")
|
|
182
182
|
.option("--no-merge", "Create release PR only")
|
|
183
|
-
.option("--
|
|
183
|
+
.option("--squash", "Squash merge (default is merge commit for release)")
|
|
184
|
+
.option("--merge", "Merge commit (default for release)")
|
|
184
185
|
.option("--rebase", "Rebase merge")
|
|
185
186
|
.option("--cwd <path>", "Target directory")
|
|
186
187
|
.action(async (opts) => {
|
|
@@ -188,6 +189,7 @@ task
|
|
|
188
189
|
dryRun: Boolean(opts.dryRun),
|
|
189
190
|
noMerge: Boolean(opts.noMerge),
|
|
190
191
|
merge: Boolean(opts.merge),
|
|
192
|
+
squash: Boolean(opts.squash),
|
|
191
193
|
rebase: Boolean(opts.rebase),
|
|
192
194
|
cwd: opts.cwd,
|
|
193
195
|
});
|
|
@@ -102,7 +102,14 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
try {
|
|
105
|
-
await mergeMergeRequest({
|
|
105
|
+
await mergeMergeRequest({
|
|
106
|
+
cwd,
|
|
107
|
+
host,
|
|
108
|
+
url: mergeRequestUrl,
|
|
109
|
+
method,
|
|
110
|
+
waitForCi: true,
|
|
111
|
+
immediate: true,
|
|
112
|
+
});
|
|
106
113
|
const verified = await assertMergeRequestMerged({
|
|
107
114
|
cwd,
|
|
108
115
|
host,
|
|
@@ -132,7 +139,14 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
132
139
|
else {
|
|
133
140
|
log.warn(`Could not read ${label} state — attempting merge anyway.`);
|
|
134
141
|
try {
|
|
135
|
-
await mergeMergeRequest({
|
|
142
|
+
await mergeMergeRequest({
|
|
143
|
+
cwd,
|
|
144
|
+
host,
|
|
145
|
+
url: mergeRequestUrl,
|
|
146
|
+
method,
|
|
147
|
+
waitForCi: true,
|
|
148
|
+
immediate: true,
|
|
149
|
+
});
|
|
136
150
|
const verified = await assertMergeRequestMerged({
|
|
137
151
|
cwd,
|
|
138
152
|
host,
|
|
@@ -5,12 +5,12 @@ import { gitRemoteUrl } from "../lib/git.js";
|
|
|
5
5
|
import { bold, dim, log } from "../lib/logger.js";
|
|
6
6
|
import { createMergeRequest, getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
7
7
|
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
8
|
-
function
|
|
8
|
+
function resolveReleaseMergeMethod(opts) {
|
|
9
9
|
if (opts.rebase === true)
|
|
10
10
|
return "rebase";
|
|
11
|
-
if (opts.
|
|
12
|
-
return "
|
|
13
|
-
return "
|
|
11
|
+
if (opts.squash === true)
|
|
12
|
+
return "squash";
|
|
13
|
+
return "merge";
|
|
14
14
|
}
|
|
15
15
|
export async function taskReleaseCommand(options = {}) {
|
|
16
16
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
@@ -51,7 +51,7 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
51
51
|
log.info(bold("dry-run — would:"));
|
|
52
52
|
log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
|
|
53
53
|
if (options.noMerge !== true) {
|
|
54
|
-
log.info(` · merge ${label} (${
|
|
54
|
+
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}, wait for CI)`);
|
|
55
55
|
}
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
@@ -90,9 +90,16 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
90
90
|
log.ok(`Release ${label} already merged.`);
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
const method =
|
|
93
|
+
const method = resolveReleaseMergeMethod(options);
|
|
94
94
|
try {
|
|
95
|
-
await mergeMergeRequest({
|
|
95
|
+
await mergeMergeRequest({
|
|
96
|
+
cwd,
|
|
97
|
+
host,
|
|
98
|
+
url: releaseUrl,
|
|
99
|
+
method,
|
|
100
|
+
waitForCi: true,
|
|
101
|
+
immediate: true,
|
|
102
|
+
});
|
|
96
103
|
const verified = await assertMergeRequestMerged({
|
|
97
104
|
cwd,
|
|
98
105
|
host,
|
|
@@ -61,7 +61,7 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
61
61
|
log.info(` · git fetch ${remote} --prune`);
|
|
62
62
|
log.info(` · git switch ${integrationBranch}`);
|
|
63
63
|
log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
|
|
64
|
-
log.info(` · git branch
|
|
64
|
+
log.info(` · git branch -D ${taskBranch}`);
|
|
65
65
|
if (options.noRemoteDelete !== true) {
|
|
66
66
|
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
67
67
|
}
|
|
@@ -133,13 +133,15 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
133
133
|
log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
|
|
134
134
|
if (await gitBranchExists(cwd, taskBranch)) {
|
|
135
135
|
try {
|
|
136
|
-
|
|
136
|
+
// Guard verified integration contains the work — squash merges leave task/* SHAs
|
|
137
|
+
// off develop ancestry, so -D is required even without --force.
|
|
138
|
+
await gitDeleteBranch(cwd, taskBranch, { force: true });
|
|
137
139
|
log.ok(`Deleted local branch ${taskBranch}`);
|
|
138
140
|
}
|
|
139
141
|
catch (e) {
|
|
140
142
|
const msg = e instanceof Error ? e.message : String(e);
|
|
141
143
|
log.error(`Could not delete local ${taskBranch}: ${msg}`);
|
|
142
|
-
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (
|
|
144
|
+
log.info(dim("Merge the MR into the integration branch first, or rerun with --force (skip merge verification)."));
|
|
143
145
|
process.exitCode = 1;
|
|
144
146
|
return;
|
|
145
147
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const ACTIVE_PIPELINE_STATUSES = new Set([
|
|
2
|
+
"pending",
|
|
3
|
+
"running",
|
|
4
|
+
"created",
|
|
5
|
+
"waiting_for_resource",
|
|
6
|
+
"preparing",
|
|
7
|
+
]);
|
|
8
|
+
const TERMINAL_PIPELINE_FAILURES = new Set(["failed", "canceled"]);
|
|
9
|
+
function normalizePipelineStatus(raw) {
|
|
10
|
+
if (raw === null || raw === undefined || raw.trim().length === 0)
|
|
11
|
+
return "none";
|
|
12
|
+
const status = raw.trim().toLowerCase();
|
|
13
|
+
if (status === "success")
|
|
14
|
+
return "success";
|
|
15
|
+
if (status === "skipped")
|
|
16
|
+
return "skipped";
|
|
17
|
+
if (status === "failed")
|
|
18
|
+
return "failed";
|
|
19
|
+
if (status === "canceled" || status === "cancelled")
|
|
20
|
+
return "canceled";
|
|
21
|
+
if (ACTIVE_PIPELINE_STATUSES.has(status))
|
|
22
|
+
return "running";
|
|
23
|
+
return "unknown";
|
|
24
|
+
}
|
|
25
|
+
export function pipelineStatusFromHost(raw) {
|
|
26
|
+
return normalizePipelineStatus(raw);
|
|
27
|
+
}
|
|
28
|
+
export function isPipelineActive(status) {
|
|
29
|
+
return status === "running" || status === "pending";
|
|
30
|
+
}
|
|
31
|
+
/** True when the host reports the MR can be merged now (CI done or not required). */
|
|
32
|
+
export function isMergeRequestReadyToMerge(readiness) {
|
|
33
|
+
if (readiness.state === "merged")
|
|
34
|
+
return true;
|
|
35
|
+
if (readiness.state !== "open")
|
|
36
|
+
return false;
|
|
37
|
+
if (readiness.hasConflicts === true)
|
|
38
|
+
return false;
|
|
39
|
+
const detailed = readiness.detailedMergeStatus?.trim().toLowerCase() ?? "";
|
|
40
|
+
if (detailed === "conflict" ||
|
|
41
|
+
detailed === "not_approved" ||
|
|
42
|
+
detailed === "draft_status" ||
|
|
43
|
+
detailed === "ci_still_running" ||
|
|
44
|
+
detailed === "checking" ||
|
|
45
|
+
detailed === "not_open" ||
|
|
46
|
+
detailed === "discussions_not_resolved" ||
|
|
47
|
+
detailed === "need_rebase" ||
|
|
48
|
+
detailed === "blocked_status") {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (TERMINAL_PIPELINE_FAILURES.has(readiness.pipelineStatus))
|
|
52
|
+
return false;
|
|
53
|
+
if (isPipelineActive(readiness.pipelineStatus))
|
|
54
|
+
return false;
|
|
55
|
+
const mergeStatus = readiness.mergeStatus?.trim().toLowerCase() ?? "";
|
|
56
|
+
return mergeStatus === "can_be_merged" || detailed === "mergeable";
|
|
57
|
+
}
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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 { isMergeRequestReadyToMerge, isPipelineActive, pipelineStatusFromHost, } from "./merge-request-readiness.js";
|
|
5
|
+
import { dim, log } from "./logger.js";
|
|
5
6
|
const execFileAsync = promisify(execFile);
|
|
6
7
|
async function ghPrCreate(opts) {
|
|
7
8
|
const { stdout } = await execFileAsync("gh", [
|
|
@@ -64,6 +65,7 @@ export async function probeMergeRequestCli(host) {
|
|
|
64
65
|
return false;
|
|
65
66
|
}
|
|
66
67
|
}
|
|
68
|
+
export { isMergeRequestReadyToMerge, isPipelineActive };
|
|
67
69
|
function normalizeGhMergeRequestState(raw) {
|
|
68
70
|
const state = raw.trim().toUpperCase();
|
|
69
71
|
if (state === "MERGED")
|
|
@@ -202,15 +204,35 @@ export async function getMergeRequestDetails(cwd, host, url) {
|
|
|
202
204
|
mergedAt: parsed.merged_at ?? null,
|
|
203
205
|
mergeCommitSha: parsed.merge_commit_sha ?? null,
|
|
204
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,
|
|
205
211
|
};
|
|
206
212
|
}
|
|
207
|
-
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit"], { cwd, maxBuffer: 1024 * 1024 });
|
|
213
|
+
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit,mergeable,statusCheckRollup"], { cwd, maxBuffer: 1024 * 1024 });
|
|
208
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";
|
|
219
|
+
}
|
|
220
|
+
else if (checks.some((c) => (c.state ?? "").toUpperCase() === "FAILURE")) {
|
|
221
|
+
pipelineStatus = "failed";
|
|
222
|
+
}
|
|
223
|
+
else if (checks.length > 0) {
|
|
224
|
+
pipelineStatus = "success";
|
|
225
|
+
}
|
|
226
|
+
const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
|
|
209
227
|
return {
|
|
210
228
|
state: normalizeGhMergeRequestState(parsed.state ?? ""),
|
|
211
229
|
mergedAt: parsed.mergedAt ?? null,
|
|
212
230
|
mergeCommitSha: parsed.mergeCommit?.oid ?? null,
|
|
213
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,
|
|
214
236
|
};
|
|
215
237
|
}
|
|
216
238
|
catch {
|
|
@@ -231,6 +253,42 @@ export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
|
|
|
231
253
|
}
|
|
232
254
|
return false;
|
|
233
255
|
}
|
|
256
|
+
function mergeRequestReadinessFromDetails(details) {
|
|
257
|
+
if (details === null)
|
|
258
|
+
return null;
|
|
259
|
+
return {
|
|
260
|
+
state: details.state,
|
|
261
|
+
mergeStatus: details.mergeStatus,
|
|
262
|
+
detailedMergeStatus: details.detailedMergeStatus,
|
|
263
|
+
pipelineStatus: details.pipelineStatus,
|
|
264
|
+
hasConflicts: details.hasConflicts,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
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;
|
|
272
|
+
const label = mergeRequestLabel(host);
|
|
273
|
+
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;
|
|
279
|
+
}
|
|
280
|
+
if (!loggedWait) {
|
|
281
|
+
log.info(dim(`Waiting for ${label} pipeline / merge checks…`));
|
|
282
|
+
loggedWait = true;
|
|
283
|
+
}
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function isMergeHttp405(error) {
|
|
289
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
290
|
+
return msg.includes("405") || msg.toLowerCase().includes("method not allowed");
|
|
291
|
+
}
|
|
234
292
|
export async function closeMergeRequest(opts) {
|
|
235
293
|
const ref = prRefFromUrl(opts.url);
|
|
236
294
|
const label = mergeRequestLabel(opts.host);
|
|
@@ -254,10 +312,19 @@ export async function mergeMergeRequest(opts) {
|
|
|
254
312
|
const ref = prRefFromUrl(opts.url);
|
|
255
313
|
const method = opts.method ?? "squash";
|
|
256
314
|
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;
|
|
257
324
|
if (opts.dryRun === true) {
|
|
258
325
|
if (opts.host === "gitlab") {
|
|
259
|
-
const immediate = opts.immediate !== false;
|
|
260
|
-
const flags = immediate ? " --auto-merge=false" : "";
|
|
326
|
+
const immediate = opts.immediate !== false && !pipelineActive;
|
|
327
|
+
const flags = immediate ? " --auto-merge=false" : " (auto-merge when pipeline succeeds)";
|
|
261
328
|
const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
|
|
262
329
|
log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
|
|
263
330
|
}
|
|
@@ -267,22 +334,46 @@ export async function mergeMergeRequest(opts) {
|
|
|
267
334
|
return;
|
|
268
335
|
}
|
|
269
336
|
if (opts.host === "gitlab") {
|
|
270
|
-
|
|
271
|
-
|
|
337
|
+
await mergeGitLabMergeRequest({
|
|
338
|
+
cwd: opts.cwd,
|
|
339
|
+
ref,
|
|
340
|
+
method,
|
|
341
|
+
immediate: opts.immediate !== false && !pipelineActive,
|
|
342
|
+
});
|
|
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) {
|
|
272
352
|
args.push("--auto-merge=false");
|
|
273
353
|
}
|
|
274
|
-
if (method === "squash") {
|
|
354
|
+
if (input.method === "squash") {
|
|
275
355
|
args.push("--squash");
|
|
276
356
|
}
|
|
277
|
-
else if (method === "rebase") {
|
|
357
|
+
else if (input.method === "rebase") {
|
|
278
358
|
args.push("--rebase");
|
|
279
359
|
}
|
|
280
360
|
await execFileAsync("glab", args, {
|
|
281
|
-
cwd:
|
|
361
|
+
cwd: input.cwd,
|
|
282
362
|
maxBuffer: 4 * 1024 * 1024,
|
|
283
363
|
});
|
|
284
|
-
|
|
364
|
+
};
|
|
365
|
+
try {
|
|
366
|
+
await attempt(input.immediate);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
if (!input.immediate && isMergeHttp405(error)) {
|
|
370
|
+
await attempt(false);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (input.immediate && isMergeHttp405(error)) {
|
|
374
|
+
await attempt(false);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
throw error;
|
|
285
378
|
}
|
|
286
|
-
const args = ["pr", "merge", ref, `--${method}`];
|
|
287
|
-
await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
288
379
|
}
|