@goodtek/vibeops 3.1.5 → 3.1.7
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 +15 -0
- package/README.md +2 -1
- package/dist/commands/task-add.js +10 -1
- package/dist/commands/task-release.js +34 -11
- package/dist/commands/task-ship.js +12 -8
- package/dist/lib/git.js +3 -1
- package/dist/lib/merge-base-freshness.js +7 -1
- package/dist/lib/task-new-cycle.js +49 -7
- package/dist/lib/task-push-pr.js +22 -5
- package/dist/lib/task.js +43 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,21 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.1.7 - 2026-10-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`task ship --new-cycle` missed work already committed on the task branch** ("Nothing to do — merged with no new changes"). With no uncommitted files it now compares the branch with the fetched `<remote>/<base>` by content (earlier cycles were squash-merged, so commit counts would mislead) and opens the new PR for what is not merged yet (TASK-032).
|
|
12
|
+
- **`-m` was ignored in a new PR cycle**, so the commit used the TASK title and dropped trailers. The new cycle now resolves the message like a normal ship (body and trailers kept); `-m` with nothing to commit warns instead of making an empty commit (TASK-032).
|
|
13
|
+
- **PR body listed other TASKs' changes** after the base branch was merged into the task branch: the Commits / Files lists came from the TASK's Base Commit. They are now computed against the freshly fetched `<remote>/<base>` (commits without merges, files from the merge base); the Base Commit is only a fallback when the fetch fails (TASK-031).
|
|
14
|
+
|
|
15
|
+
## 3.1.6 - 2026-10-17
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`task release` failed when the release PR was already open.** It now reuses the open integration → production PR; a failing lookup refuses. The dry-run now says the merge waits for the PR's own CI runs (it already did since 3.1.2) and shows `requiredChecks` as an extra by-name list (TASK-030).
|
|
20
|
+
- **An unreadable Git Context was skipped quietly.** `task ship` printed "push/MR skipped" and exited 0; it now exits 1 before committing or pushing and names the problem (e.g. ``line 160 is `## ## Git Context` ``). `task add` no longer "resumes" an In Progress TASK whose Git Context is damaged — that wrote a second section and blocked new TASKs in every worktree (TASK-030).
|
|
21
|
+
|
|
7
22
|
## 3.1.5 - 2026-10-16
|
|
8
23
|
|
|
9
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -158,7 +158,7 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
|
|
|
158
158
|
- **`task merge`**: merge MR/PR into integration (TASK md unchanged) — **only when every check is green** (see [Merge gate](#merge-gate)).
|
|
159
159
|
- **`task sync`**: verify merge → leave the task branch (switch to integration, or detach at `<remote>/<integration>` when another worktree owns it) → fast-forward local integration where safe → delete local + remote `task/*` branch (TASK md unchanged).
|
|
160
160
|
- **`pull`**: fetch + switch to integration branch + `git pull --ff-only` (one command). Refuses (exit 1) when another worktree has the integration branch checked out — run it there.
|
|
161
|
-
- **`task release`**: integration → production PR + merge (skipped on trunk policy).
|
|
161
|
+
- **`task release`**: integration → production PR + merge (skipped on trunk policy). Reuses the open release PR when there is one; the merge waits for that PR's own CI like `task merge`.
|
|
162
162
|
- **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
|
|
163
163
|
- No force-push to shared branches.
|
|
164
164
|
|
|
@@ -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
|
+
- **Unreadable Git Context stops `task ship` / `task add`:** a TASK file whose `## Git Context` cannot be read (e.g. an edit left `## ## Git Context`) makes `task ship` exit 1 before committing or pushing, naming the line; `task add` refuses to "resume" such a TASK (it would write a second section) and names the file and line.
|
|
226
227
|
- **`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).
|
|
227
228
|
|
|
228
229
|
### Configuration (`.vibeops.json` → `merge`)
|
|
@@ -12,7 +12,7 @@ import { findBlockingTask, relPath } from "../lib/task-context.js";
|
|
|
12
12
|
import { allocateTaskId, TaskIdAllocationError } from "../lib/task-id-allocation.js";
|
|
13
13
|
import { readTaskLockConfig, TaskLockConfigError, TaskLockHeldError, withTaskLock, } from "../lib/task-lock.js";
|
|
14
14
|
import { isIncompleteTaskStart, startTaskBranch } from "../lib/task-start.js";
|
|
15
|
-
import { branchNameForTaskFile, loadActionableTasks, readGitContext } from "../lib/task.js";
|
|
15
|
+
import { branchNameForTaskFile, damagedGitContext, loadActionableTasks, readGitContext, } from "../lib/task.js";
|
|
16
16
|
async function loadGitConfigOrNull(root, dryRun) {
|
|
17
17
|
try {
|
|
18
18
|
return await requireGitConfig(root);
|
|
@@ -98,6 +98,15 @@ export async function taskAddCommand(opts = {}) {
|
|
|
98
98
|
const blocking = await findBlockingTask(paths, root);
|
|
99
99
|
// Resume: In Progress file exists but task branch / Git Context was never finished.
|
|
100
100
|
if (blocking !== null && gitCfg !== null) {
|
|
101
|
+
// A damaged Git Context is not an unfinished add: resuming would write a second section.
|
|
102
|
+
const damaged = await damagedGitContext(blocking.filePath);
|
|
103
|
+
if (damaged !== null) {
|
|
104
|
+
log.error(`${bold(blocking.id)} is In Progress but its Git Context cannot be read: ${damaged}.`);
|
|
105
|
+
log.info(` ${dim("file")} ${relPath(root, blocking.filePath)}`);
|
|
106
|
+
log.info(dim("No TASK was created. Fix the TASK file (then ship it), and rerun task add."));
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return "done";
|
|
109
|
+
}
|
|
101
110
|
const incomplete = await isIncompleteTaskStart(root, blocking.filePath);
|
|
102
111
|
if (incomplete) {
|
|
103
112
|
log.warn(`${yellow("Incomplete")} — ${bold(blocking.id)} exists but the task branch was not created.`);
|
|
@@ -5,7 +5,7 @@ import { formatHostCliHint, formatHostCliMissingMessage } from "../lib/git-host-
|
|
|
5
5
|
import { detectGitHost, mergeRequestLabel } from "../lib/git-host.js";
|
|
6
6
|
import { gitRemoteUrl } from "../lib/git.js";
|
|
7
7
|
import { bold, dim, log } from "../lib/logger.js";
|
|
8
|
-
import { createMergeRequest, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
8
|
+
import { createMergeRequest, findMergeRequestByBranches, getMergeRequestState, MergeGateError, mergeMergeRequest, probeMergeRequestCli, } from "../lib/pr-create.js";
|
|
9
9
|
import { assertMergeRequestMerged } from "../lib/task-merge-verify.js";
|
|
10
10
|
function resolveReleaseMergeMethod(opts) {
|
|
11
11
|
if (opts.rebase === true)
|
|
@@ -65,9 +65,10 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
65
65
|
if (dryRun) {
|
|
66
66
|
log.info(bold("dry-run — would:"));
|
|
67
67
|
log.info(` · ${label}: ${integrationBranch} → ${productionBranch}`);
|
|
68
|
+
log.info(` · reuse the open ${label} ${integrationBranch} → ${productionBranch} if there is one`);
|
|
68
69
|
if (options.noMerge !== true) {
|
|
69
|
-
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}) only when
|
|
70
|
-
log.info(` · required checks: ${requiredChecks.length > 0 ? requiredChecks.join(", ") : "
|
|
70
|
+
log.info(` · merge ${label} (${resolveReleaseMergeMethod(options)}) only when every check on its head commit is green — waits for the ${label}'s own CI runs`);
|
|
71
|
+
log.info(` · required checks (by name, in addition): ${requiredChecks.length > 0 ? requiredChecks.join(", ") : "none configured"}`);
|
|
71
72
|
if (mergeCfg.allowNoChecks) {
|
|
72
73
|
log.info(" · allowNoChecks: true (a release PR with no checks would merge)");
|
|
73
74
|
}
|
|
@@ -84,24 +85,46 @@ export async function taskReleaseCommand(options = {}) {
|
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
87
|
let releaseUrl;
|
|
88
|
+
let open;
|
|
87
89
|
try {
|
|
88
|
-
|
|
90
|
+
open = await findMergeRequestByBranches({
|
|
89
91
|
cwd,
|
|
90
92
|
host,
|
|
91
|
-
baseBranch: productionBranch,
|
|
92
93
|
headBranch: integrationBranch,
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
baseBranch: productionBranch,
|
|
95
|
+
state: "open",
|
|
95
96
|
});
|
|
96
|
-
releaseUrl = url;
|
|
97
|
-
log.ok(`${label}: ${url}`);
|
|
98
97
|
}
|
|
99
98
|
catch (e) {
|
|
100
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
101
|
-
log.error(`Could not
|
|
99
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
100
|
+
log.error(`Could not look up an open release ${label}: ${msg}`);
|
|
102
101
|
process.exitCode = 1;
|
|
103
102
|
return;
|
|
104
103
|
}
|
|
104
|
+
if (open !== null) {
|
|
105
|
+
releaseUrl = open.url;
|
|
106
|
+
log.ok(`${label} already open — reusing: ${releaseUrl}`);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
try {
|
|
110
|
+
const { url } = await createMergeRequest({
|
|
111
|
+
cwd,
|
|
112
|
+
host,
|
|
113
|
+
baseBranch: productionBranch,
|
|
114
|
+
headBranch: integrationBranch,
|
|
115
|
+
title,
|
|
116
|
+
body,
|
|
117
|
+
});
|
|
118
|
+
releaseUrl = url;
|
|
119
|
+
log.ok(`${label}: ${url}`);
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
123
|
+
log.error(`Could not create release ${label}: ${msg}`);
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
105
128
|
if (options.noMerge === true) {
|
|
106
129
|
log.info(dim("Merge skipped (--no-merge)."));
|
|
107
130
|
return;
|
|
@@ -14,7 +14,7 @@ import { runPostMergeNewCycle } from "../lib/task-new-cycle.js";
|
|
|
14
14
|
import { applyTaskShipMemory, fallbackResultSections, llmCompleteTaskShip, writeTaskResultSections, } from "../lib/task-ship-llm.js";
|
|
15
15
|
import { detectShipPrState, prRefLabel, resolveShipAction, } from "../lib/task-ship-state.js";
|
|
16
16
|
import { finishTaskWithPullRequest } from "../lib/task-push-pr.js";
|
|
17
|
-
import { hasNonEmptySection, parseTaskFilename, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
17
|
+
import { hasNonEmptySection, parseTaskFilename, gitContextProblem, readGitContext, readTaskFile, updateInlineStatus, } from "../lib/task.js";
|
|
18
18
|
import { assertTaskIdFreeOnRemote } from "../lib/task-id-allocation.js";
|
|
19
19
|
function describePushFailure(message) {
|
|
20
20
|
const lower = message.toLowerCase();
|
|
@@ -111,6 +111,13 @@ async function runFirstShip(ctx) {
|
|
|
111
111
|
process.exitCode = 1;
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
|
+
const gitCtx = await readGitContext(taskFile);
|
|
115
|
+
if (gitCtx === null) {
|
|
116
|
+
log.error(`No Git Context in ${relPath(cwd, taskFile)}: ${await gitContextProblem(taskFile)}. Nothing was committed or pushed.`);
|
|
117
|
+
log.info(dim("`vibeops task add` writes this section with the task branch; restore it, then rerun task ship."));
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
114
121
|
if (dryRun) {
|
|
115
122
|
log.info(bold("dry-run — would:"));
|
|
116
123
|
if (!resultOk || !testOk) {
|
|
@@ -128,8 +135,7 @@ async function runFirstShip(ctx) {
|
|
|
128
135
|
});
|
|
129
136
|
return;
|
|
130
137
|
}
|
|
131
|
-
|
|
132
|
-
if (gitCtx !== null && !(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
|
|
138
|
+
if (!(await ensureOnTaskBranch(cwd, gitCtx, dryRun))) {
|
|
133
139
|
process.exitCode = 1;
|
|
134
140
|
return;
|
|
135
141
|
}
|
|
@@ -150,10 +156,6 @@ async function runFirstShip(ctx) {
|
|
|
150
156
|
});
|
|
151
157
|
await commitDirtyWorkingTree(cwd, message, false);
|
|
152
158
|
}
|
|
153
|
-
if (gitCtx === null) {
|
|
154
|
-
log.info(dim("No Git Context — push/MR skipped."));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
159
|
await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
|
|
158
160
|
const prResult = await finishTaskWithPullRequest({
|
|
159
161
|
cwd,
|
|
@@ -183,7 +185,7 @@ async function runUpdateOpenPr(ctx) {
|
|
|
183
185
|
const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
|
|
184
186
|
const gitCtx = await readGitContext(taskFile);
|
|
185
187
|
if (gitCtx === null) {
|
|
186
|
-
log.error(
|
|
188
|
+
log.error(`No Git Context — cannot update the open PR: ${await gitContextProblem(taskFile)}.`);
|
|
187
189
|
process.exitCode = 1;
|
|
188
190
|
return;
|
|
189
191
|
}
|
|
@@ -295,6 +297,8 @@ async function runNewCycleFromShip(ctx) {
|
|
|
295
297
|
recreateBranch: options.recreateBranch,
|
|
296
298
|
skipLlm: options.skipLlm,
|
|
297
299
|
allowOpenMr: options.allowOpenMr,
|
|
300
|
+
message: options.message,
|
|
301
|
+
nonInteractive: options.nonInteractive,
|
|
298
302
|
});
|
|
299
303
|
}
|
|
300
304
|
/** State/PR mismatch: Status is Shipped but no open/merged PR was found. */
|
package/dist/lib/git.js
CHANGED
|
@@ -490,8 +490,10 @@ export async function gitAllChangedFilesSinceTaskStart(baseCommit, cwd) {
|
|
|
490
490
|
const all = Array.from(new Set([...workingTree, ...committed]));
|
|
491
491
|
return { working, staged, untracked, committed, workingTree, all };
|
|
492
492
|
}
|
|
493
|
-
export async function gitLogOneline(cwd, range) {
|
|
493
|
+
export async function gitLogOneline(cwd, range, options = {}) {
|
|
494
494
|
const args = ["log", "--oneline", "--no-decorate"];
|
|
495
|
+
if (options.noMerges === true)
|
|
496
|
+
args.push("--no-merges");
|
|
495
497
|
if (typeof range === "string" && range.length > 0)
|
|
496
498
|
args.push(range);
|
|
497
499
|
const res = await tryGit(cwd, args);
|
|
@@ -7,11 +7,17 @@ import { gitIsAncestor, runGit } from "./git.js";
|
|
|
7
7
|
* into the base since; a squash merge then lands that revert. Fetches the base
|
|
8
8
|
* branch and the head commit first; any fetch / read failure fails closed.
|
|
9
9
|
*/
|
|
10
|
+
/** Fetch `<remote>/<baseBranch>` into its remote-tracking ref and return that ref. Throws on failure. */
|
|
11
|
+
export async function fetchBaseBranch(cwd, remote, baseBranch) {
|
|
12
|
+
const baseRef = `refs/remotes/${remote}/${baseBranch}`;
|
|
13
|
+
await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
|
|
14
|
+
return baseRef;
|
|
15
|
+
}
|
|
10
16
|
export async function checkBaseInHead(opts) {
|
|
11
17
|
const { cwd, remote, baseBranch, headSha } = opts;
|
|
12
18
|
const baseRef = `refs/remotes/${remote}/${baseBranch}`;
|
|
13
19
|
try {
|
|
14
|
-
await
|
|
20
|
+
await fetchBaseBranch(cwd, remote, baseBranch);
|
|
15
21
|
if (!(await commitExists(cwd, headSha))) {
|
|
16
22
|
await runGit(cwd, ["fetch", "--no-tags", remote, headSha]);
|
|
17
23
|
}
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { readText } from "./filesystem.js";
|
|
3
|
-
import { listWorkingTreeRelPaths } from "./git.js";
|
|
3
|
+
import { listWorkingTreeRelPaths, runGit } from "./git.js";
|
|
4
4
|
import { bold, cyan, dim, green, log, yellow } from "./logger.js";
|
|
5
|
+
import { fetchBaseBranch } from "./merge-base-freshness.js";
|
|
5
6
|
import { projectPaths } from "./paths.js";
|
|
6
7
|
import { prNumberFromUrl } from "./pr-create.js";
|
|
7
8
|
import { relPath } from "./task-context.js";
|
|
8
|
-
import {
|
|
9
|
+
import { resolveShipCommitMessage } from "./task-commit-msg.js";
|
|
10
|
+
import { commitDirtyWorkingTree, docsCommitMessageFor } from "./task-git-commit.js";
|
|
9
11
|
import { finishTaskWithPullRequest } from "./task-push-pr.js";
|
|
10
12
|
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
|
|
11
13
|
import { assertMergeRequestNotOpen, ensureTaskBranchForReship, integrateIntegrationIntoTaskBranch, loadGitConfigForReship, prepareGitContextForReship, refreshGitContextBaseCommit, resolveReshipTarget, } from "./task-reship.js";
|
|
12
14
|
import { hasNonEmptySection, parseTaskFilename, readGitContext } from "./task.js";
|
|
13
15
|
import { prRefLabel } from "./task-ship-state.js";
|
|
14
16
|
import { runUnderTaskLock } from "./task-lock.js";
|
|
17
|
+
/**
|
|
18
|
+
* Files this cycle would change on the base: after integrating `<remote>/<base>` the tree
|
|
19
|
+
* difference to the base is exactly the work not merged yet (earlier cycles' commits were
|
|
20
|
+
* squash-merged, so counting commits would report them again). With `--no-integrate` the
|
|
21
|
+
* branch may lack newer base changes, so compare against the merge base instead.
|
|
22
|
+
*/
|
|
23
|
+
async function unmergedFiles(cwd, remote, baseBranch, integrated) {
|
|
24
|
+
const baseRef = await fetchBaseBranch(cwd, remote, baseBranch);
|
|
25
|
+
const { stdout } = await runGit(cwd, integrated ? ["diff", "--name-only", baseRef, "HEAD"] : ["diff", "--name-only", `${baseRef}...HEAD`]);
|
|
26
|
+
return stdout
|
|
27
|
+
.split("\n")
|
|
28
|
+
.map((l) => l.trim())
|
|
29
|
+
.filter((l) => l.length > 0);
|
|
30
|
+
}
|
|
15
31
|
async function commitReshipMetadata(opts) {
|
|
16
32
|
if (opts.dryRun) {
|
|
17
33
|
log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
|
|
@@ -147,13 +163,39 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
147
163
|
process.exitCode = 1;
|
|
148
164
|
return;
|
|
149
165
|
}
|
|
166
|
+
const parts = parseTaskFilename(taskFile);
|
|
150
167
|
const dirtyBefore = await listWorkingTreeRelPaths(cwd);
|
|
151
|
-
if (dirtyBefore.length
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
if (dirtyBefore.length > 0) {
|
|
169
|
+
const message = await resolveShipCommitMessage({
|
|
170
|
+
cwd,
|
|
171
|
+
taskId: parts.id,
|
|
172
|
+
title: meta.title,
|
|
173
|
+
provided: options.message,
|
|
174
|
+
nonInteractive: options.nonInteractive === true,
|
|
175
|
+
});
|
|
176
|
+
await commitDirtyWorkingTree(cwd, message, false);
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Work may already be committed on the task branch (not merged into the base yet).
|
|
180
|
+
let pending;
|
|
181
|
+
try {
|
|
182
|
+
pending = await unmergedFiles(cwd, remote, integrationBranch, options.noIntegrate !== true);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
const msg = e instanceof Error ? e.message.split("\n")[0] : String(e);
|
|
186
|
+
log.error(`Could not compare ${taskId}'s branch with ${remote}/${integrationBranch}: ${msg}`);
|
|
187
|
+
process.exitCode = 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (pending.length === 0) {
|
|
191
|
+
log.info(`Nothing to do — ${taskId} is merged with no new changes. Create a new TASK for new work (${cyan("vibeops task add")}).`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
log.info(`No uncommitted changes; ${pending.length} file(s) committed on ${initialCtx.taskBranch} are not on ${remote}/${integrationBranch} yet — opening the PR for them.`);
|
|
195
|
+
if (options.message?.trim()) {
|
|
196
|
+
log.warn("-m/--message not used: there is nothing new to commit (existing commits keep their messages).");
|
|
197
|
+
}
|
|
154
198
|
}
|
|
155
|
-
const parts = parseTaskFilename(taskFile);
|
|
156
|
-
await commitDirtyWorkingTree(cwd, featCommitMessageFor(parts.id, meta.title), false);
|
|
157
199
|
let gitCtx = await readGitContext(taskFile);
|
|
158
200
|
if (gitCtx === null) {
|
|
159
201
|
log.error("Git Context missing after commit.");
|
package/dist/lib/task-push-pr.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectGitHost, mergeRequestLabel } from "./git-host.js";
|
|
|
5
5
|
import { requireGitConfig } from "./git-config.js";
|
|
6
6
|
import { gitCommitsAhead, gitDiffNameOnly, gitLogOneline, gitPush, gitRemoteUrl, readGitInfo, } from "./git.js";
|
|
7
7
|
import { dim, log } from "./logger.js";
|
|
8
|
+
import { fetchBaseBranch } from "./merge-base-freshness.js";
|
|
8
9
|
import { createMergeRequest, findMergeRequestByBranches, probeMergeRequestCli, } from "./pr-create.js";
|
|
9
10
|
import { fallbackTaskPr, generateTaskPrWithLlm } from "./task-pr-llm.js";
|
|
10
11
|
import { readGitContext, readTaskFile } from "./task.js";
|
|
@@ -15,9 +16,24 @@ function mergeRequestAlreadyExistsMessage(msg) {
|
|
|
15
16
|
lower.includes("merge request already exists") ||
|
|
16
17
|
lower.includes("pull request already exists"));
|
|
17
18
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* What the MR/PR changes is measured from the freshly fetched `<remote>/<base>`, not the
|
|
21
|
+
* TASK's Base Commit: after the base is merged into the task branch (required by the
|
|
22
|
+
* stale-base check), `<Base Commit>..HEAD` also contains other TASKs' commits. Falls back
|
|
23
|
+
* to the Base Commit only when the base branch cannot be fetched.
|
|
24
|
+
*/
|
|
25
|
+
async function resolveDiffBase(cwd, remote, gitCtx) {
|
|
26
|
+
try {
|
|
27
|
+
return await fetchBaseBranch(cwd, remote, gitCtx.baseBranch);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
log.warn(`Could not fetch ${remote}/${gitCtx.baseBranch} — listing changes since the TASK's Base Commit.`);
|
|
31
|
+
return gitCtx.baseCommit;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function buildDiffSummary(cwd, base) {
|
|
35
|
+
const files = await gitDiffNameOnly(cwd, `${base}...HEAD`);
|
|
36
|
+
const commits = await gitLogOneline(cwd, `${base}..HEAD`, { noMerges: true });
|
|
21
37
|
const lines = [];
|
|
22
38
|
if (commits.length > 0) {
|
|
23
39
|
lines.push("Commits:");
|
|
@@ -63,7 +79,8 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
63
79
|
if (git.branch !== gitCtx.taskBranch) {
|
|
64
80
|
log.warn(`Current branch is ${git.branch ?? "(detached)"}, not ${gitCtx.taskBranch}. Push may fail.`);
|
|
65
81
|
}
|
|
66
|
-
const
|
|
82
|
+
const diffBase = await resolveDiffBase(opts.cwd, gitCfg.remote, gitCtx);
|
|
83
|
+
const ahead = await gitCommitsAhead(opts.cwd, diffBase, "HEAD");
|
|
67
84
|
if (ahead === 0) {
|
|
68
85
|
log.warn("No commits ahead of base — pushing branch anyway.");
|
|
69
86
|
}
|
|
@@ -113,7 +130,7 @@ export async function finishTaskWithPullRequest(opts) {
|
|
|
113
130
|
const preference = config?.llm?.provider ?? "auto";
|
|
114
131
|
const meta = await readTaskFile(opts.taskFile);
|
|
115
132
|
const taskBody = await readText(opts.taskFile);
|
|
116
|
-
const diffSummary = await buildDiffSummary(opts.cwd,
|
|
133
|
+
const diffSummary = await buildDiffSummary(opts.cwd, diffBase);
|
|
117
134
|
const prInput = {
|
|
118
135
|
taskId: meta.id,
|
|
119
136
|
title: meta.title,
|
package/dist/lib/task.js
CHANGED
|
@@ -221,6 +221,25 @@ function locateSection(text, title, level = 2) {
|
|
|
221
221
|
}
|
|
222
222
|
return null;
|
|
223
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* A line that looks like the `## <title>` heading but is not one (e.g. `## ## Git Context`
|
|
226
|
+
* or `#Git Context`), when no well-formed heading exists. 1-based line number, or `null`.
|
|
227
|
+
*/
|
|
228
|
+
export function findMalformedSectionHeading(text, title, level = 2) {
|
|
229
|
+
if (locateSection(text, title, level) !== null)
|
|
230
|
+
return null;
|
|
231
|
+
const want = title.toLowerCase();
|
|
232
|
+
const lines = text.split("\n");
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
234
|
+
const line = lines[i];
|
|
235
|
+
if (!/^\s*#/.test(line))
|
|
236
|
+
continue;
|
|
237
|
+
const name = line.replace(/^[\s#]+/, "").replace(/[\s#]+$/, "").toLowerCase();
|
|
238
|
+
if (name === want)
|
|
239
|
+
return { line: i + 1, text: line.trim() };
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
224
243
|
export function readSection(body, title) {
|
|
225
244
|
const block = locateSection(body, title);
|
|
226
245
|
if (block === null)
|
|
@@ -420,6 +439,30 @@ export async function upsertGitContext(filePath, ctx) {
|
|
|
420
439
|
await writeText(filePath, updated);
|
|
421
440
|
}
|
|
422
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* The Git Context exists but cannot be read (malformed heading, or the section lacks
|
|
444
|
+
* its fields): the problem text, else `null` (readable, or no section at all).
|
|
445
|
+
*/
|
|
446
|
+
export async function damagedGitContext(filePath) {
|
|
447
|
+
if ((await readGitContext(filePath)) !== null)
|
|
448
|
+
return null;
|
|
449
|
+
const raw = await readText(filePath);
|
|
450
|
+
const malformed = findMalformedSectionHeading(raw, "Git Context") !== null;
|
|
451
|
+
const unparsable = readSection(raw, "Git Context").length > 0;
|
|
452
|
+
return malformed || unparsable ? gitContextProblem(filePath) : null;
|
|
453
|
+
}
|
|
454
|
+
/** Why {@link readGitContext} returned `null` for this file — for error messages. */
|
|
455
|
+
export async function gitContextProblem(filePath) {
|
|
456
|
+
const raw = await readText(filePath);
|
|
457
|
+
const bad = findMalformedSectionHeading(raw, "Git Context");
|
|
458
|
+
if (bad !== null) {
|
|
459
|
+
return `line ${bad.line} is \`${bad.text}\`, not \`## Git Context\` — fix that heading`;
|
|
460
|
+
}
|
|
461
|
+
if (readSection(raw, "Git Context").length === 0) {
|
|
462
|
+
return "the file has no `## Git Context` section";
|
|
463
|
+
}
|
|
464
|
+
return "`## Git Context` lacks Base Branch / Base Commit / Task Branch / Started At";
|
|
465
|
+
}
|
|
423
466
|
const GIT_CTX_RE = {
|
|
424
467
|
baseBranch: /^-\s+Base Branch:\s*`([^`]+)`/m,
|
|
425
468
|
baseCommit: /^-\s+Base Commit:\s*`([^`]+)`/m,
|