@goodtek/vibeops 2.5.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +89 -4
- package/dist/commands/pull.js +16 -0
- package/dist/commands/task-add.js +175 -97
- package/dist/commands/task-del.js +7 -0
- package/dist/commands/task-merge.js +31 -4
- package/dist/commands/task-release.js +28 -4
- package/dist/commands/task-ship.js +7 -0
- package/dist/commands/task-sync.js +60 -28
- package/dist/lib/check-rollup.js +128 -0
- package/dist/lib/config.js +3 -0
- package/dist/lib/git-integration-sync.js +77 -71
- package/dist/lib/git-worktree.js +214 -0
- package/dist/lib/git.js +63 -54
- package/dist/lib/merge-config.js +125 -0
- package/dist/lib/merge-request-readiness.js +20 -1
- package/dist/lib/pr-create.js +293 -103
- package/dist/lib/task-del.js +7 -5
- package/dist/lib/task-id-allocation.js +192 -0
- package/dist/lib/task-lock.js +356 -0
- package/dist/lib/task-new-cycle.js +12 -1
- package/dist/lib/task-push-pr.js +7 -0
- package/dist/lib/task-reship.js +27 -12
- package/dist/lib/task-start.js +35 -26
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readTextOrNull } from "./filesystem.js";
|
|
3
|
+
import { VIBEOPS_CONFIG_FILE } from "./paths.js";
|
|
4
|
+
export const MERGE_GATE_DEFAULTS = {
|
|
5
|
+
requiredChecks: [],
|
|
6
|
+
releaseRequiredChecks: [],
|
|
7
|
+
waitTimeoutSeconds: 900,
|
|
8
|
+
pollIntervalSeconds: 5,
|
|
9
|
+
emptyRollupGraceSeconds: 30,
|
|
10
|
+
allowNoChecks: false,
|
|
11
|
+
};
|
|
12
|
+
/** Inclusive bounds for the numeric settings (seconds). */
|
|
13
|
+
export const MERGE_GATE_LIMITS = {
|
|
14
|
+
waitTimeoutSeconds: { min: 1, max: 7_200 },
|
|
15
|
+
pollIntervalSeconds: { min: 1, max: 300 },
|
|
16
|
+
emptyRollupGraceSeconds: { min: 0, max: 600 },
|
|
17
|
+
};
|
|
18
|
+
const KNOWN_KEYS = new Set([
|
|
19
|
+
"requiredChecks",
|
|
20
|
+
"releaseRequiredChecks",
|
|
21
|
+
"waitTimeoutSeconds",
|
|
22
|
+
"pollIntervalSeconds",
|
|
23
|
+
"emptyRollupGraceSeconds",
|
|
24
|
+
"allowNoChecks",
|
|
25
|
+
]);
|
|
26
|
+
/** Invalid `merge` block in `.vibeops.json`. Merge commands refuse to run. */
|
|
27
|
+
export class MergeConfigError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(`${VIBEOPS_CONFIG_FILE} merge: ${message}`);
|
|
30
|
+
this.name = "MergeConfigError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function checkList(key, raw) {
|
|
34
|
+
if (!Array.isArray(raw)) {
|
|
35
|
+
throw new MergeConfigError(`\`${key}\` must be an array of check names.`);
|
|
36
|
+
}
|
|
37
|
+
return raw.map((item, i) => {
|
|
38
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
39
|
+
throw new MergeConfigError(`\`${key}[${i}]\` must be a non-empty string.`);
|
|
40
|
+
}
|
|
41
|
+
return item.trim();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function seconds(key, raw) {
|
|
45
|
+
const { min, max } = MERGE_GATE_LIMITS[key];
|
|
46
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < min || raw > max) {
|
|
47
|
+
throw new MergeConfigError(`\`${key}\` must be an integer between ${min} and ${max}.`);
|
|
48
|
+
}
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate the raw `merge` block (fail closed): must be an object, only known
|
|
53
|
+
* keys, lists of non-empty strings, bounded integer seconds, boolean flags.
|
|
54
|
+
* `undefined` → defaults.
|
|
55
|
+
*/
|
|
56
|
+
export function parseMergeConfig(raw) {
|
|
57
|
+
if (raw === undefined)
|
|
58
|
+
return MERGE_GATE_DEFAULTS;
|
|
59
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
60
|
+
throw new MergeConfigError("must be an object.");
|
|
61
|
+
}
|
|
62
|
+
const o = raw;
|
|
63
|
+
const unknown = Object.keys(o).filter((k) => !KNOWN_KEYS.has(k));
|
|
64
|
+
if (unknown.length > 0) {
|
|
65
|
+
throw new MergeConfigError(`unknown key${unknown.length > 1 ? "s" : ""} ${unknown
|
|
66
|
+
.map((k) => `\`${k}\``)
|
|
67
|
+
.join(", ")} (allowed: ${[...KNOWN_KEYS].join(", ")}).`);
|
|
68
|
+
}
|
|
69
|
+
const cfg = {
|
|
70
|
+
requiredChecks: o.requiredChecks === undefined
|
|
71
|
+
? MERGE_GATE_DEFAULTS.requiredChecks
|
|
72
|
+
: checkList("requiredChecks", o.requiredChecks),
|
|
73
|
+
releaseRequiredChecks: o.releaseRequiredChecks === undefined
|
|
74
|
+
? MERGE_GATE_DEFAULTS.releaseRequiredChecks
|
|
75
|
+
: checkList("releaseRequiredChecks", o.releaseRequiredChecks),
|
|
76
|
+
waitTimeoutSeconds: o.waitTimeoutSeconds === undefined
|
|
77
|
+
? MERGE_GATE_DEFAULTS.waitTimeoutSeconds
|
|
78
|
+
: seconds("waitTimeoutSeconds", o.waitTimeoutSeconds),
|
|
79
|
+
pollIntervalSeconds: o.pollIntervalSeconds === undefined
|
|
80
|
+
? MERGE_GATE_DEFAULTS.pollIntervalSeconds
|
|
81
|
+
: seconds("pollIntervalSeconds", o.pollIntervalSeconds),
|
|
82
|
+
emptyRollupGraceSeconds: o.emptyRollupGraceSeconds === undefined
|
|
83
|
+
? MERGE_GATE_DEFAULTS.emptyRollupGraceSeconds
|
|
84
|
+
: seconds("emptyRollupGraceSeconds", o.emptyRollupGraceSeconds),
|
|
85
|
+
allowNoChecks: MERGE_GATE_DEFAULTS.allowNoChecks,
|
|
86
|
+
};
|
|
87
|
+
if (o.allowNoChecks !== undefined) {
|
|
88
|
+
if (typeof o.allowNoChecks !== "boolean") {
|
|
89
|
+
throw new MergeConfigError("`allowNoChecks` must be true or false.");
|
|
90
|
+
}
|
|
91
|
+
cfg.allowNoChecks = o.allowNoChecks;
|
|
92
|
+
}
|
|
93
|
+
if (cfg.pollIntervalSeconds > cfg.waitTimeoutSeconds) {
|
|
94
|
+
throw new MergeConfigError("`pollIntervalSeconds` must not exceed `waitTimeoutSeconds`.");
|
|
95
|
+
}
|
|
96
|
+
return cfg;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read and validate `.vibeops.json` `merge`. Throws `MergeConfigError` on an
|
|
100
|
+
* invalid block; a missing file or block yields the defaults.
|
|
101
|
+
*/
|
|
102
|
+
export async function readMergeConfig(root) {
|
|
103
|
+
const text = await readTextOrNull(join(root, VIBEOPS_CONFIG_FILE));
|
|
104
|
+
if (text === null)
|
|
105
|
+
return MERGE_GATE_DEFAULTS;
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new MergeConfigError("file is not valid JSON.");
|
|
112
|
+
}
|
|
113
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
114
|
+
return MERGE_GATE_DEFAULTS;
|
|
115
|
+
}
|
|
116
|
+
return parseMergeConfig(parsed.merge);
|
|
117
|
+
}
|
|
118
|
+
/** Gate options (milliseconds) derived from resolved config. */
|
|
119
|
+
export function mergeGateTiming(cfg) {
|
|
120
|
+
return {
|
|
121
|
+
timeoutMs: cfg.waitTimeoutSeconds * 1000,
|
|
122
|
+
intervalMs: cfg.pollIntervalSeconds * 1000,
|
|
123
|
+
emptyRollupGraceMs: cfg.emptyRollupGraceSeconds * 1000,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -4,8 +4,13 @@ const ACTIVE_PIPELINE_STATUSES = new Set([
|
|
|
4
4
|
"created",
|
|
5
5
|
"waiting_for_resource",
|
|
6
6
|
"preparing",
|
|
7
|
+
"scheduled",
|
|
8
|
+
"waiting_for_callback",
|
|
9
|
+
"canceling",
|
|
7
10
|
]);
|
|
8
11
|
const TERMINAL_PIPELINE_FAILURES = new Set(["failed", "canceled"]);
|
|
12
|
+
/** Pipeline statuses that count as green for the merge gate (`none` = no pipeline). */
|
|
13
|
+
const GREEN_PIPELINE_STATUSES = new Set(["success", "skipped", "none"]);
|
|
9
14
|
function normalizePipelineStatus(raw) {
|
|
10
15
|
if (raw === null || raw === undefined || raw.trim().length === 0)
|
|
11
16
|
return "none";
|
|
@@ -18,6 +23,8 @@ function normalizePipelineStatus(raw) {
|
|
|
18
23
|
return "failed";
|
|
19
24
|
if (status === "canceled" || status === "cancelled")
|
|
20
25
|
return "canceled";
|
|
26
|
+
if (status === "manual")
|
|
27
|
+
return "manual";
|
|
21
28
|
if (ACTIVE_PIPELINE_STATUSES.has(status))
|
|
22
29
|
return "running";
|
|
23
30
|
return "unknown";
|
|
@@ -25,6 +32,18 @@ function normalizePipelineStatus(raw) {
|
|
|
25
32
|
export function pipelineStatusFromHost(raw) {
|
|
26
33
|
return normalizePipelineStatus(raw);
|
|
27
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Merge-gate verdict for a single pipeline status (GitLab head pipeline, or the
|
|
37
|
+
* aggregated GitHub rollup). Unknown / manual / failed / canceled are red —
|
|
38
|
+
* never merge on a status we cannot prove is green.
|
|
39
|
+
*/
|
|
40
|
+
export function pipelineGateState(status) {
|
|
41
|
+
if (GREEN_PIPELINE_STATUSES.has(status))
|
|
42
|
+
return "green";
|
|
43
|
+
if (isPipelineActive(status))
|
|
44
|
+
return "pending";
|
|
45
|
+
return "red";
|
|
46
|
+
}
|
|
28
47
|
export function isPipelineActive(status) {
|
|
29
48
|
return status === "running" || status === "pending";
|
|
30
49
|
}
|
|
@@ -50,7 +69,7 @@ export function isMergeRequestReadyToMerge(readiness) {
|
|
|
50
69
|
}
|
|
51
70
|
if (TERMINAL_PIPELINE_FAILURES.has(readiness.pipelineStatus))
|
|
52
71
|
return false;
|
|
53
|
-
if (
|
|
72
|
+
if (pipelineGateState(readiness.pipelineStatus) !== "green")
|
|
54
73
|
return false;
|
|
55
74
|
const mergeStatus = readiness.mergeStatus?.trim().toLowerCase() ?? "";
|
|
56
75
|
return mergeStatus === "can_be_merged" || detailed === "mergeable";
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { mergeRequestLabel } from "./git-host.js";
|
|
4
|
-
import {
|
|
4
|
+
import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
|
|
5
|
+
import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
|
|
5
6
|
import { dim, log } from "./logger.js";
|
|
7
|
+
import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
|
|
6
8
|
const execFileAsync = promisify(execFile);
|
|
7
9
|
async function ghPrCreate(opts) {
|
|
8
10
|
const { stdout } = await execFileAsync("gh", [
|
|
@@ -192,52 +194,125 @@ export async function getMergeRequestState(cwd, host, url) {
|
|
|
192
194
|
const details = await getMergeRequestDetails(cwd, host, url);
|
|
193
195
|
return details?.state ?? "unknown";
|
|
194
196
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
hasConflicts: parsed.has_conflicts ?? null,
|
|
211
|
-
};
|
|
197
|
+
/** Host CLI returned JSON without a field the merge gate needs — fail closed. */
|
|
198
|
+
export class MergeRequestShapeError extends Error {
|
|
199
|
+
constructor(tool, problems) {
|
|
200
|
+
super(`${tool} output cannot be trusted for the merge gate: ${problems.join("; ")}.`);
|
|
201
|
+
this.name = "MergeRequestShapeError";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Describe every field that violates its rule (`undefined`, `null`, `""`, wrong type). */
|
|
205
|
+
function shapeProblems(obj, rules) {
|
|
206
|
+
const out = [];
|
|
207
|
+
for (const [field, rule] of Object.entries(rules)) {
|
|
208
|
+
const v = obj[field];
|
|
209
|
+
if (rule === "present") {
|
|
210
|
+
if (!(field in obj) || v === undefined)
|
|
211
|
+
out.push(`\`${field}\` is missing`);
|
|
212
212
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
let pipelineStatus = "none";
|
|
217
|
-
if (checks.some((c) => (c.state ?? "").toUpperCase() === "PENDING")) {
|
|
218
|
-
pipelineStatus = "running";
|
|
213
|
+
else if (rule === "array") {
|
|
214
|
+
if (!Array.isArray(v))
|
|
215
|
+
out.push(`\`${field}\` is missing or not a list`);
|
|
219
216
|
}
|
|
220
|
-
else if (
|
|
221
|
-
|
|
217
|
+
else if (typeof v !== "string" || v.trim().length === 0) {
|
|
218
|
+
out.push(`\`${field}\` is missing or empty`);
|
|
222
219
|
}
|
|
223
|
-
|
|
224
|
-
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
export async function getMergeRequestDetails(cwd, host, url) {
|
|
224
|
+
try {
|
|
225
|
+
return await readMergeRequestDetails(cwd, host, url, { strict: false });
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Read MR/PR details from the host CLI. Throws on CLI / JSON errors. With
|
|
233
|
+
* `strict`, also throws `MergeRequestShapeError` when a field the merge gate
|
|
234
|
+
* relies on is absent (never treat missing data as green).
|
|
235
|
+
*/
|
|
236
|
+
export async function readMergeRequestDetails(cwd, host, url, options) {
|
|
237
|
+
const ref = prRefFromUrl(url);
|
|
238
|
+
if (host === "gitlab") {
|
|
239
|
+
const { stdout } = await execFileAsync("glab", ["mr", "view", ref, "-F", "json"], { cwd, maxBuffer: 1024 * 1024 });
|
|
240
|
+
const parsed = JSON.parse(stdout.trim());
|
|
241
|
+
if (options.strict) {
|
|
242
|
+
const problems = shapeProblems(parsed, {
|
|
243
|
+
state: "nonEmptyString",
|
|
244
|
+
merge_status: "present",
|
|
245
|
+
detailed_merge_status: "present",
|
|
246
|
+
// `null` = no pipeline; the key itself must be there.
|
|
247
|
+
head_pipeline: "present",
|
|
248
|
+
sha: "nonEmptyString",
|
|
249
|
+
});
|
|
250
|
+
const pipeline = parsed.head_pipeline;
|
|
251
|
+
if (pipeline !== null &&
|
|
252
|
+
pipeline !== undefined &&
|
|
253
|
+
(typeof pipeline.status !== "string" || pipeline.status.trim().length === 0)) {
|
|
254
|
+
problems.push("`head_pipeline.status` is missing or empty");
|
|
255
|
+
}
|
|
256
|
+
if (problems.length > 0)
|
|
257
|
+
throw new MergeRequestShapeError("glab", problems);
|
|
225
258
|
}
|
|
226
|
-
const
|
|
259
|
+
const state = normalizeGlabMergeRequestState(parsed.state ?? "");
|
|
227
260
|
return {
|
|
228
|
-
state
|
|
229
|
-
mergedAt: parsed.
|
|
230
|
-
mergeCommitSha: parsed.
|
|
231
|
-
squashCommitSha: null,
|
|
232
|
-
mergeStatus:
|
|
233
|
-
detailedMergeStatus:
|
|
234
|
-
pipelineStatus,
|
|
235
|
-
hasConflicts:
|
|
261
|
+
state,
|
|
262
|
+
mergedAt: parsed.merged_at ?? null,
|
|
263
|
+
mergeCommitSha: parsed.merge_commit_sha ?? null,
|
|
264
|
+
squashCommitSha: parsed.squash_commit_sha ?? null,
|
|
265
|
+
mergeStatus: parsed.merge_status ?? null,
|
|
266
|
+
detailedMergeStatus: parsed.detailed_merge_status ?? null,
|
|
267
|
+
pipelineStatus: pipelineStatusFromHost(parsed.head_pipeline?.status),
|
|
268
|
+
hasConflicts: parsed.has_conflicts ?? null,
|
|
269
|
+
checks: null,
|
|
270
|
+
headSha: parsed.sha ?? null,
|
|
236
271
|
};
|
|
237
272
|
}
|
|
238
|
-
|
|
239
|
-
|
|
273
|
+
const { stdout } = await execFileAsync("gh", [
|
|
274
|
+
"pr",
|
|
275
|
+
"view",
|
|
276
|
+
ref,
|
|
277
|
+
"--json",
|
|
278
|
+
"state,mergedAt,mergeCommit,mergeable,headRefOid,statusCheckRollup",
|
|
279
|
+
], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
280
|
+
const parsed = JSON.parse(stdout.trim());
|
|
281
|
+
if (options.strict) {
|
|
282
|
+
const problems = shapeProblems(parsed, {
|
|
283
|
+
state: "nonEmptyString",
|
|
284
|
+
mergeable: "nonEmptyString",
|
|
285
|
+
headRefOid: "nonEmptyString",
|
|
286
|
+
statusCheckRollup: "array",
|
|
287
|
+
});
|
|
288
|
+
if (problems.length > 0)
|
|
289
|
+
throw new MergeRequestShapeError("gh", problems);
|
|
290
|
+
}
|
|
291
|
+
const checks = classifyRollup(parsed.statusCheckRollup);
|
|
292
|
+
const gate = evaluateCheckGate(checks);
|
|
293
|
+
let pipelineStatus = "none";
|
|
294
|
+
if (gate.state === "red") {
|
|
295
|
+
pipelineStatus = "failed";
|
|
296
|
+
}
|
|
297
|
+
else if (gate.state === "pending") {
|
|
298
|
+
pipelineStatus = "running";
|
|
240
299
|
}
|
|
300
|
+
else if (checks.length > 0) {
|
|
301
|
+
pipelineStatus = "success";
|
|
302
|
+
}
|
|
303
|
+
const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
|
|
304
|
+
return {
|
|
305
|
+
state: normalizeGhMergeRequestState(parsed.state ?? ""),
|
|
306
|
+
mergedAt: parsed.mergedAt ?? null,
|
|
307
|
+
mergeCommitSha: parsed.mergeCommit?.oid ?? null,
|
|
308
|
+
squashCommitSha: null,
|
|
309
|
+
mergeStatus: mergeable === "MERGEABLE" ? "can_be_merged" : mergeable.toLowerCase(),
|
|
310
|
+
detailedMergeStatus: mergeable.toLowerCase(),
|
|
311
|
+
pipelineStatus,
|
|
312
|
+
hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
|
|
313
|
+
checks,
|
|
314
|
+
headSha: parsed.headRefOid ?? null,
|
|
315
|
+
};
|
|
241
316
|
}
|
|
242
317
|
/** Poll host until MR/PR state is merged (or timeout). */
|
|
243
318
|
export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
|
|
@@ -264,26 +339,135 @@ function mergeRequestReadinessFromDetails(details) {
|
|
|
264
339
|
hasConflicts: details.hasConflicts,
|
|
265
340
|
};
|
|
266
341
|
}
|
|
267
|
-
/**
|
|
268
|
-
export
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
342
|
+
/** Thrown by `mergeMergeRequest` when the merge gate refuses (red / pending / missing checks). */
|
|
343
|
+
export class MergeGateError extends Error {
|
|
344
|
+
problems;
|
|
345
|
+
constructor(label, problems) {
|
|
346
|
+
super(`Refusing to merge ${label}:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
|
|
347
|
+
this.name = "MergeGateError";
|
|
348
|
+
this.problems = problems;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function evaluateDetailsGate(details, requiredChecks) {
|
|
352
|
+
if (details.checks !== null) {
|
|
353
|
+
const gate = evaluateCheckGate(details.checks, requiredChecks);
|
|
354
|
+
return {
|
|
355
|
+
state: gate.state,
|
|
356
|
+
problems: formatCheckGateProblems(gate),
|
|
357
|
+
noChecks: details.checks.length === 0,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const state = pipelineGateState(details.pipelineStatus);
|
|
361
|
+
return {
|
|
362
|
+
state,
|
|
363
|
+
problems: state === "green" ? [] : [`head pipeline is ${details.pipelineStatus}`],
|
|
364
|
+
noChecks: details.pipelineStatus === "none",
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
368
|
+
/**
|
|
369
|
+
* Merge gate: poll the MR/PR until every check is green and the host reports it
|
|
370
|
+
* mergeable. Refuses immediately on any failed check, on host output it cannot
|
|
371
|
+
* trust, and on "no checks at all" (unless allowed); refuses after the timeout
|
|
372
|
+
* when checks stay pending or a required check never appears.
|
|
373
|
+
*/
|
|
374
|
+
export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
375
|
+
const defaults = mergeGateTiming(MERGE_GATE_DEFAULTS);
|
|
376
|
+
const timeoutMs = options.timeoutMs ?? defaults.timeoutMs;
|
|
377
|
+
const intervalMs = options.intervalMs ?? defaults.intervalMs;
|
|
378
|
+
const graceMs = options.emptyRollupGraceMs ?? defaults.emptyRollupGraceMs;
|
|
379
|
+
const allowNoChecks = options.allowNoChecks ?? MERGE_GATE_DEFAULTS.allowNoChecks;
|
|
380
|
+
const waitForPending = options.waitForPending !== false;
|
|
381
|
+
const required = options.requiredChecks ?? [];
|
|
272
382
|
const label = mergeRequestLabel(host);
|
|
383
|
+
const start = Date.now();
|
|
384
|
+
const deadline = start + timeoutMs;
|
|
273
385
|
let loggedWait = false;
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
386
|
+
let warnedGitLabRequired = false;
|
|
387
|
+
for (;;) {
|
|
388
|
+
let problems = [];
|
|
389
|
+
let details = null;
|
|
390
|
+
try {
|
|
391
|
+
details = await readMergeRequestDetails(cwd, host, url, { strict: true });
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (error instanceof MergeRequestShapeError) {
|
|
395
|
+
return { ok: false, problems: [error.message] };
|
|
396
|
+
}
|
|
397
|
+
const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
398
|
+
problems = [`could not read ${label} details from the host: ${msg}`];
|
|
399
|
+
}
|
|
400
|
+
if (details === null) {
|
|
401
|
+
// problems set in catch; keep polling (transient CLI / network error).
|
|
402
|
+
}
|
|
403
|
+
else if (details.state === "merged") {
|
|
404
|
+
return { ok: true, alreadyMerged: true, details };
|
|
405
|
+
}
|
|
406
|
+
else if (details.state !== "open") {
|
|
407
|
+
return { ok: false, problems: [`${label} is ${details.state}, not open`] };
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
const gitlab = details.checks === null;
|
|
411
|
+
if (gitlab && required.length > 0 && !warnedGitLabRequired) {
|
|
412
|
+
log.warn("merge.requiredChecks is GitHub-only; GitLab uses the head pipeline status.");
|
|
413
|
+
warnedGitLabRequired = true;
|
|
414
|
+
}
|
|
415
|
+
const ev = evaluateDetailsGate(details, required);
|
|
416
|
+
if (ev.state === "red") {
|
|
417
|
+
return { ok: false, problems: ev.problems };
|
|
418
|
+
}
|
|
419
|
+
if (details.hasConflicts === true) {
|
|
420
|
+
return { ok: false, problems: [`${label} has merge conflicts`] };
|
|
421
|
+
}
|
|
422
|
+
// "No checks at all" is judged here only when no required check could
|
|
423
|
+
// report it instead (GitHub with requiredChecks → "never ran" path).
|
|
424
|
+
const noChecksRule = ev.noChecks && (gitlab || required.length === 0);
|
|
425
|
+
const inGrace = waitForPending && noChecksRule && Date.now() - start < graceMs;
|
|
426
|
+
if (ev.state === "pending") {
|
|
427
|
+
problems = ev.problems;
|
|
428
|
+
}
|
|
429
|
+
else if (inGrace) {
|
|
430
|
+
problems = ["no status checks reported yet"];
|
|
431
|
+
}
|
|
432
|
+
else if (noChecksRule && !allowNoChecks) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
problems: [
|
|
436
|
+
`the host reports no checks for this ${label} — refusing (fail closed). ` +
|
|
437
|
+
"Configure CI and `merge.requiredChecks`, or set `merge.allowNoChecks: true` in .vibeops.json for a repo without CI",
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
else {
|
|
442
|
+
const readiness = mergeRequestReadinessFromDetails(details);
|
|
443
|
+
if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
|
|
444
|
+
if (noChecksRule) {
|
|
445
|
+
// Once, after the gate has decided to merge — not on every poll.
|
|
446
|
+
log.warn(`WARNING: merging ${label} with NO checks (merge.allowNoChecks is true). Nothing verified this change.`);
|
|
447
|
+
}
|
|
448
|
+
return { ok: true, alreadyMerged: false, details };
|
|
449
|
+
}
|
|
450
|
+
problems = [
|
|
451
|
+
`host does not report the ${label} as mergeable yet (${details.detailedMergeStatus || details.mergeStatus || "unknown"})`,
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const now = Date.now();
|
|
456
|
+
if (!waitForPending || now >= deadline) {
|
|
457
|
+
const suffix = waitForPending
|
|
458
|
+
? ` (gave up after ${Math.round(timeoutMs / 1000)}s)`
|
|
459
|
+
: "";
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
problems: problems.map((p, i) => (i === problems.length - 1 ? `${p}${suffix}` : p)),
|
|
463
|
+
};
|
|
279
464
|
}
|
|
280
465
|
if (!loggedWait) {
|
|
281
|
-
log.info(dim(`Waiting for ${label}
|
|
466
|
+
log.info(dim(`Waiting for ${label} checks: ${problems.join("; ")}…`));
|
|
282
467
|
loggedWait = true;
|
|
283
468
|
}
|
|
284
|
-
await
|
|
469
|
+
await sleep(Math.min(intervalMs, deadline - now));
|
|
285
470
|
}
|
|
286
|
-
return false;
|
|
287
471
|
}
|
|
288
472
|
function isMergeHttp405(error) {
|
|
289
473
|
const msg = error instanceof Error ? error.message : String(error);
|
|
@@ -312,68 +496,74 @@ export async function mergeMergeRequest(opts) {
|
|
|
312
496
|
const ref = prRefFromUrl(opts.url);
|
|
313
497
|
const method = opts.method ?? "squash";
|
|
314
498
|
const label = mergeRequestLabel(opts.host);
|
|
315
|
-
if (opts.waitForCi === true && opts.dryRun !== true) {
|
|
316
|
-
const ready = await waitForMergeRequestReadyToMerge(opts.cwd, opts.host, opts.url);
|
|
317
|
-
if (!ready) {
|
|
318
|
-
throw new Error(`${label} is not ready to merge yet (pipeline running, checks pending, or timeout).`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
const details = await getMergeRequestDetails(opts.cwd, opts.host, opts.url);
|
|
322
|
-
const readiness = mergeRequestReadinessFromDetails(details);
|
|
323
|
-
const pipelineActive = readiness !== null ? isPipelineActive(readiness.pipelineStatus) : false;
|
|
324
499
|
if (opts.dryRun === true) {
|
|
500
|
+
const required = opts.requiredChecks && opts.requiredChecks.length > 0
|
|
501
|
+
? ` + required: ${opts.requiredChecks.join(", ")}`
|
|
502
|
+
: "";
|
|
503
|
+
log.info(`would check ${label} status checks (all green${required}) before merging`);
|
|
325
504
|
if (opts.host === "gitlab") {
|
|
326
|
-
|
|
327
|
-
const flags = immediate ? " --auto-merge=false" : " (auto-merge when pipeline succeeds)";
|
|
328
|
-
const methodFlag = method === "squash" ? " --squash" : method === "rebase" ? " --rebase" : "";
|
|
329
|
-
log.info(`would ${label} merge ${ref} (glab mr merge${flags}${methodFlag})`);
|
|
505
|
+
log.info(`would ${label} merge ${ref} (glab ${gitLabMergeArgs(ref, method, "<head sha>").join(" ")})`);
|
|
330
506
|
}
|
|
331
507
|
else {
|
|
332
|
-
log.info(`would gh
|
|
508
|
+
log.info(`would gh ${gitHubMergeArgs(ref, method, "<head sha>").join(" ")}`);
|
|
333
509
|
}
|
|
334
510
|
return;
|
|
335
511
|
}
|
|
336
|
-
|
|
337
|
-
await
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
immediate: opts.immediate !== false && !pipelineActive,
|
|
512
|
+
const runGate = async () => {
|
|
513
|
+
const gate = await waitForMergeGate(opts.cwd, opts.host, opts.url, {
|
|
514
|
+
...opts.gate,
|
|
515
|
+
waitForPending: opts.waitForCi === true,
|
|
516
|
+
requiredChecks: opts.requiredChecks ?? [],
|
|
342
517
|
});
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const args = ["mr", "merge", input.ref];
|
|
351
|
-
if (immediate) {
|
|
352
|
-
args.push("--auto-merge=false");
|
|
353
|
-
}
|
|
354
|
-
if (input.method === "squash") {
|
|
355
|
-
args.push("--squash");
|
|
356
|
-
}
|
|
357
|
-
else if (input.method === "rebase") {
|
|
358
|
-
args.push("--rebase");
|
|
518
|
+
if (!gate.ok)
|
|
519
|
+
throw new MergeGateError(label, gate.problems);
|
|
520
|
+
if (gate.alreadyMerged)
|
|
521
|
+
return null;
|
|
522
|
+
if (gate.details.headSha === null || gate.details.headSha.trim().length === 0) {
|
|
523
|
+
// Strict read already guarantees this; never merge unpinned.
|
|
524
|
+
throw new MergeGateError(label, ["host did not report the head commit SHA"]);
|
|
359
525
|
}
|
|
360
|
-
|
|
361
|
-
cwd: input.cwd,
|
|
362
|
-
maxBuffer: 4 * 1024 * 1024,
|
|
363
|
-
});
|
|
526
|
+
return gate.details;
|
|
364
527
|
};
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
await
|
|
371
|
-
return;
|
|
528
|
+
const details = await runGate();
|
|
529
|
+
if (details === null)
|
|
530
|
+
return;
|
|
531
|
+
if (opts.host === "gitlab") {
|
|
532
|
+
try {
|
|
533
|
+
await execGlabMerge(opts.cwd, gitLabMergeArgs(ref, method, details.headSha));
|
|
372
534
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
535
|
+
catch (error) {
|
|
536
|
+
if (!isMergeHttp405(error))
|
|
537
|
+
throw error;
|
|
538
|
+
// 405: GitLab not ready to accept the merge yet (e.g. mergeability still
|
|
539
|
+
// being computed). Re-run the gate, then retry once with the same flags
|
|
540
|
+
// (still `--auto-merge=false`, pinned to the re-checked SHA).
|
|
541
|
+
const again = await runGate();
|
|
542
|
+
if (again === null)
|
|
543
|
+
return;
|
|
544
|
+
await execGlabMerge(opts.cwd, gitLabMergeArgs(ref, method, again.headSha));
|
|
376
545
|
}
|
|
377
|
-
|
|
546
|
+
return;
|
|
378
547
|
}
|
|
548
|
+
// Pinned to the commit the gate checked: a push after the gate makes GitHub refuse.
|
|
549
|
+
await execFileAsync("gh", gitHubMergeArgs(ref, method, details.headSha), {
|
|
550
|
+
cwd: opts.cwd,
|
|
551
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
/** `gh pr merge` argv. `--match-head-commit` needs gh ≥ 2.13.0. */
|
|
555
|
+
export function gitHubMergeArgs(ref, method, headSha) {
|
|
556
|
+
return ["pr", "merge", ref, `--${method}`, "--match-head-commit", headSha];
|
|
557
|
+
}
|
|
558
|
+
/** `glab mr merge` argv: immediate (never schedule auto-merge) and pinned with `--sha`. */
|
|
559
|
+
export function gitLabMergeArgs(ref, method, headSha) {
|
|
560
|
+
const args = ["mr", "merge", ref, "--auto-merge=false", "--sha", headSha];
|
|
561
|
+
if (method === "squash")
|
|
562
|
+
args.push("--squash");
|
|
563
|
+
else if (method === "rebase")
|
|
564
|
+
args.push("--rebase");
|
|
565
|
+
return args;
|
|
566
|
+
}
|
|
567
|
+
async function execGlabMerge(cwd, args) {
|
|
568
|
+
await execFileAsync("glab", [...args], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
379
569
|
}
|
package/dist/lib/task-del.js
CHANGED
|
@@ -2,7 +2,8 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { deleteFile } from "./filesystem.js";
|
|
3
3
|
import { GitConfigError, requireGitConfig } from "./git-config.js";
|
|
4
4
|
import { mergeRequestLabel } from "./git-host.js";
|
|
5
|
-
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists,
|
|
5
|
+
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitGovernanceOnlyDirty, gitRemoteBranchExists, readGitInfo, } from "./git.js";
|
|
6
|
+
import { leaveTaskBranch } from "./git-worktree.js";
|
|
6
7
|
import { dim, log } from "./logger.js";
|
|
7
8
|
import { closeMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
8
9
|
import { normalizeTaskRef } from "./resolve-task.js";
|
|
@@ -83,12 +84,13 @@ export async function deleteTaskBranches(opts) {
|
|
|
83
84
|
}
|
|
84
85
|
const git = await readGitInfo(cwd);
|
|
85
86
|
if (git.branch === opts.taskBranch) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
// Worktree-safe (D-006): detach when another worktree owns the integration branch.
|
|
88
|
+
const left = await leaveTaskBranch(cwd, opts.remote, opts.integrationBranch);
|
|
89
|
+
if (!left.ok) {
|
|
90
|
+
log.error(left.message);
|
|
89
91
|
return false;
|
|
90
92
|
}
|
|
91
|
-
log.ok(`On ${opts.integrationBranch}`);
|
|
93
|
+
log.ok(left.on === "integration" ? `On ${opts.integrationBranch}` : `Detached at ${left.ref}`);
|
|
92
94
|
}
|
|
93
95
|
if (await gitBranchExists(cwd, opts.taskBranch)) {
|
|
94
96
|
try {
|