@goodtek/vibeops 3.4.0 → 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 +6 -0
- package/README.md +2 -1
- package/dist/lib/check-rollup.js +76 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ 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
|
+
|
|
7
13
|
## 3.4.0 - 2026-11-07
|
|
8
14
|
|
|
9
15
|
### Added
|
package/README.md
CHANGED
|
@@ -254,7 +254,8 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
254
254
|
- **passed:** `SUCCESS`, `NEUTRAL`, `SKIPPED`
|
|
255
255
|
- **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
|
|
256
256
|
- **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
|
|
257
|
-
- **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.
|
|
258
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:
|
|
259
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`.
|
|
260
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).
|
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) {
|