@goodtek/vibeops 2.4.2 → 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 +19 -0
- package/dist/cli.js +5 -3
- package/dist/commands/task-merge.js +40 -3
- package/dist/commands/task-release.js +26 -7
- package/dist/commands/task-sync.js +34 -30
- package/dist/lib/git.js +14 -0
- package/dist/lib/merge-request-readiness.js +57 -0
- package/dist/lib/pr-create.js +142 -22
- package/dist/lib/task-merge-verify.js +23 -0
- package/dist/lib/task-sync-guard.js +113 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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
|
+
|
|
15
|
+
## 2.4.3 - 2026-06-25
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`task merge` / `task release`:** GitLab merges use `--auto-merge=false` so CI running no longer schedules auto-merge and reports success prematurely. Commands poll the host until `merged_at` is set before succeeding.
|
|
20
|
+
- **`task sync`:** Refuses branch cleanup when the MR is open, closed-not-merged, auto-merge pending, or integration branch does not contain task commits. `--force` bypasses merge verification (local branch delete still uses `-D` when needed).
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`task-sync-guard`:** MR lifecycle and integration-branch ancestry checks before `task sync` deletes branches.
|
|
25
|
+
|
|
7
26
|
## 2.4.2 - 2026-06-24
|
|
8
27
|
|
|
9
28
|
### 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", "
|
|
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
|
});
|
|
@@ -6,6 +6,7 @@ import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
|
6
6
|
import { projectPaths } from "../lib/paths.js";
|
|
7
7
|
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
8
8
|
import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
9
|
+
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
9
10
|
import { relPath } from "../lib/task-context.js";
|
|
10
11
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
11
12
|
import { resolveOpenTaskMergeRequest } from "../lib/task-effective-status.js";
|
|
@@ -101,7 +102,25 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
101
102
|
return;
|
|
102
103
|
}
|
|
103
104
|
try {
|
|
104
|
-
await mergeMergeRequest({
|
|
105
|
+
await mergeMergeRequest({
|
|
106
|
+
cwd,
|
|
107
|
+
host,
|
|
108
|
+
url: mergeRequestUrl,
|
|
109
|
+
method,
|
|
110
|
+
waitForCi: true,
|
|
111
|
+
immediate: true,
|
|
112
|
+
});
|
|
113
|
+
const verified = await assertMergeRequestMerged({
|
|
114
|
+
cwd,
|
|
115
|
+
host,
|
|
116
|
+
url: mergeRequestUrl,
|
|
117
|
+
integrationBranch: ctx.baseBranch,
|
|
118
|
+
});
|
|
119
|
+
if (!verified.ok) {
|
|
120
|
+
log.error(verified.message);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
105
124
|
log.ok(`${label} merged into ${ctx.baseBranch}.`);
|
|
106
125
|
}
|
|
107
126
|
catch (e) {
|
|
@@ -120,8 +139,26 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
120
139
|
else {
|
|
121
140
|
log.warn(`Could not read ${label} state — attempting merge anyway.`);
|
|
122
141
|
try {
|
|
123
|
-
await mergeMergeRequest({
|
|
124
|
-
|
|
142
|
+
await mergeMergeRequest({
|
|
143
|
+
cwd,
|
|
144
|
+
host,
|
|
145
|
+
url: mergeRequestUrl,
|
|
146
|
+
method,
|
|
147
|
+
waitForCi: true,
|
|
148
|
+
immediate: true,
|
|
149
|
+
});
|
|
150
|
+
const verified = await assertMergeRequestMerged({
|
|
151
|
+
cwd,
|
|
152
|
+
host,
|
|
153
|
+
url: mergeRequestUrl,
|
|
154
|
+
integrationBranch: ctx.baseBranch,
|
|
155
|
+
});
|
|
156
|
+
if (!verified.ok) {
|
|
157
|
+
log.error(verified.message);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
log.ok(`${label} merged into ${ctx.baseBranch}.`);
|
|
125
162
|
}
|
|
126
163
|
catch (e) {
|
|
127
164
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -4,12 +4,13 @@ import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
|
4
4
|
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 resolveReleaseMergeMethod(opts) {
|
|
8
9
|
if (opts.rebase === true)
|
|
9
10
|
return "rebase";
|
|
10
|
-
if (opts.
|
|
11
|
-
return "
|
|
12
|
-
return "
|
|
11
|
+
if (opts.squash === true)
|
|
12
|
+
return "squash";
|
|
13
|
+
return "merge";
|
|
13
14
|
}
|
|
14
15
|
export async function taskReleaseCommand(options = {}) {
|
|
15
16
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
@@ -50,7 +51,7 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
50
51
|
log.info(bold("dry-run — would:"));
|
|
51
52
|
log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
|
|
52
53
|
if (options.noMerge !== true) {
|
|
53
|
-
log.info(` · merge ${label} (${
|
|
54
|
+
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}, wait for CI)`);
|
|
54
55
|
}
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
@@ -89,9 +90,27 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
89
90
|
log.ok(`Release ${label} already merged.`);
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
|
-
const method =
|
|
93
|
+
const method = resolveReleaseMergeMethod(options);
|
|
93
94
|
try {
|
|
94
|
-
await mergeMergeRequest({
|
|
95
|
+
await mergeMergeRequest({
|
|
96
|
+
cwd,
|
|
97
|
+
host,
|
|
98
|
+
url: releaseUrl,
|
|
99
|
+
method,
|
|
100
|
+
waitForCi: true,
|
|
101
|
+
immediate: true,
|
|
102
|
+
});
|
|
103
|
+
const verified = await assertMergeRequestMerged({
|
|
104
|
+
cwd,
|
|
105
|
+
host,
|
|
106
|
+
url: releaseUrl,
|
|
107
|
+
integrationBranch: productionBranch,
|
|
108
|
+
});
|
|
109
|
+
if (!verified.ok) {
|
|
110
|
+
log.error(verified.message);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
95
114
|
log.ok(`Release ${label} merged into ${productionBranch}.`);
|
|
96
115
|
}
|
|
97
116
|
catch (e) {
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
-
import { detectGitHost
|
|
3
|
+
import { detectGitHost } from "../lib/git-host.js";
|
|
4
4
|
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitPullFastForwardOnly, gitRemoteBranchExists, gitRemoteUrl, gitSwitchToBranch, readGitInfo, } from "../lib/git.js";
|
|
5
|
-
import { bold, cyan, dim, log
|
|
6
|
-
import { probeMergeRequestCli } from "../lib/pr-create.js";
|
|
5
|
+
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
7
6
|
import { projectPaths } from "../lib/paths.js";
|
|
8
7
|
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
9
8
|
import { relPath } from "../lib/task-context.js";
|
|
10
|
-
import { getTaskMergeRequestLifecycle, isMergeRequestMerged, } from "../lib/task-effective-status.js";
|
|
11
9
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
10
|
+
import { checkTaskSyncReady } from "../lib/task-sync-guard.js";
|
|
12
11
|
import { readTaskFile } from "../lib/task.js";
|
|
13
12
|
function isProtectedBranch(name, integration, production) {
|
|
14
13
|
return name === integration || name === production || name === "main" || name === "master";
|
|
@@ -53,35 +52,16 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
53
52
|
log.info(` ${dim("file")} ${relPath(cwd, taskFile)}`);
|
|
54
53
|
log.info(` ${dim("note")} ${dim("Git cleanup only — TASK md stays Shipped")}`);
|
|
55
54
|
log.blank();
|
|
56
|
-
const meta = await readTaskFile(taskFile);
|
|
57
|
-
if (!dryRun) {
|
|
58
|
-
const lifecycle = await getTaskMergeRequestLifecycle(cwd, taskFile);
|
|
59
|
-
if (lifecycle !== "none") {
|
|
60
|
-
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
61
|
-
const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
62
|
-
? detectGitHost(remoteUrl)
|
|
63
|
-
: gitCfg.host;
|
|
64
|
-
const label = mergeRequestLabel(host);
|
|
65
|
-
if (lifecycle === "open") {
|
|
66
|
-
log.warn(`${yellow(label)} still open — merge with task merge or the host UI before sync.`);
|
|
67
|
-
}
|
|
68
|
-
else if (lifecycle === "merged") {
|
|
69
|
-
log.ok(`${label} merged.`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
else if (await probeMergeRequestCli(gitCfg.host)) {
|
|
73
|
-
log.warn("Could not read MR state — continuing with branch cleanup.");
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
else if (meta.status === "shipped" && !(await isMergeRequestMerged(cwd, meta))) {
|
|
77
|
-
log.warn(`${yellow("MR")} not merged yet — merge on the host or \`task merge\`, then rerun task sync.`);
|
|
78
|
-
}
|
|
79
55
|
if (dryRun) {
|
|
56
|
+
const meta = await readTaskFile(taskFile);
|
|
57
|
+
if (meta.status === "shipped" && options.force !== true) {
|
|
58
|
+
log.info(dim("Would verify MR is merged and integration branch contains task commits before cleanup."));
|
|
59
|
+
}
|
|
80
60
|
log.info(bold("dry-run — would:"));
|
|
81
61
|
log.info(` · git fetch ${remote} --prune`);
|
|
82
62
|
log.info(` · git switch ${integrationBranch}`);
|
|
83
63
|
log.info(` · git pull --ff-only ${remote} ${integrationBranch}`);
|
|
84
|
-
log.info(` · git branch
|
|
64
|
+
log.info(` · git branch -D ${taskBranch}`);
|
|
85
65
|
if (options.noRemoteDelete !== true) {
|
|
86
66
|
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
87
67
|
}
|
|
@@ -129,15 +109,39 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
129
109
|
else {
|
|
130
110
|
log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
|
|
131
111
|
}
|
|
112
|
+
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
113
|
+
const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
114
|
+
? detectGitHost(remoteUrl)
|
|
115
|
+
: gitCfg.host;
|
|
116
|
+
const syncGuard = await checkTaskSyncReady({
|
|
117
|
+
cwd,
|
|
118
|
+
taskFile,
|
|
119
|
+
remote,
|
|
120
|
+
integrationBranch,
|
|
121
|
+
taskBranch,
|
|
122
|
+
host,
|
|
123
|
+
force: options.force === true,
|
|
124
|
+
});
|
|
125
|
+
if (!syncGuard.ok) {
|
|
126
|
+
log.error(syncGuard.message ?? "Refusing task sync — merge checks failed.");
|
|
127
|
+
if (options.force !== true) {
|
|
128
|
+
log.info(dim("Use --force only if you intentionally want to delete the task branch without merge verification."));
|
|
129
|
+
}
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
|
|
132
134
|
if (await gitBranchExists(cwd, taskBranch)) {
|
|
133
135
|
try {
|
|
134
|
-
|
|
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 });
|
|
135
139
|
log.ok(`Deleted local branch ${taskBranch}`);
|
|
136
140
|
}
|
|
137
141
|
catch (e) {
|
|
138
142
|
const msg = e instanceof Error ? e.message : String(e);
|
|
139
143
|
log.error(`Could not delete local ${taskBranch}: ${msg}`);
|
|
140
|
-
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)."));
|
|
141
145
|
process.exitCode = 1;
|
|
142
146
|
return;
|
|
143
147
|
}
|
package/dist/lib/git.js
CHANGED
|
@@ -79,6 +79,20 @@ export async function gitHeadCommit(cwd, short = true) {
|
|
|
79
79
|
const res = await tryGit(cwd, short ? ["rev-parse", "--short", "HEAD"] : ["rev-parse", "HEAD"]);
|
|
80
80
|
return res ? res.stdout.trim() : null;
|
|
81
81
|
}
|
|
82
|
+
export async function gitRevParse(cwd, ref) {
|
|
83
|
+
const res = await tryGit(cwd, ["rev-parse", ref]);
|
|
84
|
+
return res ? res.stdout.trim() : null;
|
|
85
|
+
}
|
|
86
|
+
/** True when `ancestor` is contained in `descendant`'s history. */
|
|
87
|
+
export async function gitIsAncestor(cwd, ancestor, descendant) {
|
|
88
|
+
try {
|
|
89
|
+
await runGit(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
82
96
|
export async function gitBranchExists(cwd, name) {
|
|
83
97
|
const res = await tryGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${name}`]);
|
|
84
98
|
return res !== null;
|
|
@@ -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")
|
|
@@ -187,33 +189,105 @@ function prRefFromUrl(url) {
|
|
|
187
189
|
return prNumberFromUrl(url) ?? url.trim();
|
|
188
190
|
}
|
|
189
191
|
export async function getMergeRequestState(cwd, host, url) {
|
|
192
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
193
|
+
return details?.state ?? "unknown";
|
|
194
|
+
}
|
|
195
|
+
export async function getMergeRequestDetails(cwd, host, url) {
|
|
190
196
|
const ref = prRefFromUrl(url);
|
|
191
197
|
try {
|
|
192
198
|
if (host === "gitlab") {
|
|
193
199
|
const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "-F", "json"], { cwd, maxBuffer: 1024 * 1024 });
|
|
194
200
|
const parsed = JSON.parse(stdout.trim());
|
|
195
|
-
const state = (parsed.state ?? "")
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
const state = normalizeGlabMergeRequestState(parsed.state ?? "");
|
|
202
|
+
return {
|
|
203
|
+
state,
|
|
204
|
+
mergedAt: parsed.merged_at ?? null,
|
|
205
|
+
mergeCommitSha: parsed.merge_commit_sha ?? null,
|
|
206
|
+
squashCommitSha: parsed.squash_commit_sha ?? null,
|
|
207
|
+
mergeStatus: parsed.merge_status ?? null,
|
|
208
|
+
detailedMergeStatus: parsed.detailed_merge_status ?? null,
|
|
209
|
+
pipelineStatus: pipelineStatusFromHost(parsed.head_pipeline?.status),
|
|
210
|
+
hasConflicts: parsed.has_conflicts ?? null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit,mergeable,statusCheckRollup"], { cwd, maxBuffer: 1024 * 1024 });
|
|
214
|
+
const parsed = JSON.parse(stdout.trim());
|
|
215
|
+
const checks = parsed.statusCheckRollup ?? [];
|
|
216
|
+
let pipelineStatus = "none";
|
|
217
|
+
if (checks.some((c) => (c.state ?? "").toUpperCase() === "PENDING")) {
|
|
218
|
+
pipelineStatus = "running";
|
|
219
|
+
}
|
|
220
|
+
else if (checks.some((c) => (c.state ?? "").toUpperCase() === "FAILURE")) {
|
|
221
|
+
pipelineStatus = "failed";
|
|
203
222
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
223
|
+
else if (checks.length > 0) {
|
|
224
|
+
pipelineStatus = "success";
|
|
225
|
+
}
|
|
226
|
+
const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
|
|
227
|
+
return {
|
|
228
|
+
state: normalizeGhMergeRequestState(parsed.state ?? ""),
|
|
229
|
+
mergedAt: parsed.mergedAt ?? null,
|
|
230
|
+
mergeCommitSha: parsed.mergeCommit?.oid ?? null,
|
|
231
|
+
squashCommitSha: null,
|
|
232
|
+
mergeStatus: mergeable === "MERGEABLE" ? "can_be_merged" : mergeable.toLowerCase(),
|
|
233
|
+
detailedMergeStatus: mergeable.toLowerCase(),
|
|
234
|
+
pipelineStatus,
|
|
235
|
+
hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
|
|
236
|
+
};
|
|
213
237
|
}
|
|
214
238
|
catch {
|
|
215
|
-
return
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Poll host until MR/PR state is merged (or timeout). */
|
|
243
|
+
export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
|
|
244
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
245
|
+
const intervalMs = options.intervalMs ?? 2_000;
|
|
246
|
+
const deadline = Date.now() + timeoutMs;
|
|
247
|
+
while (Date.now() < deadline) {
|
|
248
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
249
|
+
if (details?.state === "merged" && details.mergedAt !== null) {
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
253
|
+
}
|
|
254
|
+
return false;
|
|
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));
|
|
216
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");
|
|
217
291
|
}
|
|
218
292
|
export async function closeMergeRequest(opts) {
|
|
219
293
|
const ref = prRefFromUrl(opts.url);
|
|
@@ -238,9 +312,21 @@ export async function mergeMergeRequest(opts) {
|
|
|
238
312
|
const ref = prRefFromUrl(opts.url);
|
|
239
313
|
const method = opts.method ?? "squash";
|
|
240
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;
|
|
241
324
|
if (opts.dryRun === true) {
|
|
242
325
|
if (opts.host === "gitlab") {
|
|
243
|
-
|
|
326
|
+
const immediate = opts.immediate !== false && !pipelineActive;
|
|
327
|
+
const flags = immediate ? " --auto-merge=false" : " (auto-merge when pipeline succeeds)";
|
|
328
|
+
const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
|
|
329
|
+
log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
|
|
244
330
|
}
|
|
245
331
|
else {
|
|
246
332
|
log.info(`would gh pr merge ${ref} --${method}`);
|
|
@@ -248,12 +334,46 @@ export async function mergeMergeRequest(opts) {
|
|
|
248
334
|
return;
|
|
249
335
|
}
|
|
250
336
|
if (opts.host === "gitlab") {
|
|
251
|
-
await
|
|
337
|
+
await mergeGitLabMergeRequest({
|
|
252
338
|
cwd: opts.cwd,
|
|
253
|
-
|
|
339
|
+
ref,
|
|
340
|
+
method,
|
|
341
|
+
immediate: opts.immediate !== false && !pipelineActive,
|
|
254
342
|
});
|
|
255
343
|
return;
|
|
256
344
|
}
|
|
257
345
|
const args = ["pr", "merge", ref, `--${method}`];
|
|
258
346
|
await execFileAsync("gh", args, { cwd: opts.cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
259
347
|
}
|
|
348
|
+
async function mergeGitLabMergeRequest(input) {
|
|
349
|
+
const attempt = async (immediate) => {
|
|
350
|
+
const args = ["mr", "merge", input.ref];
|
|
351
|
+
if (immediate) {
|
|
352
|
+
args.push("--auto-merge=false");
|
|
353
|
+
}
|
|
354
|
+
if (input.method === "squash") {
|
|
355
|
+
args.push("--squash");
|
|
356
|
+
}
|
|
357
|
+
else if (input.method === "rebase") {
|
|
358
|
+
args.push("--rebase");
|
|
359
|
+
}
|
|
360
|
+
await execFileAsync("glab", args, {
|
|
361
|
+
cwd: input.cwd,
|
|
362
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
363
|
+
});
|
|
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;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mergeRequestLabel } from "./git-host.js";
|
|
2
|
+
import { getMergeRequestDetails, waitForMergeRequestMerged, } from "./pr-create.js";
|
|
3
|
+
export async function assertMergeRequestMerged(input) {
|
|
4
|
+
const label = mergeRequestLabel(input.host);
|
|
5
|
+
if (input.wait !== false) {
|
|
6
|
+
const merged = await waitForMergeRequestMerged(input.cwd, input.host, input.url);
|
|
7
|
+
if (!merged) {
|
|
8
|
+
return {
|
|
9
|
+
ok: false,
|
|
10
|
+
message: `${label} merge did not complete (still open or auto-merge pending). Wait for Merged on the host, or rerun \`vibeops task merge\`.`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const details = await getMergeRequestDetails(input.cwd, input.host, input.url);
|
|
15
|
+
if (details?.state !== "merged" || details.mergedAt === null) {
|
|
16
|
+
const state = details?.state ?? "unknown";
|
|
17
|
+
return {
|
|
18
|
+
ok: false,
|
|
19
|
+
message: `${label} is ${state}, not merged into ${input.integrationBranch}. Do not run task sync until the host shows Merged.`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { ok: true };
|
|
23
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mergeRequestLabel } from "./git-host.js";
|
|
2
|
+
import { gitIsAncestor, gitRemoteBranchExists, gitRevParse } from "./git.js";
|
|
3
|
+
import { getMergeRequestDetails } from "./pr-create.js";
|
|
4
|
+
import { resolveTaskMergeRequestLifecycle } from "./task-effective-status.js";
|
|
5
|
+
import { readGitContext } from "./task.js";
|
|
6
|
+
export function mrLifecycleBlocksSync(lifecycle) {
|
|
7
|
+
if (lifecycle === "open")
|
|
8
|
+
return "mr-open";
|
|
9
|
+
if (lifecycle === "closed")
|
|
10
|
+
return "mr-closed-not-merged";
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
function integrationRef(remote, branch) {
|
|
14
|
+
return `${remote}/${branch}`;
|
|
15
|
+
}
|
|
16
|
+
async function resolveMergedCommitOnIntegration(cwd, host, mrUrl, remote, integrationBranch, taskBranch) {
|
|
17
|
+
const details = await getMergeRequestDetails(cwd, host, mrUrl);
|
|
18
|
+
const fromMr = details?.squashCommitSha?.trim() ||
|
|
19
|
+
details?.mergeCommitSha?.trim() ||
|
|
20
|
+
null;
|
|
21
|
+
if (fromMr)
|
|
22
|
+
return fromMr;
|
|
23
|
+
const taskRemoteRef = integrationRef(remote, taskBranch);
|
|
24
|
+
if (await gitRemoteBranchExists(cwd, remote, taskBranch)) {
|
|
25
|
+
return gitRevParse(cwd, taskRemoteRef);
|
|
26
|
+
}
|
|
27
|
+
const integrationSha = await gitRevParse(cwd, integrationRef(remote, integrationBranch));
|
|
28
|
+
return integrationSha;
|
|
29
|
+
}
|
|
30
|
+
async function integrationContainsTaskWork(cwd, remote, integrationBranch, taskBranch, host, mrUrl, lifecycle) {
|
|
31
|
+
const target = integrationRef(remote, integrationBranch);
|
|
32
|
+
const targetSha = await gitRevParse(cwd, target);
|
|
33
|
+
if (targetSha === null)
|
|
34
|
+
return false;
|
|
35
|
+
if (lifecycle === "merged" && mrUrl !== null && host !== null) {
|
|
36
|
+
const mergedCommit = await resolveMergedCommitOnIntegration(cwd, host, mrUrl, remote, integrationBranch, taskBranch);
|
|
37
|
+
if (mergedCommit !== null) {
|
|
38
|
+
return gitIsAncestor(cwd, mergedCommit, target);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const taskRemoteRef = integrationRef(remote, taskBranch);
|
|
42
|
+
if (await gitRemoteBranchExists(cwd, remote, taskBranch)) {
|
|
43
|
+
const taskTip = await gitRevParse(cwd, taskRemoteRef);
|
|
44
|
+
if (taskTip !== null) {
|
|
45
|
+
return gitIsAncestor(cwd, taskTip, target);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// No remote task branch and no merged commit to verify — allow cleanup only when MR is merged.
|
|
49
|
+
return lifecycle === "merged";
|
|
50
|
+
}
|
|
51
|
+
export async function checkTaskSyncReady(input) {
|
|
52
|
+
if (input.force === true) {
|
|
53
|
+
return { ok: true };
|
|
54
|
+
}
|
|
55
|
+
const lifecycleInfo = await resolveTaskMergeRequestLifecycle(input.cwd, input.taskFile);
|
|
56
|
+
const lifecycle = lifecycleInfo.state;
|
|
57
|
+
const mrUrl = lifecycleInfo.url;
|
|
58
|
+
const host = lifecycleInfo.host ?? input.host;
|
|
59
|
+
const label = mergeRequestLabel(host ?? "github");
|
|
60
|
+
const lifecycleBlock = mrLifecycleBlocksSync(lifecycle);
|
|
61
|
+
if (lifecycleBlock === "mr-open") {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
reason: "mr-open",
|
|
65
|
+
message: `${label} is still open. Run \`vibeops task merge\` (or merge in the host UI) and confirm it shows Merged before sync.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (lifecycleBlock === "mr-closed-not-merged") {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: "mr-closed-not-merged",
|
|
72
|
+
message: `${label} is closed but not merged. Re-open or recreate the MR and merge into ${input.integrationBranch} before sync.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (lifecycle === "unknown" && mrUrl !== null) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: "mr-state-unknown",
|
|
79
|
+
message: `Could not verify ${label} state. Merge on the host, then rerun sync (or use --force to skip checks).`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (lifecycle === "merged") {
|
|
83
|
+
if (mrUrl !== null && host !== null) {
|
|
84
|
+
const details = await getMergeRequestDetails(input.cwd, host, mrUrl);
|
|
85
|
+
if (details?.state !== "merged" || details.mergedAt === null) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "mr-not-merged",
|
|
89
|
+
message: `${label} is not merged yet (auto-merge may still be pending). Wait for Merged on the host, then rerun sync.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (lifecycle === "none") {
|
|
95
|
+
const ctx = await readGitContext(input.taskFile);
|
|
96
|
+
if (ctx === null) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: "mr-state-unknown",
|
|
100
|
+
message: "No Git Context on TASK — cannot verify merge before sync.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const integrated = await integrationContainsTaskWork(input.cwd, input.remote, input.integrationBranch, input.taskBranch, host, mrUrl, lifecycle);
|
|
105
|
+
if (!integrated) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: "integration-not-updated",
|
|
109
|
+
message: `${input.integrationBranch} does not contain the task branch commits yet. Merge the ${label} into ${input.integrationBranch} before sync.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|