@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 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 checks and delete local task branch with -D if not fully merged")
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("--merge", "Merge commit (instead of squash)")
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({ cwd, host, url: mergeRequestUrl, method, immediate: true });
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({ cwd, host, url: mergeRequestUrl, method, immediate: true });
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 resolveMergeMethod(opts) {
8
+ function resolveReleaseMergeMethod(opts) {
9
9
  if (opts.rebase === true)
10
10
  return "rebase";
11
- if (opts.merge === true)
12
- return "merge";
13
- return "squash";
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} (${resolveMergeMethod(options)})`);
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 = resolveMergeMethod(options);
93
+ const method = resolveReleaseMergeMethod(options);
94
94
  try {
95
- await mergeMergeRequest({ cwd, host, url: releaseUrl, method, immediate: true });
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 ${options.force === true ? "-D" : "-d"} ${taskBranch}`);
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
- await gitDeleteBranch(cwd, taskBranch, { force: options.force === true });
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 (discards unmerged branch)."));
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
+ }
@@ -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 { log } from "./logger.js";
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
- const args = ["mr", "merge", ref];
271
- if (opts.immediate !== false) {
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: opts.cwd,
361
+ cwd: input.cwd,
282
362
  maxBuffer: 4 * 1024 * 1024,
283
363
  });
284
- return;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "2.4.3",
3
+ "version": "2.4.4",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "author": "VibeOps contributors",