@goodtek/vibeops 2.4.1 → 2.4.3
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 +17 -0
- package/dist/cli.js +1 -1
- package/dist/commands/task-merge.js +26 -3
- package/dist/commands/task-release.js +13 -1
- package/dist/commands/task-sync.js +29 -27
- package/dist/lib/git.js +14 -0
- package/dist/lib/pr-create.js +68 -29
- 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,23 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 2.4.3 - 2026-06-25
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **`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).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`task-sync-guard`:** MR lifecycle and integration-branch ancestry checks before `task sync` deletes branches.
|
|
17
|
+
|
|
18
|
+
## 2.4.2 - 2026-06-24
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **GitLab (`glab` 1.105+):** `task merge` / `task ship` now resolve merge requests using `-F json` and `-M`/`-c`/`-A` state filters instead of deprecated `--output` / `-S` flags.
|
|
23
|
+
|
|
7
24
|
## 2.4.0 - 2026-06-12
|
|
8
25
|
|
|
9
26
|
### Changed
|
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 checks and delete local task branch with -D if not fully merged")
|
|
169
169
|
.option("--cwd <path>", "Target directory")
|
|
170
170
|
.action(async (taskRef, opts) => {
|
|
171
171
|
await taskSyncCommand(taskRef, {
|
|
@@ -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,18 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
101
102
|
return;
|
|
102
103
|
}
|
|
103
104
|
try {
|
|
104
|
-
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
|
|
105
|
+
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method, immediate: true });
|
|
106
|
+
const verified = await assertMergeRequestMerged({
|
|
107
|
+
cwd,
|
|
108
|
+
host,
|
|
109
|
+
url: mergeRequestUrl,
|
|
110
|
+
integrationBranch: ctx.baseBranch,
|
|
111
|
+
});
|
|
112
|
+
if (!verified.ok) {
|
|
113
|
+
log.error(verified.message);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
105
117
|
log.ok(`${label} merged into ${ctx.baseBranch}.`);
|
|
106
118
|
}
|
|
107
119
|
catch (e) {
|
|
@@ -120,8 +132,19 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
120
132
|
else {
|
|
121
133
|
log.warn(`Could not read ${label} state — attempting merge anyway.`);
|
|
122
134
|
try {
|
|
123
|
-
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method });
|
|
124
|
-
|
|
135
|
+
await mergeMergeRequest({ cwd, host, url: mergeRequestUrl, method, immediate: true });
|
|
136
|
+
const verified = await assertMergeRequestMerged({
|
|
137
|
+
cwd,
|
|
138
|
+
host,
|
|
139
|
+
url: mergeRequestUrl,
|
|
140
|
+
integrationBranch: ctx.baseBranch,
|
|
141
|
+
});
|
|
142
|
+
if (!verified.ok) {
|
|
143
|
+
log.error(verified.message);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
log.ok(`${label} merged into ${ctx.baseBranch}.`);
|
|
125
148
|
}
|
|
126
149
|
catch (e) {
|
|
127
150
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -4,6 +4,7 @@ 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
|
+
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
7
8
|
function resolveMergeMethod(opts) {
|
|
8
9
|
if (opts.rebase === true)
|
|
9
10
|
return "rebase";
|
|
@@ -91,7 +92,18 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
91
92
|
}
|
|
92
93
|
const method = resolveMergeMethod(options);
|
|
93
94
|
try {
|
|
94
|
-
await mergeMergeRequest({ cwd, host, url: releaseUrl, method });
|
|
95
|
+
await mergeMergeRequest({ cwd, host, url: releaseUrl, method, immediate: true });
|
|
96
|
+
const verified = await assertMergeRequestMerged({
|
|
97
|
+
cwd,
|
|
98
|
+
host,
|
|
99
|
+
url: releaseUrl,
|
|
100
|
+
integrationBranch: productionBranch,
|
|
101
|
+
});
|
|
102
|
+
if (!verified.ok) {
|
|
103
|
+
log.error(verified.message);
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
95
107
|
log.ok(`Release ${label} merged into ${productionBranch}.`);
|
|
96
108
|
}
|
|
97
109
|
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,30 +52,11 @@ 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}`);
|
|
@@ -129,6 +109,28 @@ 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
|
await gitDeleteBranch(cwd, taskBranch, { force: options.force === true });
|
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;
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -114,22 +114,31 @@ async function ghFindMergeRequestByBranches(opts) {
|
|
|
114
114
|
}
|
|
115
115
|
async function glabFindMergeRequestByBranches(opts) {
|
|
116
116
|
const state = opts.state ?? "open";
|
|
117
|
-
const
|
|
118
|
-
const { stdout } = await execFileAsync("glab", [
|
|
117
|
+
const args = [
|
|
119
118
|
"mr",
|
|
120
119
|
"list",
|
|
121
120
|
"--source-branch",
|
|
122
121
|
opts.headBranch,
|
|
123
122
|
"--target-branch",
|
|
124
123
|
opts.baseBranch,
|
|
125
|
-
"-
|
|
126
|
-
|
|
124
|
+
"-F",
|
|
125
|
+
"json",
|
|
127
126
|
"-P",
|
|
128
127
|
"1",
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"
|
|
132
|
-
|
|
128
|
+
];
|
|
129
|
+
if (state === "merged") {
|
|
130
|
+
args.push("-M");
|
|
131
|
+
}
|
|
132
|
+
else if (state === "closed") {
|
|
133
|
+
args.push("-c");
|
|
134
|
+
}
|
|
135
|
+
else if (state === "all") {
|
|
136
|
+
args.push("-A");
|
|
137
|
+
}
|
|
138
|
+
const { stdout } = await execFileAsync("glab", args, {
|
|
139
|
+
cwd: opts.cwd,
|
|
140
|
+
maxBuffer: 1024 * 1024,
|
|
141
|
+
});
|
|
133
142
|
const trimmed = stdout.trim();
|
|
134
143
|
if (trimmed.length === 0 || trimmed === "[]")
|
|
135
144
|
return null;
|
|
@@ -178,33 +187,50 @@ function prRefFromUrl(url) {
|
|
|
178
187
|
return prNumberFromUrl(url) ?? url.trim();
|
|
179
188
|
}
|
|
180
189
|
export async function getMergeRequestState(cwd, host, url) {
|
|
190
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
191
|
+
return details?.state ?? "unknown";
|
|
192
|
+
}
|
|
193
|
+
export async function getMergeRequestDetails(cwd, host, url) {
|
|
181
194
|
const ref = prRefFromUrl(url);
|
|
182
195
|
try {
|
|
183
196
|
if (host === "gitlab") {
|
|
184
|
-
const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "-F", "json"], { cwd, maxBuffer: 1024 * 1024 });
|
|
198
|
+
const parsed = JSON.parse(stdout.trim());
|
|
199
|
+
const state = normalizeGlabMergeRequestState(parsed.state ?? "");
|
|
200
|
+
return {
|
|
201
|
+
state,
|
|
202
|
+
mergedAt: parsed.merged_at ?? null,
|
|
203
|
+
mergeCommitSha: parsed.merge_commit_sha ?? null,
|
|
204
|
+
squashCommitSha: parsed.squash_commit_sha ?? null,
|
|
205
|
+
};
|
|
193
206
|
}
|
|
194
|
-
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
return "unknown";
|
|
207
|
+
const { stdout } = await execFileAsync("gh", ["pr", "view", ref, "--json", "state,mergedAt,mergeCommit"], { cwd, maxBuffer: 1024 * 1024 });
|
|
208
|
+
const parsed = JSON.parse(stdout.trim());
|
|
209
|
+
return {
|
|
210
|
+
state: normalizeGhMergeRequestState(parsed.state ?? ""),
|
|
211
|
+
mergedAt: parsed.mergedAt ?? null,
|
|
212
|
+
mergeCommitSha: parsed.mergeCommit?.oid ?? null,
|
|
213
|
+
squashCommitSha: null,
|
|
214
|
+
};
|
|
203
215
|
}
|
|
204
216
|
catch {
|
|
205
|
-
return
|
|
217
|
+
return null;
|
|
206
218
|
}
|
|
207
219
|
}
|
|
220
|
+
/** Poll host until MR/PR state is merged (or timeout). */
|
|
221
|
+
export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
|
|
222
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
223
|
+
const intervalMs = options.intervalMs ?? 2_000;
|
|
224
|
+
const deadline = Date.now() + timeoutMs;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
const details = await getMergeRequestDetails(cwd, host, url);
|
|
227
|
+
if (details?.state === "merged" && details.mergedAt !== null) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
231
|
+
}
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
208
234
|
export async function closeMergeRequest(opts) {
|
|
209
235
|
const ref = prRefFromUrl(opts.url);
|
|
210
236
|
const label = mergeRequestLabel(opts.host);
|
|
@@ -230,7 +256,10 @@ export async function mergeMergeRequest(opts) {
|
|
|
230
256
|
const label = mergeRequestLabel(opts.host);
|
|
231
257
|
if (opts.dryRun === true) {
|
|
232
258
|
if (opts.host === "gitlab") {
|
|
233
|
-
|
|
259
|
+
const immediate = opts.immediate !== false;
|
|
260
|
+
const flags = immediate ? " --auto-merge=false" : "";
|
|
261
|
+
const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
|
|
262
|
+
log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
|
|
234
263
|
}
|
|
235
264
|
else {
|
|
236
265
|
log.info(`would gh pr merge ${ref} --${method}`);
|
|
@@ -238,7 +267,17 @@ export async function mergeMergeRequest(opts) {
|
|
|
238
267
|
return;
|
|
239
268
|
}
|
|
240
269
|
if (opts.host === "gitlab") {
|
|
241
|
-
|
|
270
|
+
const args = ["mr", "merge", ref];
|
|
271
|
+
if (opts.immediate !== false) {
|
|
272
|
+
args.push("--auto-merge=false");
|
|
273
|
+
}
|
|
274
|
+
if (method === "squash") {
|
|
275
|
+
args.push("--squash");
|
|
276
|
+
}
|
|
277
|
+
else if (method === "rebase") {
|
|
278
|
+
args.push("--rebase");
|
|
279
|
+
}
|
|
280
|
+
await execFileAsync("glab", args, {
|
|
242
281
|
cwd: opts.cwd,
|
|
243
282
|
maxBuffer: 4 * 1024 * 1024,
|
|
244
283
|
});
|
|
@@ -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
|
+
}
|