@goodtek/vibeops 3.3.1 → 3.4.1
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 +12 -0
- package/README.md +22 -1
- package/dist/commands/task-ship.js +21 -9
- package/dist/lib/check-rollup.js +76 -1
- package/dist/lib/commit-trailers.js +33 -0
- package/dist/lib/config.js +19 -3
- package/dist/lib/git-config.js +4 -0
- package/dist/lib/task-git-commit.js +7 -3
- package/dist/lib/task-new-cycle.js +16 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.4.1 - 2026-11-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Merge gate: a re-run supersedes the earlier run of the same check.** `statusCheckRollup` keeps every run of a check on the head commit, and any failure among them refused the merge — so a check that failed once (e.g. a transient host error) and then passed on a re-run still blocked `task merge`. The gate now keeps the latest item per check identity (CheckRun: workflow + name; StatusContext: context), latest = started last (`startedAt`, then `completedAt`; still running = newest). A later failure still beats an earlier pass, a newer pending run still waits, and when the order is unknown (an item without `startedAt`, or an exact tie) every run of that check is kept, so the gate stays fail-closed (D-016, TASK-059).
|
|
12
|
+
|
|
13
|
+
## 3.4.0 - 2026-11-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`.vibeops.json` `git.commitTrailers`**: git trailers (e.g. `Co-Authored-By: …`) added with `git commit --trailer` to every commit `task ship` / `--new-cycle` makes, including the `mark shipped` and new-cycle metadata commits. Entries are validated strictly (one-line `Token: value`); an invalid entry exits 1 naming it. `--dry-run` lists them (TASK-057).
|
|
18
|
+
|
|
7
19
|
## 3.3.1 - 2026-10-18
|
|
8
20
|
|
|
9
21
|
### Changed
|
package/README.md
CHANGED
|
@@ -163,6 +163,26 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
|
|
|
163
163
|
- **`status`**: Now / Next card — focus stage, checklist, PR, and the next command to run.
|
|
164
164
|
- No force-push to shared branches.
|
|
165
165
|
|
|
166
|
+
### Commit trailers (`.vibeops.json` → `git.commitTrailers`)
|
|
167
|
+
|
|
168
|
+
Commits VibeOps makes itself (`task ship` implementation and `mark shipped` commits, `--new-cycle` metadata) can carry git trailers. git's `trailer.*` config only applies with `--trailer` / `interpret-trailers`, and `commit.template` is ignored for `-m`, so set them here:
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"git": {
|
|
173
|
+
"remote": "origin",
|
|
174
|
+
"host": "github",
|
|
175
|
+
"integrationBranch": "develop",
|
|
176
|
+
"productionBranch": "main",
|
|
177
|
+
"commitTrailers": ["Co-Authored-By: Name <name@example.com>"]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- Each entry is passed as `git commit --trailer <entry>`; `--dry-run` lists them.
|
|
183
|
+
- Validated strictly: an array of one-line `Token: value` strings (token: letters, digits, `-`). Anything else (not an array, newline, missing `: `, empty value) makes the command exit 1 naming the entry.
|
|
184
|
+
- Unset → no trailers. Re-running `init` keeps the list. The initial `init` commit does not use it.
|
|
185
|
+
|
|
166
186
|
## Worktrees
|
|
167
187
|
|
|
168
188
|
VibeOps supports several `git worktree` checkouts of one repo working in parallel. It never needs to check out the integration branch:
|
|
@@ -234,7 +254,8 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
234
254
|
- **passed:** `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
|
235
255
|
- **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
|
|
236
256
|
- **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
|
|
237
|
-
- **anything missing or undocumented fails closed** (treated as failed).
|
|
257
|
+
- **anything missing or undocumented fails closed** (treated as failed).
|
|
258
|
+
- **Re-runs: the latest run of each check counts.** The rollup keeps every run of a check on the head commit (a re-run adds one). The gate keeps the latest per check identity — CheckRun: workflow + name (`test` in two workflows are two checks); StatusContext: context — where latest = started last (`startedAt`, ties by `completedAt`, a still-running item is newest). So a failure followed by a passing re-run passes, a pass followed by a failing re-run fails, and a newer queued / running re-run waits. If the order is unknown — an item without `startedAt` or an exact tie — all runs of that check are kept and any failure among them fails.
|
|
238
259
|
- **GitHub Actions — the PR's own CI, not leftovers:** the rollup lists every check on the head commit, including checks from other events (e.g. a finished `push` workflow on `develop` when a release PR `develop → main` is opened). Right after a PR is opened or pushed, GitHub has not created its `pull_request` run — or that run's jobs — yet, so such a rollup looked green and 3.1.x merged before CI started. The gate therefore also reads the GitHub Actions API for the head SHA:
|
|
239
260
|
- **Expected workflows:** every `.github/workflows/*.yml|yaml` at the head commit whose `on:` triggers `pull_request` for this PR, using GitHub's filter rules — `branches` / `branches-ignore` against the base branch, `paths` / `paths-ignore` against the PR's changed files (first 300, like GitHub), and `types` (a custom list must contain both `opened` and `synchronize`). Disabled workflows are skipped; `pull_request_target`, `workflow_call` and other events are not awaited. Each expected workflow must have a `pull_request` run for the head SHA — until it appears the gate **waits** (`workflow … (pull_request) has not started for <sha> yet`), then refuses at `waitTimeoutSeconds`.
|
|
240
261
|
- **All runs for the head SHA** (any event, latest run per workflow + event) must be `completed` with `success`, `neutral` or `skipped`. A queued / in-progress / waiting run waits; any other conclusion refuses immediately — including `startup_failure` (no jobs, so nothing in the rollup) and `action_required` (fork PR awaiting approval).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { readText } from "../lib/filesystem.js";
|
|
3
|
-
import { requireGitConfig } from "../lib/git-config.js";
|
|
3
|
+
import { readCommitTrailers, requireGitConfig } from "../lib/git-config.js";
|
|
4
4
|
import { BASELINE_REPAIR_SECTION, preflightBaseline } from "../lib/project-baseline.js";
|
|
5
5
|
import { gitCommitsAhead, gitHeadCommit, gitPush, gitRemoteBranchExists, readGitInfo, } from "../lib/git.js";
|
|
6
6
|
import { askYesNo, isInteractiveSession } from "../lib/inquirer-helpers.js";
|
|
@@ -56,18 +56,20 @@ async function commitShipMetadata(opts) {
|
|
|
56
56
|
if (opts.dryRun) {
|
|
57
57
|
log.info(dim(" would set Status → Shipped"));
|
|
58
58
|
log.info(dim(" would commit ship metadata"));
|
|
59
|
+
if (opts.trailers.length > 0)
|
|
60
|
+
log.info(dim(` with trailers: ${opts.trailers.join(" | ")}`));
|
|
59
61
|
return;
|
|
60
62
|
}
|
|
61
63
|
await updateInlineStatus(opts.taskFile, "shipped");
|
|
62
64
|
log.ok("Status → Shipped");
|
|
63
|
-
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false);
|
|
65
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "mark shipped"), false, opts.trailers);
|
|
64
66
|
if (!committed) {
|
|
65
67
|
log.warn("No file changes for ship metadata commit — check TASK Status on the remote branch.");
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
/** State 1: first submit — push the task branch and open a new PR/MR. */
|
|
69
71
|
async function runFirstShip(ctx) {
|
|
70
|
-
const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options } = ctx;
|
|
72
|
+
const { cwd, taskId, taskFile, meta, relFile, dryRun, nonInteractive, options, trailers } = ctx;
|
|
71
73
|
let body = ctx.body;
|
|
72
74
|
let resultOk = hasNonEmptySection(body, "Result");
|
|
73
75
|
let testOk = hasNonEmptySection(body, "Test Result");
|
|
@@ -127,7 +129,7 @@ async function runFirstShip(ctx) {
|
|
|
127
129
|
log.info(" · commit implementation (TASK-scoped message) + ship metadata (Status → Shipped)");
|
|
128
130
|
log.info(" · push task branch once, open a new MR/PR (unless --no-pr)");
|
|
129
131
|
const partsDry = parseTaskFilename(taskFile);
|
|
130
|
-
await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true });
|
|
132
|
+
await commitShipMetadata({ cwd, taskFile, taskId: partsDry.id, dryRun: true, trailers });
|
|
131
133
|
await finishTaskWithPullRequest({
|
|
132
134
|
cwd,
|
|
133
135
|
taskFile,
|
|
@@ -155,9 +157,9 @@ async function runFirstShip(ctx) {
|
|
|
155
157
|
provided: options.message,
|
|
156
158
|
nonInteractive,
|
|
157
159
|
});
|
|
158
|
-
await commitDirtyWorkingTree(cwd, message, false);
|
|
160
|
+
await commitDirtyWorkingTree(cwd, message, false, trailers);
|
|
159
161
|
}
|
|
160
|
-
await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false });
|
|
162
|
+
await commitShipMetadata({ cwd, taskFile, taskId: parts.id, dryRun: false, trailers });
|
|
161
163
|
const prResult = await finishTaskWithPullRequest({
|
|
162
164
|
cwd,
|
|
163
165
|
taskFile,
|
|
@@ -183,7 +185,7 @@ async function runFirstShip(ctx) {
|
|
|
183
185
|
}
|
|
184
186
|
/** State 2: an open PR exists — commit + push to update it (no new PR). */
|
|
185
187
|
async function runUpdateOpenPr(ctx) {
|
|
186
|
-
const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options } = ctx;
|
|
188
|
+
const { cwd, taskId, taskFile, meta, body, prCtx, dryRun, nonInteractive, options, trailers } = ctx;
|
|
187
189
|
const gitCtx = await readGitContext(taskFile);
|
|
188
190
|
if (gitCtx === null) {
|
|
189
191
|
log.error(`No Git Context — cannot update the open PR: ${await gitContextProblem(taskFile)}.`);
|
|
@@ -220,7 +222,7 @@ async function runUpdateOpenPr(ctx) {
|
|
|
220
222
|
if (meta.status === "in_progress") {
|
|
221
223
|
await updateInlineStatus(taskFile, "shipped");
|
|
222
224
|
log.ok("Status → Shipped");
|
|
223
|
-
await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false);
|
|
225
|
+
await commitDirtyWorkingTree(cwd, docsCommitMessageFor(taskId, "mark shipped"), false, trailers);
|
|
224
226
|
}
|
|
225
227
|
if (options.noCommit === true) {
|
|
226
228
|
const git = await readGitInfo(cwd);
|
|
@@ -236,7 +238,7 @@ async function runUpdateOpenPr(ctx) {
|
|
|
236
238
|
provided: options.message,
|
|
237
239
|
nonInteractive,
|
|
238
240
|
});
|
|
239
|
-
await commitDirtyWorkingTree(cwd, message, false);
|
|
241
|
+
await commitDirtyWorkingTree(cwd, message, false, trailers);
|
|
240
242
|
}
|
|
241
243
|
const remoteExists = await gitRemoteBranchExists(cwd, remote, gitCtx.taskBranch);
|
|
242
244
|
const ahead = remoteExists
|
|
@@ -338,6 +340,15 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
338
340
|
process.exitCode = 1;
|
|
339
341
|
return;
|
|
340
342
|
}
|
|
343
|
+
let trailers;
|
|
344
|
+
try {
|
|
345
|
+
trailers = await readCommitTrailers(cwd);
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
349
|
+
process.exitCode = 1;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
341
352
|
const ctx = {
|
|
342
353
|
cwd,
|
|
343
354
|
taskId,
|
|
@@ -349,6 +360,7 @@ export async function taskShipCommand(taskRef, options = {}) {
|
|
|
349
360
|
dryRun,
|
|
350
361
|
nonInteractive,
|
|
351
362
|
options,
|
|
363
|
+
trailers,
|
|
352
364
|
};
|
|
353
365
|
switch (action) {
|
|
354
366
|
case "update-open":
|
package/dist/lib/check-rollup.js
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* GitHub returns two item shapes:
|
|
6
6
|
* - `CheckRun` (GitHub Actions, check apps): `status` + `conclusion`, no `state`.
|
|
7
7
|
* - `StatusContext` (commit status API): `state`, no `status`/`conclusion`.
|
|
8
|
+
*
|
|
9
|
+
* The rollup keeps every run of a check: a re-run (new attempt or new workflow run on the
|
|
10
|
+
* same head SHA) adds an item next to the old one. {@link latestPerCheck} keeps the latest
|
|
11
|
+
* item per check identity, so a passed re-run supersedes an earlier failure and a later
|
|
12
|
+
* failure supersedes an earlier pass.
|
|
8
13
|
*/
|
|
9
14
|
/** GitHub GraphQL `CheckStatusState` values other than COMPLETED. */
|
|
10
15
|
const CHECK_RUN_IN_FLIGHT = new Set(["REQUESTED", "QUEUED", "IN_PROGRESS", "WAITING", "PENDING"]);
|
|
@@ -32,7 +37,23 @@ function isCheckRun(item) {
|
|
|
32
37
|
// Unknown typename: infer from fields.
|
|
33
38
|
return (item.status ?? null) !== null || (item.conclusion ?? null) !== null;
|
|
34
39
|
}
|
|
40
|
+
/** RFC 3339 → epoch ms; missing, unparseable or Go's zero time (`0001-01-01T00:00:00Z`) → `null`. */
|
|
41
|
+
function timestamp(raw) {
|
|
42
|
+
if (raw === null || raw === undefined || raw.trim() === "")
|
|
43
|
+
return null;
|
|
44
|
+
const ms = Date.parse(raw);
|
|
45
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
46
|
+
}
|
|
35
47
|
export function classifyRollupItem(item) {
|
|
48
|
+
const base = classifyVerdict(item);
|
|
49
|
+
return {
|
|
50
|
+
...base,
|
|
51
|
+
workflow: base.kind === "CheckRun" ? (item.workflowName ?? "").trim() || null : null,
|
|
52
|
+
startedAt: timestamp(item.startedAt),
|
|
53
|
+
completedAt: timestamp(item.completedAt),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function classifyVerdict(item) {
|
|
36
57
|
if (isCheckRun(item)) {
|
|
37
58
|
const name = (item.name ?? "").trim() || "(unnamed check run)";
|
|
38
59
|
const status = upper(item.status);
|
|
@@ -72,8 +93,62 @@ export function classifyRollupItem(item) {
|
|
|
72
93
|
// Missing or undocumented state — fail closed.
|
|
73
94
|
return { name, kind: "StatusContext", verdict: "failed", detail: state || "MISSING_STATE" };
|
|
74
95
|
}
|
|
96
|
+
/** Classified, latest item per check identity ({@link latestPerCheck}). */
|
|
75
97
|
export function classifyRollup(items) {
|
|
76
|
-
return (items ?? []).map(classifyRollupItem);
|
|
98
|
+
return latestPerCheck((items ?? []).map(classifyRollupItem));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check identity: CheckRun = workflow + name (two workflows may both have a `test` job);
|
|
102
|
+
* StatusContext = context. GitHub's `workflowName` carries no event, so runs of one workflow
|
|
103
|
+
* from different events share an identity here; the Actions part of the gate
|
|
104
|
+
* (`evaluateActionsRuns`) still judges the latest run per workflow + event.
|
|
105
|
+
*/
|
|
106
|
+
function checkIdentity(c) {
|
|
107
|
+
return c.kind === "CheckRun"
|
|
108
|
+
? `CheckRun\u0000${c.workflow ?? ""}\u0000${c.name}`
|
|
109
|
+
: `StatusContext\u0000${c.name}`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Keep the latest item per check identity, like GitHub's merge box, which shows the latest
|
|
113
|
+
* result of each check. "Latest" = the item that started last (`startedAt`; a re-run starts
|
|
114
|
+
* after the run it repeats), ties broken by `completedAt` (an in-flight item with no
|
|
115
|
+
* `completedAt` is newest). Its verdict stands: a newer pending item is pending, a newer
|
|
116
|
+
* failure fails even after an earlier pass.
|
|
117
|
+
*
|
|
118
|
+
* Fail closed when the order is unknown: if any item of an identity lacks `startedAt`
|
|
119
|
+
* (e.g. queued, or an older gh), or the latest items tie exactly, every item of that
|
|
120
|
+
* identity is kept — so any failure among them still fails and any pending still waits.
|
|
121
|
+
*/
|
|
122
|
+
export function latestPerCheck(checks) {
|
|
123
|
+
const groups = new Map();
|
|
124
|
+
for (const c of checks) {
|
|
125
|
+
const key = checkIdentity(c);
|
|
126
|
+
const group = groups.get(key);
|
|
127
|
+
if (group === undefined)
|
|
128
|
+
groups.set(key, [c]);
|
|
129
|
+
else
|
|
130
|
+
group.push(c);
|
|
131
|
+
}
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const group of groups.values()) {
|
|
134
|
+
if (group.length === 1 || group.some((c) => (c.startedAt ?? null) === null)) {
|
|
135
|
+
out.push(...group);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const sorted = [...group].sort(compareRecency);
|
|
139
|
+
const newest = sorted[sorted.length - 1];
|
|
140
|
+
out.push(...sorted.filter((c) => compareRecency(c, newest) === 0));
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Ascending recency: `startedAt`, then `completedAt` (missing = still running = newest). */
|
|
145
|
+
function compareRecency(a, b) {
|
|
146
|
+
const started = (a.startedAt ?? 0) - (b.startedAt ?? 0);
|
|
147
|
+
if (started !== 0)
|
|
148
|
+
return started;
|
|
149
|
+
const ac = a.completedAt ?? Number.POSITIVE_INFINITY;
|
|
150
|
+
const bc = b.completedAt ?? Number.POSITIVE_INFINITY;
|
|
151
|
+
return ac === bc ? 0 : ac < bc ? -1 : 1;
|
|
77
152
|
}
|
|
78
153
|
/** Exact name, or a `*` glob (e.g. `lint*`, `* · linux`). */
|
|
79
154
|
export function checkNameMatches(name, pattern) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git trailers for commits VibeOps creates itself (`.vibeops.json` `git.commitTrailers`).
|
|
3
|
+
* Each entry is passed as `git commit --trailer <entry>` (git-commit(1), git-interpret-trailers(1)).
|
|
4
|
+
*/
|
|
5
|
+
export class CommitTrailersConfigError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CommitTrailersConfigError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** `Token: value` — token of letters, digits and `-` (git trailer key), non-empty single-line value. */
|
|
12
|
+
const TRAILER_RE = /^[A-Za-z0-9][A-Za-z0-9-]*: \S.*$/;
|
|
13
|
+
/**
|
|
14
|
+
* Validate the raw `git.commitTrailers` value. Absent → `undefined`; anything that is not
|
|
15
|
+
* an array of single-line `Token: value` strings throws (fail closed, entry named).
|
|
16
|
+
*/
|
|
17
|
+
export function parseCommitTrailers(raw) {
|
|
18
|
+
if (raw === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (!Array.isArray(raw)) {
|
|
21
|
+
throw new CommitTrailersConfigError(".vibeops.json git.commitTrailers must be an array of \"Token: value\" strings.");
|
|
22
|
+
}
|
|
23
|
+
return raw.map((entry, i) => {
|
|
24
|
+
if (typeof entry !== "string" || /[\r\n]/.test(entry) || !TRAILER_RE.test(entry)) {
|
|
25
|
+
throw new CommitTrailersConfigError(`.vibeops.json git.commitTrailers[${i}] is invalid: ${JSON.stringify(entry)} — expected one line "Token: value" (e.g. "Co-Authored-By: Name <name@example.com>").`);
|
|
26
|
+
}
|
|
27
|
+
return entry;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** `git commit` arguments for the trailers (`--trailer <t>` each). */
|
|
31
|
+
export function commitTrailerArgs(trailers) {
|
|
32
|
+
return trailers.flatMap((t) => ["--trailer", t]);
|
|
33
|
+
}
|
package/dist/lib/config.js
CHANGED
|
@@ -4,6 +4,7 @@ import { VIBEOPS_CONFIG_FILE } from "./paths.js";
|
|
|
4
4
|
import { VERSION } from "../version.js";
|
|
5
5
|
import { VIBEOPS_CONFIG_SCHEMA_VERSION, } from "../types/config.js";
|
|
6
6
|
import { isVibeopsClientId } from "./init-clients.js";
|
|
7
|
+
import { CommitTrailersConfigError, parseCommitTrailers } from "./commit-trailers.js";
|
|
7
8
|
function parseClientsBlock(raw) {
|
|
8
9
|
if (!Array.isArray(raw))
|
|
9
10
|
return undefined;
|
|
@@ -40,7 +41,14 @@ function parseGitBlock(raw) {
|
|
|
40
41
|
: undefined;
|
|
41
42
|
if (!host || !integrationBranch || !productionBranch)
|
|
42
43
|
return undefined;
|
|
43
|
-
|
|
44
|
+
const commitTrailers = parseCommitTrailers(o.commitTrailers);
|
|
45
|
+
return {
|
|
46
|
+
remote,
|
|
47
|
+
host,
|
|
48
|
+
integrationBranch,
|
|
49
|
+
productionBranch,
|
|
50
|
+
...(commitTrailers ? { commitTrailers } : {}),
|
|
51
|
+
};
|
|
44
52
|
}
|
|
45
53
|
function parseLlmBlock(raw) {
|
|
46
54
|
if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -81,7 +89,10 @@ export async function readConfig(root) {
|
|
|
81
89
|
}
|
|
82
90
|
return null;
|
|
83
91
|
}
|
|
84
|
-
catch {
|
|
92
|
+
catch (e) {
|
|
93
|
+
// An invalid commitTrailers entry is a visible error, not "no config".
|
|
94
|
+
if (e instanceof CommitTrailersConfigError)
|
|
95
|
+
throw e;
|
|
85
96
|
return null;
|
|
86
97
|
}
|
|
87
98
|
}
|
|
@@ -92,7 +103,12 @@ export function buildConfig(name, clients, git, existing, nowIso) {
|
|
|
92
103
|
schemaVersion: VIBEOPS_CONFIG_SCHEMA_VERSION,
|
|
93
104
|
createdAt: existing?.createdAt ?? nowIso ?? new Date().toISOString(),
|
|
94
105
|
clients,
|
|
95
|
-
git
|
|
106
|
+
git: {
|
|
107
|
+
...git,
|
|
108
|
+
...(git.commitTrailers === undefined && existing?.git?.commitTrailers
|
|
109
|
+
? { commitTrailers: existing.git.commitTrailers }
|
|
110
|
+
: {}),
|
|
111
|
+
},
|
|
96
112
|
llm: existing?.llm ?? { provider: "auto" },
|
|
97
113
|
...(existing?.merge !== undefined ? { merge: existing.merge } : {}),
|
|
98
114
|
...(existing?.baseline !== undefined ? { baseline: existing.baseline } : {}),
|
package/dist/lib/git-config.js
CHANGED
|
@@ -19,3 +19,7 @@ export async function requireGitConfig(cwd) {
|
|
|
19
19
|
}
|
|
20
20
|
return config.git;
|
|
21
21
|
}
|
|
22
|
+
/** `git.commitTrailers` from `.vibeops.json` (empty when unset or no config); throws when invalid. */
|
|
23
|
+
export async function readCommitTrailers(cwd) {
|
|
24
|
+
return (await readConfig(cwd))?.git?.commitTrailers ?? [];
|
|
25
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { commitTrailerArgs } from "./commit-trailers.js";
|
|
1
2
|
import { dim, log } from "./logger.js";
|
|
2
3
|
import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
|
|
3
4
|
export function featCommitMessageFor(taskId, title) {
|
|
@@ -40,9 +41,10 @@ export function taskScopedCommitMessage(taskId, rawMessage, defaultType = "feat"
|
|
|
40
41
|
* Commit all committable dirty paths (staged and unstaged, including deletions
|
|
41
42
|
* and renames); returns whether a commit was created. Only paths whose
|
|
42
43
|
* working-tree state is not yet in the index are passed to `git add`; staged
|
|
43
|
-
* changes are already part of the commit.
|
|
44
|
+
* changes are already part of the commit. Each `trailers` entry
|
|
45
|
+
* (`.vibeops.json` `git.commitTrailers`) is added with `git commit --trailer`.
|
|
44
46
|
*/
|
|
45
|
-
export async function commitDirtyWorkingTree(cwd, message, dryRun) {
|
|
47
|
+
export async function commitDirtyWorkingTree(cwd, message, dryRun, trailers) {
|
|
46
48
|
const git = await readGitInfo(cwd);
|
|
47
49
|
if (!git.isRepo || git.dirty !== true)
|
|
48
50
|
return false;
|
|
@@ -54,11 +56,13 @@ export async function commitDirtyWorkingTree(cwd, message, dryRun) {
|
|
|
54
56
|
}
|
|
55
57
|
if (dryRun) {
|
|
56
58
|
log.info(dim(` would commit ${committable.length} path(s): ${message}`));
|
|
59
|
+
if (trailers.length > 0)
|
|
60
|
+
log.info(dim(` with trailers: ${trailers.join(" | ")}`));
|
|
57
61
|
return true;
|
|
58
62
|
}
|
|
59
63
|
const committableSet = new Set(committable);
|
|
60
64
|
await gitAddPaths(cwd, (await listUnstagedRelPaths(cwd)).filter((p) => committableSet.has(p)));
|
|
61
|
-
await runGit(cwd, ["commit", "-q", "-m", message]);
|
|
65
|
+
await runGit(cwd, ["commit", "-q", "-m", message, ...commitTrailerArgs(trailers)]);
|
|
62
66
|
log.ok(`Committed: ${message}`);
|
|
63
67
|
return true;
|
|
64
68
|
}
|
|
@@ -7,6 +7,7 @@ import { projectPaths } from "./paths.js";
|
|
|
7
7
|
import { prNumberFromUrl } from "./pr-create.js";
|
|
8
8
|
import { relPath } from "./task-context.js";
|
|
9
9
|
import { resolveShipCommitMessage } from "./task-commit-msg.js";
|
|
10
|
+
import { readCommitTrailers } from "./git-config.js";
|
|
10
11
|
import { commitDirtyWorkingTree, docsCommitMessageFor } from "./task-git-commit.js";
|
|
11
12
|
import { finishTaskWithPullRequest } from "./task-push-pr.js";
|
|
12
13
|
import { applyTaskShipMemory, llmCompleteTaskReship, writeTaskResultSections, } from "./task-ship-llm.js";
|
|
@@ -33,7 +34,7 @@ async function commitReshipMetadata(opts) {
|
|
|
33
34
|
log.info(dim(" would commit new-cycle metadata (Status stays Shipped)"));
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
36
|
-
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false);
|
|
37
|
+
const committed = await commitDirtyWorkingTree(opts.cwd, docsCommitMessageFor(opts.taskId, "new PR cycle metadata"), false, opts.trailers);
|
|
37
38
|
if (!committed) {
|
|
38
39
|
log.info(dim("No new-cycle metadata commit (Git Context already on disk)."));
|
|
39
40
|
}
|
|
@@ -66,6 +67,18 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
66
67
|
process.exitCode = 1;
|
|
67
68
|
return;
|
|
68
69
|
}
|
|
70
|
+
let trailers;
|
|
71
|
+
try {
|
|
72
|
+
trailers = await readCommitTrailers(cwd);
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (dryRun && trailers.length > 0) {
|
|
80
|
+
log.info(dim(` commits would carry trailers: ${trailers.join(" | ")}`));
|
|
81
|
+
}
|
|
69
82
|
const integrationBranch = gitCfg?.integrationBranch ?? "develop";
|
|
70
83
|
const remote = gitCfg?.remote ?? "origin";
|
|
71
84
|
const updateOpenMr = options.allowOpenMr === true;
|
|
@@ -173,7 +186,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
173
186
|
provided: options.message,
|
|
174
187
|
nonInteractive: options.nonInteractive === true,
|
|
175
188
|
});
|
|
176
|
-
await commitDirtyWorkingTree(cwd, message, false);
|
|
189
|
+
await commitDirtyWorkingTree(cwd, message, false, trailers);
|
|
177
190
|
}
|
|
178
191
|
else {
|
|
179
192
|
// Work may already be committed on the task branch (not merged into the base yet).
|
|
@@ -213,6 +226,7 @@ export async function runPostMergeNewCycle(taskRef, options = {}) {
|
|
|
213
226
|
taskFile,
|
|
214
227
|
taskId: parts.id,
|
|
215
228
|
dryRun: false,
|
|
229
|
+
trailers,
|
|
216
230
|
});
|
|
217
231
|
}
|
|
218
232
|
const prResult = await finishTaskWithPullRequest({
|