@goodtek/vibeops 3.1.3 → 3.1.5
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/README.md +1 -0
- package/dist/commands/task-merge.js +6 -0
- package/dist/lib/merge-base-freshness.js +55 -0
- package/dist/lib/pr-create.js +15 -0
- package/dist/lib/task-git-commit.js +9 -4
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.1.5 - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`task merge` merged stale branches.** It now refuses unless the head commit the gate approved contains the current `<remote>/<base>` (`git merge-base --is-ancestor` after fetching both); the error says how many commits behind and how to update. Observed in a consumer repo: a branch rebuilt with `git reset --soft` on a stale `origin/develop` was squash-merged after `develop` had moved and silently reverted other TASKs' work. Fetch errors refuse. `task release` is unchanged (D-008, TASK-029).
|
|
12
|
+
- **`task ship -m` joined multi-line messages** into one line, so a `Co-Authored-By:` trailer ended up in the subject. Only the subject is TASK-scoped now; the body and trailers are kept (TASK-029).
|
|
13
|
+
|
|
14
|
+
### Security
|
|
15
|
+
|
|
16
|
+
- `js-yaml` 3.14.2 → 3.15.2 (via `gray-matter`; GHSA-52cp-r559-cp3m, GHSA-5p4m-2wfm-xmqj, GHSA-2883-xcg3-v3hh, GHSA-h67p-54hq-rp68). Dev: `tsx` ^4.23.15 → `esbuild` 0.28.2 (GHSA-g7r4-m6w7-qqqr). `sprintf-js` (GHSA-hp3w-g68c-fv3c) has no fixed release; it is only used by `js-yaml`'s command-line tool, which vibeops does not run (TASK-027).
|
|
17
|
+
|
|
18
|
+
## 3.1.4 - 2026-10-16
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- No CLI changes. First release published from GitHub Actions (`publish.yml`, npm Trusted Publishing with provenance) instead of a maintainer machine.
|
|
23
|
+
|
|
7
24
|
## 3.1.3 - 2026-10-16
|
|
8
25
|
|
|
9
26
|
### Fixed
|
package/README.md
CHANGED
|
@@ -223,6 +223,7 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
223
223
|
- **No checks at all → refuse** unless `allowNoChecks: true`. "No checks" means: empty rollup, no GitHub Actions run and no expected workflow for the head commit (GitLab: no pipeline). For CI outside GitHub Actions (commit statuses / check apps), the empty list is re-polled for `emptyRollupGraceSeconds` first; list such checks in `requiredChecks` so the gate waits for them by name.
|
|
224
224
|
- **Waiting** polls the host (`gh pr view` + the Actions REST API) every `pollIntervalSeconds` (default 10 s, the default of `gh pr checks --watch`) until the state changes, bounded by `waitTimeoutSeconds` (default 900 s). Host read errors back off exponentially with jitter (capped at 60 s, GitHub's guidance for rate-limited clients).
|
|
225
225
|
- **The merge is pinned to the checked commit:** right before merging the gate re-reads the PR head; if it moved, the new head is evaluated from scratch. The merge itself uses `gh pr merge --match-head-commit <sha>` (requires **gh ≥ 2.13.0**) / `glab mr merge --auto-merge=false --sha <sha>` (verified with glab 1.106), so a push between the last check and the merge makes the host refuse. A GitLab `405` re-runs the gate and retries once with the same flags.
|
|
226
|
+
- **`task merge` refuses a stale base:** the head commit the gate approved must contain the current `<remote>/<base>` (`git merge-base --is-ancestor`, after fetching the base branch and the head commit). A head that does not — the base moved since the branch was pushed, or the branch was rebuilt on an old base (e.g. `git reset --soft <stale origin/develop>` + commit) — can silently revert work merged into the base since, and a squash merge lands that revert. Bring the branch up to date (`git fetch` + `git merge <remote>/<base>`, test, `task ship`), wait for CI, then rerun `task merge`. A fetch failure refuses (fail closed). `task release` does not apply this check (with merge-commit releases the production branch is normally not an ancestor of the integration branch).
|
|
226
227
|
|
|
227
228
|
### Configuration (`.vibeops.json` → `merge`)
|
|
228
229
|
|
|
@@ -22,6 +22,9 @@ function resolveMergeMethod(opts) {
|
|
|
22
22
|
}
|
|
23
23
|
function mergeFailureHint(error) {
|
|
24
24
|
if (error instanceof MergeGateError) {
|
|
25
|
+
if (error.problems.some((p) => p.startsWith("stale base:"))) {
|
|
26
|
+
return "Not merged. Bring the task branch up to date with its base (see above), push, wait for CI, then rerun `vibeops task merge`.";
|
|
27
|
+
}
|
|
25
28
|
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
29
|
}
|
|
27
30
|
return "Merge in the host UI if branch protection blocks CLI merge.";
|
|
@@ -105,6 +108,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
105
108
|
method,
|
|
106
109
|
requiredChecks,
|
|
107
110
|
gate,
|
|
111
|
+
requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
|
|
108
112
|
dryRun: true,
|
|
109
113
|
});
|
|
110
114
|
log.blank();
|
|
@@ -138,6 +142,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
138
142
|
waitForCi: true,
|
|
139
143
|
requiredChecks,
|
|
140
144
|
gate,
|
|
145
|
+
requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
|
|
141
146
|
});
|
|
142
147
|
const verified = await assertMergeRequestMerged({
|
|
143
148
|
cwd,
|
|
@@ -176,6 +181,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
176
181
|
waitForCi: true,
|
|
177
182
|
requiredChecks,
|
|
178
183
|
gate,
|
|
184
|
+
requireBaseInHead: { remote: gitCfg.remote, baseBranch: ctx.baseBranch },
|
|
179
185
|
});
|
|
180
186
|
const verified = await assertMergeRequestMerged({
|
|
181
187
|
cwd,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { gitIsAncestor, runGit } from "./git.js";
|
|
2
|
+
/**
|
|
3
|
+
* The MR/PR head must contain the current tip of its base branch.
|
|
4
|
+
*
|
|
5
|
+
* A head that does not (base moved, or the branch was rebuilt on a stale base,
|
|
6
|
+
* e.g. `git reset --soft <old base>` + commit) can silently revert work merged
|
|
7
|
+
* into the base since; a squash merge then lands that revert. Fetches the base
|
|
8
|
+
* branch and the head commit first; any fetch / read failure fails closed.
|
|
9
|
+
*/
|
|
10
|
+
export async function checkBaseInHead(opts) {
|
|
11
|
+
const { cwd, remote, baseBranch, headSha } = opts;
|
|
12
|
+
const baseRef = `refs/remotes/${remote}/${baseBranch}`;
|
|
13
|
+
try {
|
|
14
|
+
await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
|
|
15
|
+
if (!(await commitExists(cwd, headSha))) {
|
|
16
|
+
await runGit(cwd, ["fetch", "--no-tags", remote, headSha]);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
problem: `could not fetch ${remote}/${baseBranch} and head ${short(headSha)} to check the base is current (${msg})`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (await gitIsAncestor(cwd, baseRef, headSha))
|
|
27
|
+
return { ok: true };
|
|
28
|
+
let behind = "";
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await runGit(cwd, ["rev-list", "--count", `${headSha}..${baseRef}`]);
|
|
31
|
+
behind = ` (${stdout.trim()} commit(s) behind)`;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// count is informational only
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
problem: `stale base: head ${short(headSha)} does not contain ${remote}/${baseBranch}${behind}. ` +
|
|
39
|
+
`Merging it can silently revert work merged into ${baseBranch} since. ` +
|
|
40
|
+
`Update the task branch (git fetch ${remote} && git merge ${remote}/${baseBranch}, resolve, test), ` +
|
|
41
|
+
`push it (vibeops task ship), wait for CI, then rerun vibeops task merge.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function commitExists(cwd, sha) {
|
|
45
|
+
try {
|
|
46
|
+
await runGit(cwd, ["cat-file", "-e", `${sha}^{commit}`]);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function short(sha) {
|
|
54
|
+
return sha.slice(0, 7);
|
|
55
|
+
}
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -5,6 +5,7 @@ import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./c
|
|
|
5
5
|
import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
|
|
6
6
|
import { evaluateActionsRuns, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
|
|
7
7
|
import { dim, log } from "./logger.js";
|
|
8
|
+
import { checkBaseInHead } from "./merge-base-freshness.js";
|
|
8
9
|
import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
async function ghPrCreate(opts) {
|
|
@@ -607,6 +608,10 @@ export async function mergeMergeRequest(opts) {
|
|
|
607
608
|
? ` + required: ${opts.requiredChecks.join(", ")}`
|
|
608
609
|
: "";
|
|
609
610
|
log.info(`would check ${label} status checks (all green${required}) before merging`);
|
|
611
|
+
if (opts.requireBaseInHead !== undefined) {
|
|
612
|
+
const { remote, baseBranch } = opts.requireBaseInHead;
|
|
613
|
+
log.info(`would refuse unless the head commit contains ${remote}/${baseBranch} (stale base)`);
|
|
614
|
+
}
|
|
610
615
|
if (opts.host === "gitlab") {
|
|
611
616
|
log.info(`would ${label} merge ${ref} (glab ${gitLabMergeArgs(ref, method, "<head sha>").join(" ")})`);
|
|
612
617
|
}
|
|
@@ -629,6 +634,16 @@ export async function mergeMergeRequest(opts) {
|
|
|
629
634
|
// Strict read already guarantees this; never merge unpinned.
|
|
630
635
|
throw new MergeGateError(label, ["host did not report the head commit SHA"]);
|
|
631
636
|
}
|
|
637
|
+
if (opts.requireBaseInHead !== undefined) {
|
|
638
|
+
const fresh = await checkBaseInHead({
|
|
639
|
+
cwd: opts.cwd,
|
|
640
|
+
remote: opts.requireBaseInHead.remote,
|
|
641
|
+
baseBranch: gate.details.baseBranch ?? opts.requireBaseInHead.baseBranch,
|
|
642
|
+
headSha: gate.details.headSha,
|
|
643
|
+
});
|
|
644
|
+
if (!fresh.ok)
|
|
645
|
+
throw new MergeGateError(label, [fresh.problem]);
|
|
646
|
+
}
|
|
632
647
|
return gate.details;
|
|
633
648
|
};
|
|
634
649
|
const details = await runGate();
|
|
@@ -16,20 +16,25 @@ const CONVENTIONAL_COMMIT_RE = /^([a-z]+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/is;
|
|
|
16
16
|
* - `"fix(login): handle null"` → `fix(task-001): handle null` (scope rewritten)
|
|
17
17
|
* - `"handle null"` → `feat(task-001): handle null` (defaultType)
|
|
18
18
|
* - already-scoped correct messages are returned unchanged.
|
|
19
|
+
* - only the subject (first line) is scoped; a body and trailers after it
|
|
20
|
+
* (e.g. `Co-Authored-By:`) are kept line by line.
|
|
19
21
|
*/
|
|
20
22
|
export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat") {
|
|
21
23
|
const scope = taskId.toLowerCase();
|
|
22
|
-
const
|
|
24
|
+
const lines = rawMessage.replace(/\r\n?/g, "\n").trim().split("\n");
|
|
25
|
+
const msg = (lines[0] ?? "").replace(/\s+/g, " ").trim();
|
|
26
|
+
const body = lines.slice(1).map((l) => l.trimEnd()).join("\n").replace(/^\n+/, "");
|
|
27
|
+
const withBody = (subject) => (body.length > 0 ? `${subject}\n\n${body}` : subject);
|
|
23
28
|
if (msg.length === 0)
|
|
24
|
-
return `${defaultType}(${scope}): ${taskId}
|
|
29
|
+
return withBody(`${defaultType}(${scope}): ${taskId}`);
|
|
25
30
|
const m = CONVENTIONAL_COMMIT_RE.exec(msg);
|
|
26
31
|
if (m) {
|
|
27
32
|
const type = m[1].toLowerCase();
|
|
28
33
|
const bang = m[3] ?? "";
|
|
29
34
|
const subject = m[4].trim();
|
|
30
|
-
return `${type}(${scope})${bang}: ${subject}
|
|
35
|
+
return withBody(`${type}(${scope})${bang}: ${subject}`);
|
|
31
36
|
}
|
|
32
|
-
return `${defaultType}(${scope}): ${msg}
|
|
37
|
+
return withBody(`${defaultType}(${scope}): ${msg}`);
|
|
33
38
|
}
|
|
34
39
|
/**
|
|
35
40
|
* Commit all committable dirty paths (staged and unstaged, including deletions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodtek/vibeops",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.5",
|
|
4
4
|
"description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@9.15.9",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"@eslint/js": "10.0.1",
|
|
64
64
|
"@types/node": "^20.14.0",
|
|
65
65
|
"eslint": "10.11.0",
|
|
66
|
-
"tsx": "^4.
|
|
66
|
+
"tsx": "^4.23.15",
|
|
67
67
|
"typescript": "^5.5.4",
|
|
68
68
|
"typescript-eslint": "8.71.0"
|
|
69
69
|
}
|