@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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
|
|
3
4
|
import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
|
|
4
5
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
5
6
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
6
7
|
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
7
8
|
import { projectPaths } from "../lib/paths.js";
|
|
8
9
|
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
9
|
-
import { getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
10
|
+
import { getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
10
11
|
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
11
12
|
import { relPath } from "../lib/task-context.js";
|
|
12
13
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
@@ -19,6 +20,12 @@ function resolveMergeMethod(opts) {
|
|
|
19
20
|
return "merge";
|
|
20
21
|
return "squash";
|
|
21
22
|
}
|
|
23
|
+
function mergeFailureHint(error) {
|
|
24
|
+
if (error instanceof MergeGateError) {
|
|
25
|
+
return "Not merged. Fix the failing checks (re-run `vibeops task ship` to push a fix and re-run CI), then rerun `vibeops task merge`.";
|
|
26
|
+
}
|
|
27
|
+
return "Merge in the host UI if branch protection blocks CLI merge.";
|
|
28
|
+
}
|
|
22
29
|
export async function taskMergeCommand(taskRef, options = {}) {
|
|
23
30
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
24
31
|
const dryRun = options.dryRun === true;
|
|
@@ -35,6 +42,20 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
35
42
|
}
|
|
36
43
|
throw e;
|
|
37
44
|
}
|
|
45
|
+
let mergeCfg;
|
|
46
|
+
try {
|
|
47
|
+
mergeCfg = await readMergeConfig(cwd);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
if (e instanceof MergeConfigError) {
|
|
51
|
+
log.error(e.message);
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
const requiredChecks = mergeCfg.requiredChecks;
|
|
58
|
+
const gate = { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks };
|
|
38
59
|
const target = await resolveLifecycleTarget(paths, cwd, taskRef);
|
|
39
60
|
if (target === null) {
|
|
40
61
|
if (taskRef?.trim()) {
|
|
@@ -74,6 +95,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
74
95
|
log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
|
|
75
96
|
log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
|
|
76
97
|
log.info(` ${dim("method")} ${method}`);
|
|
98
|
+
log.info(` ${dim("checks")} all green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
|
|
77
99
|
log.blank();
|
|
78
100
|
if (dryRun) {
|
|
79
101
|
await mergeMergeRequest({
|
|
@@ -81,6 +103,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
81
103
|
host,
|
|
82
104
|
url: mergeRequestUrl,
|
|
83
105
|
method,
|
|
106
|
+
requiredChecks,
|
|
107
|
+
gate,
|
|
84
108
|
dryRun: true,
|
|
85
109
|
});
|
|
86
110
|
log.blank();
|
|
@@ -112,7 +136,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
112
136
|
url: mergeRequestUrl,
|
|
113
137
|
method,
|
|
114
138
|
waitForCi: true,
|
|
115
|
-
|
|
139
|
+
requiredChecks,
|
|
140
|
+
gate,
|
|
116
141
|
});
|
|
117
142
|
const verified = await assertMergeRequestMerged({
|
|
118
143
|
cwd,
|
|
@@ -130,7 +155,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
130
155
|
catch (e) {
|
|
131
156
|
const msg = e instanceof Error ? e.message : String(e);
|
|
132
157
|
log.error(`Merge failed: ${msg}`);
|
|
133
|
-
log.info(dim(
|
|
158
|
+
log.info(dim(mergeFailureHint(e)));
|
|
134
159
|
process.exitCode = 1;
|
|
135
160
|
return;
|
|
136
161
|
}
|
|
@@ -149,7 +174,8 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
149
174
|
url: mergeRequestUrl,
|
|
150
175
|
method,
|
|
151
176
|
waitForCi: true,
|
|
152
|
-
|
|
177
|
+
requiredChecks,
|
|
178
|
+
gate,
|
|
153
179
|
});
|
|
154
180
|
const verified = await assertMergeRequestMerged({
|
|
155
181
|
cwd,
|
|
@@ -167,6 +193,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
167
193
|
catch (e) {
|
|
168
194
|
const msg = e instanceof Error ? e.message : String(e);
|
|
169
195
|
log.error(`Merge failed: ${msg}`);
|
|
196
|
+
log.info(dim(mergeFailureHint(e)));
|
|
170
197
|
process.exitCode = 1;
|
|
171
198
|
return;
|
|
172
199
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { MergeConfigError, mergeGateTiming, readMergeConfig } from "../lib/merge-config.js";
|
|
3
4
|
import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-cli.js";
|
|
4
5
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
5
6
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
6
7
|
import { bold, dim, log } from "../lib/logger.js";
|
|
7
|
-
import { createMergeRequest, getMergeRequestState, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
8
|
+
import { createMergeRequest, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
8
9
|
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
9
10
|
function resolveReleaseMergeMethod(opts) {
|
|
10
11
|
if (opts.rebase === true)
|
|
@@ -29,6 +30,19 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
29
30
|
throw e;
|
|
30
31
|
}
|
|
31
32
|
const { integrationBranch, productionBranch, remote } = gitCfg;
|
|
33
|
+
let mergeCfg;
|
|
34
|
+
try {
|
|
35
|
+
mergeCfg = await readMergeConfig(cwd);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
if (e instanceof MergeConfigError) {
|
|
39
|
+
log.error(e.message);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw e;
|
|
44
|
+
}
|
|
45
|
+
const requiredChecks = mergeCfg.releaseRequiredChecks;
|
|
32
46
|
if (integrationBranch === productionBranch) {
|
|
33
47
|
log.info("Trunk policy: integration and production are the same branch — no release PR step.");
|
|
34
48
|
return;
|
|
@@ -52,7 +66,11 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
52
66
|
log.info(bold("dry-run — would:"));
|
|
53
67
|
log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
|
|
54
68
|
if (options.noMerge !== true) {
|
|
55
|
-
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}
|
|
69
|
+
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}) only when all checks are green`);
|
|
70
|
+
log.info(` · required checks: ${requiredChecks.length > 0 ? requiredChecks.join(", ") : "(none configured)"}`);
|
|
71
|
+
if (mergeCfg.allowNoChecks) {
|
|
72
|
+
log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
return;
|
|
58
76
|
}
|
|
@@ -101,7 +119,8 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
101
119
|
url: releaseUrl,
|
|
102
120
|
method,
|
|
103
121
|
waitForCi: true,
|
|
104
|
-
|
|
122
|
+
requiredChecks,
|
|
123
|
+
gate: { ...mergeGateTiming(mergeCfg), allowNoChecks: mergeCfg.allowNoChecks },
|
|
105
124
|
});
|
|
106
125
|
const verified = await assertMergeRequestMerged({
|
|
107
126
|
cwd,
|
|
@@ -119,7 +138,12 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
119
138
|
catch (e) {
|
|
120
139
|
const msg = e instanceof Error ? e.message : String(e);
|
|
121
140
|
log.error(`Release merge failed: ${msg}`);
|
|
122
|
-
|
|
141
|
+
if (e instanceof MergeGateError) {
|
|
142
|
+
log.info(dim(`Not merged. Fix the failing checks on ${releaseUrl}, then rerun task release.`));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
log.info(dim(`Complete merge in the host UI: ${releaseUrl}`));
|
|
146
|
+
}
|
|
123
147
|
process.exitCode = 1;
|
|
124
148
|
}
|
|
125
149
|
}
|
|
@@ -15,6 +15,7 @@ import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, write
|
|
|
15
15
|
import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
|
|
16
16
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
17
17
|
import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
18
|
+
import { assertTaskIdFreeOnRemote } from "../lib/task-id-allocation.js";
|
|
18
19
|
function describePushFailure(message) {
|
|
19
20
|
const lower = message.toLowerCase();
|
|
20
21
|
if (lower.includes("protected") ||
|
|
@@ -245,6 +246,12 @@ async function runUpdateOpenPr(ctx) {
|
|
|
245
246
|
log.info(`Nothing to ship (working tree clean, PR ${prRefLabel(prCtx.number)} up to date).`);
|
|
246
247
|
return;
|
|
247
248
|
}
|
|
249
|
+
const idFree = await assertTaskIdFreeOnRemote(cwd, remote, gitCtx.taskBranch);
|
|
250
|
+
if (!idFree.ok) {
|
|
251
|
+
log.error(idFree.message);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
248
255
|
try {
|
|
249
256
|
await gitPush(cwd, remote, gitCtx.taskBranch, false);
|
|
250
257
|
log.ok(`Pushed ${gitCtx.taskBranch} → ${remote}`);
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { GitConfigError, requireGitConfig } from "../lib/git-config.js";
|
|
3
3
|
import { detectGitHost } from "../lib/git-host.js";
|
|
4
|
-
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote,
|
|
4
|
+
import { gitBranchExists, gitDeleteBranch, gitDeleteRemoteBranch, gitFetchRemote, gitRemoteBranchExists, gitRemoteUrl, readGitInfo, } from "../lib/git.js";
|
|
5
|
+
import { branchCheckedOutElsewhere, fastForwardLocalIntegration, leaveTaskBranch, logLocalIntegrationUpdate, } from "../lib/git-worktree.js";
|
|
5
6
|
import { bold, cyan, dim, log } from "../lib/logger.js";
|
|
6
7
|
import { projectPaths } from "../lib/paths.js";
|
|
7
8
|
import { taskNotFoundMessage } from "../lib/resolve-task.js";
|
|
8
9
|
import { relPath } from "../lib/task-context.js";
|
|
10
|
+
import { runUnderTaskLock } from "../lib/task-lock.js";
|
|
9
11
|
import { resolveLifecycleTarget } from "../lib/task-lifecycle-target.js";
|
|
10
12
|
import { checkTaskSyncReady } from "../lib/task-sync-guard.js";
|
|
11
13
|
import { readTaskFile } from "../lib/task.js";
|
|
@@ -13,6 +15,13 @@ function isProtectedBranch(name, integration, production) {
|
|
|
13
15
|
return name === integration || name === production || name === "main" || name === "master";
|
|
14
16
|
}
|
|
15
17
|
export async function taskSyncCommand(taskRef, options = {}) {
|
|
18
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
19
|
+
if (options.dryRun === true)
|
|
20
|
+
return taskSyncUnlocked(taskRef, options);
|
|
21
|
+
// D-006: branch switch / stash / delete are serialized across worktrees.
|
|
22
|
+
await runUnderTaskLock(cwd, "task sync", () => taskSyncUnlocked(taskRef, options));
|
|
23
|
+
}
|
|
24
|
+
async function taskSyncUnlocked(taskRef, options) {
|
|
16
25
|
const cwd = resolve(options.cwd ?? process.cwd());
|
|
17
26
|
const dryRun = options.dryRun === true;
|
|
18
27
|
const paths = projectPaths(cwd);
|
|
@@ -57,10 +66,20 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
57
66
|
if (meta.status === "shipped" && options.force !== true) {
|
|
58
67
|
log.info(dim("Would verify MR is merged and integration branch contains task commits before cleanup."));
|
|
59
68
|
}
|
|
69
|
+
const owner = (await readGitInfo(cwd)).isRepo
|
|
70
|
+
? await branchCheckedOutElsewhere(cwd, integrationBranch)
|
|
71
|
+
: null;
|
|
60
72
|
log.info(bold("dry-run — would:"));
|
|
61
73
|
log.info(` · git fetch ${remote} --prune`);
|
|
62
|
-
log.info(` ·
|
|
63
|
-
|
|
74
|
+
log.info(` · verify the MR is merged (${remote}/${integrationBranch} contains the task commits)`);
|
|
75
|
+
if (owner === null) {
|
|
76
|
+
log.info(` · if on ${taskBranch}: git switch ${integrationBranch}`);
|
|
77
|
+
log.info(` · fast-forward local ${integrationBranch} to ${remote}/${integrationBranch} (--ff-only)`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
log.info(` · if on ${taskBranch}: git switch --detach ${remote}/${integrationBranch}`);
|
|
81
|
+
log.info(dim(` · ${integrationBranch} is checked out in worktree ${owner} — left unchanged`));
|
|
82
|
+
}
|
|
64
83
|
log.info(` · git branch -D ${taskBranch}`);
|
|
65
84
|
if (options.noRemoteDelete !== true) {
|
|
66
85
|
log.info(` · git push ${remote} --delete ${taskBranch} (if exists)`);
|
|
@@ -76,9 +95,6 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
76
95
|
process.exitCode = 1;
|
|
77
96
|
return;
|
|
78
97
|
}
|
|
79
|
-
if (git.branch === taskBranch) {
|
|
80
|
-
log.info(dim(`On ${taskBranch} — switching to ${integrationBranch} first.`));
|
|
81
|
-
}
|
|
82
98
|
try {
|
|
83
99
|
await gitFetchRemote(cwd, remote);
|
|
84
100
|
log.ok(`Fetched ${remote} (--prune)`);
|
|
@@ -87,32 +103,11 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
87
103
|
const msg = e instanceof Error ? e.message : String(e);
|
|
88
104
|
log.warn(`git fetch failed (${msg}). Continuing with local refs.`);
|
|
89
105
|
}
|
|
90
|
-
const switched = await gitSwitchToBranch(cwd, integrationBranch, remote);
|
|
91
|
-
if (!switched) {
|
|
92
|
-
log.error(`Integration branch "${integrationBranch}" not found locally or on ${remote}.`);
|
|
93
|
-
process.exitCode = 1;
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
log.ok(`On ${integrationBranch}`);
|
|
97
|
-
if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
|
|
98
|
-
try {
|
|
99
|
-
await gitPullFastForwardOnly(cwd, remote, integrationBranch);
|
|
100
|
-
log.ok(`Up to date with ${remote}/${integrationBranch} (--ff-only)`);
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
104
|
-
log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
|
|
105
|
-
process.exitCode = 1;
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
log.warn(`No ${remote}/${integrationBranch} — skipped pull.`);
|
|
111
|
-
}
|
|
112
106
|
const remoteUrl = await gitRemoteUrl(cwd, remote);
|
|
113
107
|
const host = remoteUrl !== null && detectGitHost(remoteUrl) !== null
|
|
114
108
|
? detectGitHost(remoteUrl)
|
|
115
109
|
: gitCfg.host;
|
|
110
|
+
// Verify the merge BEFORE moving HEAD or touching any branch.
|
|
116
111
|
const syncGuard = await checkTaskSyncReady({
|
|
117
112
|
cwd,
|
|
118
113
|
taskFile,
|
|
@@ -131,6 +126,43 @@ export async function taskSyncCommand(taskRef, options = {}) {
|
|
|
131
126
|
return;
|
|
132
127
|
}
|
|
133
128
|
log.ok(`Ready to sync — ${integrationBranch} contains the task commits.`);
|
|
129
|
+
// Worktree-safe (D-006): leave the task branch without requiring the
|
|
130
|
+
// integration branch when another worktree has it checked out.
|
|
131
|
+
if (git.branch === taskBranch) {
|
|
132
|
+
const left = await leaveTaskBranch(cwd, remote, integrationBranch);
|
|
133
|
+
if (!left.ok) {
|
|
134
|
+
log.error(left.message);
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
log.ok(left.on === "integration" ? `On ${integrationBranch}` : `Detached at ${left.ref}`);
|
|
139
|
+
}
|
|
140
|
+
if (await gitRemoteBranchExists(cwd, remote, integrationBranch)) {
|
|
141
|
+
try {
|
|
142
|
+
const update = await fastForwardLocalIntegration(cwd, remote, integrationBranch);
|
|
143
|
+
if (update.kind === "up_to_date") {
|
|
144
|
+
log.ok(`Local ${integrationBranch} is up to date with ${remote}/${integrationBranch}`);
|
|
145
|
+
}
|
|
146
|
+
else if (update.kind === "not_fast_forward") {
|
|
147
|
+
// Same outcome as the former `git pull --ff-only` failure: stop before cleanup.
|
|
148
|
+
log.error(`Local ${integrationBranch} has commits not on ${remote}/${integrationBranch} — cannot fast-forward (never forced). Push or reset it, then rerun.`);
|
|
149
|
+
process.exitCode = 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
logLocalIntegrationUpdate(update, remote, integrationBranch);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
158
|
+
log.error(`Could not fast-forward ${integrationBranch}: ${msg}. Resolve locally, then rerun.`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
log.warn(`No ${remote}/${integrationBranch} — skipped fast-forward.`);
|
|
165
|
+
}
|
|
134
166
|
if (await gitBranchExists(cwd, taskBranch)) {
|
|
135
167
|
try {
|
|
136
168
|
// Guard verified integration contains the work — squash merges leave task/* SHAs
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify GitHub `statusCheckRollup` items and evaluate the merge gate
|
|
3
|
+
* (`task merge` / `task release` refuse to merge on red or missing checks).
|
|
4
|
+
*
|
|
5
|
+
* GitHub returns two item shapes:
|
|
6
|
+
* - `CheckRun` (GitHub Actions, check apps): `status` + `conclusion`, no `state`.
|
|
7
|
+
* - `StatusContext` (commit status API): `state`, no `status`/`conclusion`.
|
|
8
|
+
*/
|
|
9
|
+
/** GitHub GraphQL `CheckStatusState` values other than COMPLETED. */
|
|
10
|
+
const CHECK_RUN_IN_FLIGHT = new Set(["REQUESTED", "QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
|
|
11
|
+
/** GitHub GraphQL `CheckConclusionState`, split into pass / fail (exhaustive). */
|
|
12
|
+
const CHECK_RUN_PASS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
|
|
13
|
+
const CHECK_RUN_FAIL = new Set([
|
|
14
|
+
"FAILURE",
|
|
15
|
+
"CANCELLED",
|
|
16
|
+
"TIMED_OUT",
|
|
17
|
+
"ACTION_REQUIRED",
|
|
18
|
+
"STARTUP_FAILURE",
|
|
19
|
+
"STALE",
|
|
20
|
+
]);
|
|
21
|
+
/** GitHub GraphQL `StatusState` (exhaustive): SUCCESS | PENDING | EXPECTED | FAILURE | ERROR. */
|
|
22
|
+
const STATUS_CONTEXT_PENDING = new Set(["PENDING", "EXPECTED"]);
|
|
23
|
+
const STATUS_CONTEXT_FAIL = new Set(["FAILURE", "ERROR"]);
|
|
24
|
+
function upper(raw) {
|
|
25
|
+
return (raw ?? "").trim().toUpperCase();
|
|
26
|
+
}
|
|
27
|
+
function isCheckRun(item) {
|
|
28
|
+
if (item.__typename === "CheckRun")
|
|
29
|
+
return true;
|
|
30
|
+
if (item.__typename === "StatusContext")
|
|
31
|
+
return false;
|
|
32
|
+
// Unknown typename: infer from fields.
|
|
33
|
+
return (item.status ?? null) !== null || (item.conclusion ?? null) !== null;
|
|
34
|
+
}
|
|
35
|
+
export function classifyRollupItem(item) {
|
|
36
|
+
if (isCheckRun(item)) {
|
|
37
|
+
const name = (item.name ?? "").trim() || "(unnamed check run)";
|
|
38
|
+
const status = upper(item.status);
|
|
39
|
+
const conclusion = upper(item.conclusion);
|
|
40
|
+
if (CHECK_RUN_IN_FLIGHT.has(status)) {
|
|
41
|
+
return { name, kind: "CheckRun", verdict: "pending", detail: status };
|
|
42
|
+
}
|
|
43
|
+
if (status !== "COMPLETED") {
|
|
44
|
+
// Missing or undocumented status — fail closed.
|
|
45
|
+
return { name, kind: "CheckRun", verdict: "failed", detail: status || "MISSING_STATUS" };
|
|
46
|
+
}
|
|
47
|
+
if (CHECK_RUN_PASS.has(conclusion)) {
|
|
48
|
+
return { name, kind: "CheckRun", verdict: "passed", detail: conclusion };
|
|
49
|
+
}
|
|
50
|
+
if (CHECK_RUN_FAIL.has(conclusion)) {
|
|
51
|
+
return { name, kind: "CheckRun", verdict: "failed", detail: conclusion };
|
|
52
|
+
}
|
|
53
|
+
// Completed with a missing or undocumented conclusion — fail closed.
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
kind: "CheckRun",
|
|
57
|
+
verdict: "failed",
|
|
58
|
+
detail: conclusion || "MISSING_CONCLUSION",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const name = (item.context ?? item.name ?? "").trim() || "(unnamed status)";
|
|
62
|
+
const state = upper(item.state);
|
|
63
|
+
if (state === "SUCCESS") {
|
|
64
|
+
return { name, kind: "StatusContext", verdict: "passed", detail: state };
|
|
65
|
+
}
|
|
66
|
+
if (STATUS_CONTEXT_PENDING.has(state)) {
|
|
67
|
+
return { name, kind: "StatusContext", verdict: "pending", detail: state };
|
|
68
|
+
}
|
|
69
|
+
if (STATUS_CONTEXT_FAIL.has(state)) {
|
|
70
|
+
return { name, kind: "StatusContext", verdict: "failed", detail: state };
|
|
71
|
+
}
|
|
72
|
+
// Missing or undocumented state — fail closed.
|
|
73
|
+
return { name, kind: "StatusContext", verdict: "failed", detail: state || "MISSING_STATE" };
|
|
74
|
+
}
|
|
75
|
+
export function classifyRollup(items) {
|
|
76
|
+
return (items ?? []).map(classifyRollupItem);
|
|
77
|
+
}
|
|
78
|
+
/** Exact name, or a `*` glob (e.g. `lint*`, `* · linux`). */
|
|
79
|
+
export function checkNameMatches(name, pattern) {
|
|
80
|
+
const p = pattern.trim();
|
|
81
|
+
if (p.length === 0)
|
|
82
|
+
return false;
|
|
83
|
+
if (!p.includes("*"))
|
|
84
|
+
return name === p;
|
|
85
|
+
const re = new RegExp(`^${p
|
|
86
|
+
.split("*")
|
|
87
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
88
|
+
.join(".*")}$`);
|
|
89
|
+
return re.test(name);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Evaluate classified checks against optional required patterns.
|
|
93
|
+
* - any failed → red
|
|
94
|
+
* - any pending, or a required check not (yet) present → pending
|
|
95
|
+
* - otherwise green (including "no checks at all" when nothing is required)
|
|
96
|
+
*/
|
|
97
|
+
export function evaluateCheckGate(checks, requiredChecks = []) {
|
|
98
|
+
const failed = checks.filter((c) => c.verdict === "failed");
|
|
99
|
+
const pending = checks.filter((c) => c.verdict === "pending");
|
|
100
|
+
const passed = checks.filter((c) => c.verdict === "passed");
|
|
101
|
+
const missingRequired = requiredChecks
|
|
102
|
+
.map((p) => p.trim())
|
|
103
|
+
.filter((p) => p.length > 0)
|
|
104
|
+
.filter((p) => !checks.some((c) => checkNameMatches(c.name, p)));
|
|
105
|
+
let state = "green";
|
|
106
|
+
if (failed.length > 0)
|
|
107
|
+
state = "red";
|
|
108
|
+
else if (pending.length > 0 || missingRequired.length > 0)
|
|
109
|
+
state = "pending";
|
|
110
|
+
return { state, failed, pending, passed, missingRequired };
|
|
111
|
+
}
|
|
112
|
+
function describe(c) {
|
|
113
|
+
return `"${c.name}" (${c.detail})`;
|
|
114
|
+
}
|
|
115
|
+
/** Human-readable lines for a refused merge. */
|
|
116
|
+
export function formatCheckGateProblems(gate) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
if (gate.failed.length > 0) {
|
|
119
|
+
lines.push(`failed checks: ${gate.failed.map(describe).join(", ")}`);
|
|
120
|
+
}
|
|
121
|
+
if (gate.pending.length > 0) {
|
|
122
|
+
lines.push(`checks still pending: ${gate.pending.map(describe).join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
for (const p of gate.missingRequired) {
|
|
125
|
+
lines.push(`required check "${p}" never ran`);
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -64,6 +64,7 @@ export async function readConfig(root) {
|
|
|
64
64
|
parsed.schemaVersion === VIBEOPS_CONFIG_SCHEMA_VERSION) {
|
|
65
65
|
const llm = parseLlmBlock(parsed.llm);
|
|
66
66
|
const git = parseGitBlock(parsed.git);
|
|
67
|
+
const merge = parsed.merge;
|
|
67
68
|
const clients = parseClientsBlock(parsed.clients) ?? ["cursor"];
|
|
68
69
|
return {
|
|
69
70
|
name: parsed.name,
|
|
@@ -73,6 +74,7 @@ export async function readConfig(root) {
|
|
|
73
74
|
clients,
|
|
74
75
|
...(git ? { git } : {}),
|
|
75
76
|
...(llm ? { llm } : {}),
|
|
77
|
+
...(merge !== undefined ? { merge } : {}),
|
|
76
78
|
};
|
|
77
79
|
}
|
|
78
80
|
return null;
|
|
@@ -90,6 +92,7 @@ export function buildConfig(name, clients, git, existing, nowIso) {
|
|
|
90
92
|
clients,
|
|
91
93
|
git,
|
|
92
94
|
llm: existing?.llm ?? { provider: "auto" },
|
|
95
|
+
...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
|
|
93
96
|
};
|
|
94
97
|
}
|
|
95
98
|
export function configToJson(config) {
|