@goodtek/vibeops 3.1.1 → 3.1.4
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 +35 -0
- package/README.md +11 -6
- package/dist/cli.js +4 -4
- package/dist/commands/task-add.js +2 -2
- package/dist/commands/task-merge.js +1 -1
- package/dist/lib/github-actions-gate.js +355 -0
- package/dist/lib/logger.js +0 -1
- package/dist/lib/merge-config.js +2 -1
- package/dist/lib/pr-create.js +124 -18
- package/dist/lib/task-add-llm.js +1 -1
- package/dist/lib/task-git-commit.js +1 -1
- package/dist/lib/task-pr-llm.js +1 -1
- package/dist/lib/task-start.js +4 -2
- package/package.json +13 -8
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to VibeOps are documented here.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 3.1.4 - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- No CLI changes. First release published from GitHub Actions (`publish.yml`, npm Trusted Publishing with provenance) instead of a maintainer machine.
|
|
12
|
+
|
|
13
|
+
## 3.1.3 - 2026-10-16
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Negated flags were ignored.** commander stores `--no-foo` as `foo: false` and never sets `noFoo`, but the CLI read `noFoo`, so these flags did nothing: `task release --no-merge` (created the release PR and merged it anyway — observed merging before the PR's checks finished), `task del --no-remote-delete` / `--no-close-mr` (deleted the remote branch / closed the open PR anyway) and `task sync --no-remote-delete` (deleted the remote branch anyway). They now take effect, in dry-run plans too (TASK-025).
|
|
18
|
+
- **`task add --dry-run` crashed** with `ENOENT` after printing its plan: the branch step read the TASK file the dry run never wrote. The plan now also names the real branch (`task/<NNN>-<slug>`; it omitted the TASK number) (TASK-026).
|
|
19
|
+
|
|
20
|
+
## 3.1.2 - 2026-10-16
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **`task merge` / `task release` merged before CI started (GitHub).** The gate judged only `statusCheckRollup`, which also holds checks from other events on the head commit (e.g. a finished `push` workflow). Right after a PR was opened or pushed, its own `pull_request` run — or that run's jobs — did not exist yet, so earlier green checks counted as "all green" (observed: a release PR merged 5 s after opening, before its CI job was created; a required check name satisfied by the same-named job of a `push` workflow). The gate now also reads the GitHub Actions API for the head SHA: every workflow whose `on:` triggers `pull_request` for this PR (base-branch, path and type filters evaluated as GitHub does; disabled workflows skipped) must have a `pull_request` run, and every Actions run for the SHA must be completed with `success` / `neutral` / `skipped`. Missing or running → wait up to `waitTimeoutSeconds`, then refuse with the workflow named; `startup_failure`, `action_required` and other conclusions → refuse immediately; unreadable Actions API → wait, then refuse (TASK-024).
|
|
25
|
+
- **Head SHA race:** right before merging, the gate re-reads the PR head; if it moved while checks were read, the new head is evaluated from scratch (the merge stays pinned with `--match-head-commit` / `--sha`) (TASK-024).
|
|
26
|
+
- The `task merge` header no longer prints `checks all green` before anything was checked; it states the rule (`waits until every check on the head commit is green`).
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **CI** (`.github/workflows/ci.yml`, pull requests + `main`): lint (ESLint, new `pnpm lint`), typecheck, build, tests and smoke on Node 20 / 22 / 24, GitHub-hosted runners only, read-only token, actions pinned by commit SHA (TASK-023).
|
|
31
|
+
- **Secret scanning in CI:** gitleaks 8.30.1 CLI (pinned release, sha256 checked against the pinned value and the release checksums file); PR commits `base..head`, full history on `main`; any finding fails the job (TASK-023).
|
|
32
|
+
- **npm Trusted Publishing** (`.github/workflows/publish.yml`): tag `vX.Y.Z` on `main` → verify → `npm publish` via OIDC with automatic provenance; no npm token in CI. Owner setup in `docs/project/09-deployment.md`; the manual `pnpm publish:npm` path stays until it is configured (TASK-023).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `merge.pollIntervalSeconds` default **5 → 10** (the default of `gh pr checks --watch`). Host read errors back off exponentially with jitter (cap 60 s) within `waitTimeoutSeconds`.
|
|
37
|
+
- "No checks at all" now also requires that no GitHub Actions run exists or is expected for the head commit; `emptyRollupGraceSeconds` only matters for CI outside GitHub Actions.
|
|
38
|
+
- New runtime dependency: `yaml` (ISC) to read workflow triggers.
|
|
39
|
+
- `package.json` `repository` / `homepage` / `bugs` point at `github.com/goodtek-xyz/vibeops` (required to match exactly for Trusted Publishing provenance).
|
|
40
|
+
- Docs, scripts and examples use placeholders instead of maintainer-internal hosts and paths; `.infisical.json` is no longer tracked (gitignored).
|
|
41
|
+
|
|
7
42
|
## 3.1.1 - 2026-10-15
|
|
8
43
|
|
|
9
44
|
### Fixed
|
package/README.md
CHANGED
|
@@ -213,11 +213,16 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
213
213
|
- **failed:** `FAILURE`, `CANCELLED`, `TIMED_OUT`, `ACTION_REQUIRED`, `STARTUP_FAILURE`, `STALE`, `ERROR`
|
|
214
214
|
- **pending:** CheckRun `REQUESTED`, `QUEUED`, `IN_PROGRESS`, `WAITING`, `PENDING`; StatusContext `PENDING`, `EXPECTED`
|
|
215
215
|
- **anything missing or undocumented fails closed** (treated as failed). Several checks with the same name: any failure fails.
|
|
216
|
+
- **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:
|
|
217
|
+
- **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`.
|
|
218
|
+
- **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).
|
|
219
|
+
- The Actions API is required: if it cannot be read, the gate keeps waiting (back-off) and refuses at the timeout — it never merges blind.
|
|
216
220
|
- **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
|
|
217
221
|
- **Host output is verified:** if `gh pr view` lacks or has empty `state`, `mergeable`, `headRefOid`, or `statusCheckRollup` is not a list (GitLab: `state`, `sha`, `merge_status`, `detailed_merge_status`, `head_pipeline`), the merge is refused.
|
|
218
222
|
- **Failed check → refuse immediately** (exit code 1, checks named). **Pending → wait** up to `waitTimeoutSeconds`, then refuse. There is no override flag: fix CI (re-run `task ship`) and rerun `task merge`.
|
|
219
|
-
- **No checks at all → refuse**
|
|
220
|
-
- **
|
|
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
|
+
- **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
|
+
- **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.
|
|
221
226
|
|
|
222
227
|
### Configuration (`.vibeops.json` → `merge`)
|
|
223
228
|
|
|
@@ -227,7 +232,7 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
227
232
|
"requiredChecks": ["build", "lint*"],
|
|
228
233
|
"releaseRequiredChecks": ["release smoke"],
|
|
229
234
|
"waitTimeoutSeconds": 900,
|
|
230
|
-
"pollIntervalSeconds":
|
|
235
|
+
"pollIntervalSeconds": 10,
|
|
231
236
|
"emptyRollupGraceSeconds": 30,
|
|
232
237
|
"allowNoChecks": false
|
|
233
238
|
}
|
|
@@ -239,13 +244,13 @@ Before calling `gh pr merge` / `glab mr merge`:
|
|
|
239
244
|
| `requiredChecks` | `[]` | array of non-empty strings | Checks that must be present and green for `task merge`. A missing one refuses: `required check "X" never ran`. |
|
|
240
245
|
| `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
|
|
241
246
|
| `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
|
|
242
|
-
| `pollIntervalSeconds` | `
|
|
243
|
-
| `emptyRollupGraceSeconds` | `30` | integer 0–600 |
|
|
247
|
+
| `pollIntervalSeconds` | `10` | integer 1–300, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
|
|
248
|
+
| `emptyRollupGraceSeconds` | `30` | integer 0–600 | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies (time for external CI to report). |
|
|
244
249
|
| `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
|
|
245
250
|
|
|
246
251
|
- Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
|
|
247
252
|
- The block is validated strictly: it must be an object, unknown keys (typos) and wrong types are errors, and `task merge` / `task release` exit 1 naming the key.
|
|
248
|
-
- **Recommended:** list your CI jobs in `requiredChecks`. Without
|
|
253
|
+
- **Recommended:** list your CI jobs in `requiredChecks`. Required checks must be present **and** completed green; missing or pending means wait. Without them the gate still waits for every GitHub Actions workflow that triggers on the PR (see above), but checks from other CI systems are only seen once they report.
|
|
249
254
|
|
|
250
255
|
## License
|
|
251
256
|
|
package/dist/cli.js
CHANGED
|
@@ -88,8 +88,8 @@ task
|
|
|
88
88
|
await taskDelCommand(taskRef, {
|
|
89
89
|
dryRun: Boolean(opts.dryRun),
|
|
90
90
|
force: Boolean(opts.force),
|
|
91
|
-
noRemoteDelete:
|
|
92
|
-
noCloseMr:
|
|
91
|
+
noRemoteDelete: opts.remoteDelete === false,
|
|
92
|
+
noCloseMr: opts.closeMr === false,
|
|
93
93
|
cwd: opts.cwd,
|
|
94
94
|
});
|
|
95
95
|
});
|
|
@@ -148,7 +148,7 @@ task
|
|
|
148
148
|
.action(async (taskRef, opts) => {
|
|
149
149
|
await taskSyncCommand(taskRef, {
|
|
150
150
|
dryRun: Boolean(opts.dryRun),
|
|
151
|
-
noRemoteDelete:
|
|
151
|
+
noRemoteDelete: opts.remoteDelete === false,
|
|
152
152
|
force: Boolean(opts.force),
|
|
153
153
|
cwd: opts.cwd,
|
|
154
154
|
});
|
|
@@ -165,7 +165,7 @@ task
|
|
|
165
165
|
.action(async (opts) => {
|
|
166
166
|
await taskReleaseCommand({
|
|
167
167
|
dryRun: Boolean(opts.dryRun),
|
|
168
|
-
noMerge:
|
|
168
|
+
noMerge: opts.merge === false,
|
|
169
169
|
merge: Boolean(opts.merge),
|
|
170
170
|
squash: Boolean(opts.squash),
|
|
171
171
|
rebase: Boolean(opts.rebase),
|
|
@@ -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 { loadActionableTasks, readGitContext } from "../lib/task.js";
|
|
15
|
+
import { branchNameForTaskFile, loadActionableTasks, readGitContext } from "../lib/task.js";
|
|
16
16
|
async function loadGitConfigOrNull(root, dryRun) {
|
|
17
17
|
try {
|
|
18
18
|
return await requireGitConfig(root);
|
|
@@ -242,7 +242,7 @@ export async function taskAddCommand(opts = {}) {
|
|
|
242
242
|
const integration = gitCfg?.integrationBranch ?? "integration";
|
|
243
243
|
if (dryRun) {
|
|
244
244
|
log.info(`[dry-run] Would create ${bold(taskId)} → ${cyan(relFile)}`);
|
|
245
|
-
log.info(dim(` branch
|
|
245
|
+
log.info(dim(` branch ${branchNameForTaskFile(filePath)} from ${gitCfg?.remote ?? "origin"}/${integration}`));
|
|
246
246
|
if (gitCfg) {
|
|
247
247
|
await startTaskBranch({
|
|
248
248
|
cwd: root,
|
|
@@ -95,7 +95,7 @@ export async function taskMergeCommand(taskRef, options = {}) {
|
|
|
95
95
|
log.info(` ${dim("MR/PR")} ${mergeRequestUrl}`);
|
|
96
96
|
log.info(` ${dim("target")} ${ctx.baseBranch} (${gitCfg.integrationBranch})`);
|
|
97
97
|
log.info(` ${dim("method")} ${method}`);
|
|
98
|
-
log.info(` ${dim("
|
|
98
|
+
log.info(` ${dim("gate")} waits until every check on the head commit is green${requiredChecks.length > 0 ? ` + required: ${requiredChecks.join(", ")}` : ""}${mergeCfg.allowNoChecks ? dim(" (allowNoChecks: true)") : ""}`);
|
|
99
99
|
log.blank();
|
|
100
100
|
if (dryRun) {
|
|
101
101
|
await mergeMergeRequest({
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions part of the merge gate: which workflow runs a PR head commit
|
|
3
|
+
* is still owed, and whether the runs that exist for it are finished and green.
|
|
4
|
+
*
|
|
5
|
+
* Why: `statusCheckRollup` lists every check on the head commit, including
|
|
6
|
+
* checks from other events (e.g. a `push` workflow on the same commit). Right
|
|
7
|
+
* after a PR is opened or pushed, GitHub has not created the `pull_request`
|
|
8
|
+
* workflow run — or its jobs — yet, so a rollup made only of earlier, finished
|
|
9
|
+
* checks looks green although the PR's own CI has not started. The gate
|
|
10
|
+
* therefore also asks the Actions API:
|
|
11
|
+
*
|
|
12
|
+
* 1. Expected runs: workflows in `.github/workflows` at the head commit whose
|
|
13
|
+
* `on:` triggers `pull_request` for this PR (base branch, changed paths,
|
|
14
|
+
* activity types — GitHub's documented filter rules) and that are active.
|
|
15
|
+
* Each must have a `pull_request` run for the head SHA.
|
|
16
|
+
* 2. Every workflow run for the head SHA (any event) must be completed with
|
|
17
|
+
* `success`, `neutral` or `skipped` (latest run per workflow + event).
|
|
18
|
+
*
|
|
19
|
+
* Docs: https://docs.github.com/actions/writing-workflows/workflow-syntax-for-github-actions#onpull_requestpull_request_targetbranchesbranches-ignore
|
|
20
|
+
* https://docs.github.com/rest/actions/workflow-runs#list-workflow-runs-for-a-repository
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { parse as parseYaml } from "yaml";
|
|
25
|
+
const execFileAsync = promisify(execFile);
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Filter patterns (GitHub "Filter pattern cheat sheet")
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* GitHub filter pattern → anchored RegExp. `*` = any chars except `/`,
|
|
31
|
+
* `**` = any chars, `?` / `+` = zero-or-one / one-or-more of the preceding
|
|
32
|
+
* char, `[…]` = character class, `\` escapes the next char.
|
|
33
|
+
*/
|
|
34
|
+
export function filterPatternToRegExp(pattern) {
|
|
35
|
+
let out = "";
|
|
36
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
37
|
+
const ch = pattern[i];
|
|
38
|
+
if (ch === "\\" && i + 1 < pattern.length) {
|
|
39
|
+
out += escapeRegExp(pattern[++i]);
|
|
40
|
+
}
|
|
41
|
+
else if (ch === "*") {
|
|
42
|
+
if (pattern[i + 1] === "*") {
|
|
43
|
+
out += ".*";
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
out += "[^/]*";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (ch === "?" || ch === "+") {
|
|
51
|
+
out += ch;
|
|
52
|
+
}
|
|
53
|
+
else if (ch === "[") {
|
|
54
|
+
const end = pattern.indexOf("]", i + 1);
|
|
55
|
+
if (end === -1) {
|
|
56
|
+
out += "\\[";
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
out += pattern.slice(i, end + 1);
|
|
60
|
+
i = end;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
out += escapeRegExp(ch);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return new RegExp(`^${out}$`);
|
|
68
|
+
}
|
|
69
|
+
function escapeRegExp(ch) {
|
|
70
|
+
return ch.replace(/[.^$(){}|[\]\\*+?/]/g, "\\$&");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Ordered include list with `!` negations (`branches`, `paths`): the last
|
|
74
|
+
* matching pattern decides; no match → not included.
|
|
75
|
+
*/
|
|
76
|
+
export function includedByFilter(patterns, value) {
|
|
77
|
+
let included = false;
|
|
78
|
+
for (const raw of patterns) {
|
|
79
|
+
const negated = raw.startsWith("!");
|
|
80
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
81
|
+
if (filterPatternToRegExp(pattern).test(value))
|
|
82
|
+
included = !negated;
|
|
83
|
+
}
|
|
84
|
+
return included;
|
|
85
|
+
}
|
|
86
|
+
/** Ignore list (`branches-ignore`, `paths-ignore`): any match ignores. */
|
|
87
|
+
export function ignoredByFilter(patterns, value) {
|
|
88
|
+
return patterns.some((p) => filterPatternToRegExp(p).test(value));
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Workflow triggers
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
/** GitHub evaluates path filters against at most this many changed files. */
|
|
94
|
+
export const PATH_FILTER_FILE_LIMIT = 300;
|
|
95
|
+
function stringList(raw) {
|
|
96
|
+
if (raw === undefined || raw === null)
|
|
97
|
+
return null;
|
|
98
|
+
if (typeof raw === "string")
|
|
99
|
+
return [raw];
|
|
100
|
+
if (Array.isArray(raw))
|
|
101
|
+
return raw.filter((x) => typeof x === "string");
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Does this workflow start a `pull_request` run for the PR's head commit?
|
|
106
|
+
*
|
|
107
|
+
* - `on: pull_request` in string, list or map form (no other key counts;
|
|
108
|
+
* `pull_request_target` runs on the base commit and is not awaited).
|
|
109
|
+
* - `types`: the head commit is reported by `opened` (new PR) or
|
|
110
|
+
* `synchronize` (push); a custom list must contain both, otherwise the
|
|
111
|
+
* workflow is not awaited (configure `merge.requiredChecks` for it).
|
|
112
|
+
* - `branches` / `branches-ignore` against the base branch;
|
|
113
|
+
* `paths` / `paths-ignore` against the PR's changed files (first 300).
|
|
114
|
+
* - YAML GitHub cannot parse still produces a failed run → expected.
|
|
115
|
+
*/
|
|
116
|
+
export async function workflowExpectedForPullRequest(yamlText, ctx) {
|
|
117
|
+
let doc;
|
|
118
|
+
try {
|
|
119
|
+
doc = parseYaml(yamlText);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { expected: true };
|
|
123
|
+
}
|
|
124
|
+
if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
125
|
+
return { expected: true };
|
|
126
|
+
}
|
|
127
|
+
const on = doc["on"] ?? doc["true"];
|
|
128
|
+
if (typeof on === "string") {
|
|
129
|
+
return on === "pull_request"
|
|
130
|
+
? { expected: true }
|
|
131
|
+
: { expected: false, reason: "not triggered by pull_request" };
|
|
132
|
+
}
|
|
133
|
+
if (Array.isArray(on)) {
|
|
134
|
+
return on.includes("pull_request")
|
|
135
|
+
? { expected: true }
|
|
136
|
+
: { expected: false, reason: "not triggered by pull_request" };
|
|
137
|
+
}
|
|
138
|
+
if (on === null || typeof on !== "object" || !("pull_request" in on)) {
|
|
139
|
+
return { expected: false, reason: "not triggered by pull_request" };
|
|
140
|
+
}
|
|
141
|
+
const cfg = on["pull_request"];
|
|
142
|
+
if (cfg === null || cfg === undefined || typeof cfg !== "object" || Array.isArray(cfg)) {
|
|
143
|
+
return { expected: true };
|
|
144
|
+
}
|
|
145
|
+
const c = cfg;
|
|
146
|
+
const types = stringList(c["types"]);
|
|
147
|
+
if (types !== null && !(types.includes("opened") && types.includes("synchronize"))) {
|
|
148
|
+
return { expected: false, reason: `pull_request types [${types.join(", ")}]` };
|
|
149
|
+
}
|
|
150
|
+
if (ctx.baseBranch !== null) {
|
|
151
|
+
const branches = stringList(c["branches"]);
|
|
152
|
+
const branchesIgnore = stringList(c["branches-ignore"]);
|
|
153
|
+
if (branches !== null && !includedByFilter(branches, ctx.baseBranch)) {
|
|
154
|
+
return { expected: false, reason: `branches filter excludes ${ctx.baseBranch}` };
|
|
155
|
+
}
|
|
156
|
+
if (branchesIgnore !== null && ignoredByFilter(branchesIgnore, ctx.baseBranch)) {
|
|
157
|
+
return { expected: false, reason: `branches-ignore matches ${ctx.baseBranch}` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const paths = stringList(c["paths"]);
|
|
161
|
+
const pathsIgnore = stringList(c["paths-ignore"]);
|
|
162
|
+
if (paths !== null || pathsIgnore !== null) {
|
|
163
|
+
const files = (await ctx.changedFiles()).slice(0, PATH_FILTER_FILE_LIMIT);
|
|
164
|
+
if (paths !== null && !files.some((f) => includedByFilter(paths, f))) {
|
|
165
|
+
return { expected: false, reason: "paths filter matches no changed file" };
|
|
166
|
+
}
|
|
167
|
+
if (pathsIgnore !== null && files.every((f) => ignoredByFilter(pathsIgnore, f))) {
|
|
168
|
+
return { expected: false, reason: "paths-ignore matches every changed file" };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { expected: true };
|
|
172
|
+
}
|
|
173
|
+
const RUN_PASS = new Set(["success", "neutral", "skipped"]);
|
|
174
|
+
function runPath(run) {
|
|
175
|
+
return run.path.split("@")[0];
|
|
176
|
+
}
|
|
177
|
+
function describeRun(run, detail) {
|
|
178
|
+
return `workflow "${run.name}" (${run.event}) ${detail}`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Latest run per (workflow, event) must be completed and passing; every
|
|
182
|
+
* expected workflow must have a `pull_request` run. Unknown statuses /
|
|
183
|
+
* conclusions fail closed.
|
|
184
|
+
*/
|
|
185
|
+
export function evaluateActionsRuns(runs, expectedPaths) {
|
|
186
|
+
const latest = new Map();
|
|
187
|
+
for (const run of runs) {
|
|
188
|
+
const key = `${runPath(run)}\u0000${run.event}`;
|
|
189
|
+
const prev = latest.get(key);
|
|
190
|
+
if (prev === undefined || run.id > prev.id)
|
|
191
|
+
latest.set(key, run);
|
|
192
|
+
}
|
|
193
|
+
const failed = [];
|
|
194
|
+
const pending = [];
|
|
195
|
+
for (const run of latest.values()) {
|
|
196
|
+
const status = run.status.toLowerCase();
|
|
197
|
+
if (status !== "completed") {
|
|
198
|
+
pending.push(describeRun(run, `is ${status || "missing status"}`));
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const conclusion = (run.conclusion ?? "").toLowerCase();
|
|
202
|
+
if (RUN_PASS.has(conclusion))
|
|
203
|
+
continue;
|
|
204
|
+
failed.push(describeRun(run, conclusion === "action_required"
|
|
205
|
+
? "needs approval (action_required)"
|
|
206
|
+
: `concluded ${conclusion || "without a conclusion"}`));
|
|
207
|
+
}
|
|
208
|
+
const missing = expectedPaths.filter((p) => ![...latest.values()].some((r) => r.event === "pull_request" && runPath(r) === p));
|
|
209
|
+
return { failed, pending, missing };
|
|
210
|
+
}
|
|
211
|
+
/** `https://<host>/<owner>/<repo>/pull/<n>` → parts, else `null`. */
|
|
212
|
+
export function parseGitHubPullUrl(url) {
|
|
213
|
+
const m = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)/i.exec(url.trim());
|
|
214
|
+
if (m === null)
|
|
215
|
+
return null;
|
|
216
|
+
return { hostname: m[1], owner: m[2], repo: m[3], number: m[4] };
|
|
217
|
+
}
|
|
218
|
+
/** `gh api` failed with HTTP 404 (resource absent). */
|
|
219
|
+
export class GitHubNotFoundError extends Error {
|
|
220
|
+
}
|
|
221
|
+
async function ghApi(cwd, pr, path, accept) {
|
|
222
|
+
const args = ["api"];
|
|
223
|
+
if (pr.hostname.toLowerCase() !== "github.com")
|
|
224
|
+
args.push("--hostname", pr.hostname);
|
|
225
|
+
if (accept !== undefined)
|
|
226
|
+
args.push("-H", `Accept: ${accept}`);
|
|
227
|
+
args.push(path);
|
|
228
|
+
try {
|
|
229
|
+
const { stdout } = await execFileAsync("gh", args, { cwd, maxBuffer: 16 * 1024 * 1024 });
|
|
230
|
+
return stdout;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
const e = error;
|
|
234
|
+
const text = `${e.stderr ?? ""} ${e.message ?? ""}`;
|
|
235
|
+
if (/HTTP 404|Not Found/i.test(text))
|
|
236
|
+
throw new GitHubNotFoundError(path);
|
|
237
|
+
throw new Error(`gh api ${path}: ${(e.stderr ?? e.message ?? "").trim().split("\n")[0]}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function repoPath(pr) {
|
|
241
|
+
return `repos/${encodeURIComponent(pr.owner)}/${encodeURIComponent(pr.repo)}`;
|
|
242
|
+
}
|
|
243
|
+
const RUNS_PAGE_SIZE = 100;
|
|
244
|
+
const RUNS_MAX_PAGES = 10;
|
|
245
|
+
const FILES_PAGE_SIZE = 100;
|
|
246
|
+
/** All workflow runs for `sha` (Actions disabled / absent → empty). */
|
|
247
|
+
export async function listWorkflowRunsForSha(cwd, pr, sha) {
|
|
248
|
+
const runs = [];
|
|
249
|
+
for (let page = 1; page <= RUNS_MAX_PAGES; page++) {
|
|
250
|
+
let text;
|
|
251
|
+
try {
|
|
252
|
+
text = await ghApi(cwd, pr, `${repoPath(pr)}/actions/runs?head_sha=${sha}&per_page=${RUNS_PAGE_SIZE}&page=${page}`);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error instanceof GitHubNotFoundError)
|
|
256
|
+
return runs;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
const parsed = JSON.parse(text);
|
|
260
|
+
if (!Array.isArray(parsed.workflow_runs)) {
|
|
261
|
+
throw new Error("Actions API returned no workflow_runs list");
|
|
262
|
+
}
|
|
263
|
+
for (const r of parsed.workflow_runs) {
|
|
264
|
+
if (typeof r["id"] !== "number" || typeof r["path"] !== "string") {
|
|
265
|
+
throw new Error("Actions API returned a workflow run without id/path");
|
|
266
|
+
}
|
|
267
|
+
runs.push({
|
|
268
|
+
id: r["id"],
|
|
269
|
+
name: typeof r["name"] === "string" ? r["name"] : String(r["path"]),
|
|
270
|
+
path: r["path"],
|
|
271
|
+
event: typeof r["event"] === "string" ? r["event"] : "",
|
|
272
|
+
status: typeof r["status"] === "string" ? r["status"] : "",
|
|
273
|
+
conclusion: typeof r["conclusion"] === "string" ? r["conclusion"] : null,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
if (parsed.workflow_runs.length < RUNS_PAGE_SIZE)
|
|
277
|
+
return runs;
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`more than ${RUNS_PAGE_SIZE * RUNS_MAX_PAGES} workflow runs for ${sha}`);
|
|
280
|
+
}
|
|
281
|
+
/** Paths of workflows GitHub will not run (`disabled_*`). */
|
|
282
|
+
async function listDisabledWorkflowPaths(cwd, pr) {
|
|
283
|
+
let text;
|
|
284
|
+
try {
|
|
285
|
+
text = await ghApi(cwd, pr, `${repoPath(pr)}/actions/workflows?per_page=100`);
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
if (error instanceof GitHubNotFoundError)
|
|
289
|
+
return new Set();
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
const parsed = JSON.parse(text);
|
|
293
|
+
const out = new Set();
|
|
294
|
+
for (const w of parsed.workflows ?? []) {
|
|
295
|
+
if (typeof w.path === "string" && typeof w.state === "string" && w.state !== "active") {
|
|
296
|
+
out.add(w.path);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
async function listChangedFiles(cwd, pr) {
|
|
302
|
+
const files = [];
|
|
303
|
+
// GitHub path filters look at the first 300 changed files only.
|
|
304
|
+
for (let page = 1; files.length < PATH_FILTER_FILE_LIMIT; page++) {
|
|
305
|
+
const text = await ghApi(cwd, pr, `${repoPath(pr)}/pulls/${pr.number}/files?per_page=${FILES_PAGE_SIZE}&page=${page}`);
|
|
306
|
+
const rows = JSON.parse(text);
|
|
307
|
+
if (!Array.isArray(rows))
|
|
308
|
+
throw new Error("pulls files API returned no list");
|
|
309
|
+
for (const row of rows)
|
|
310
|
+
if (typeof row.filename === "string")
|
|
311
|
+
files.push(row.filename);
|
|
312
|
+
if (rows.length < FILES_PAGE_SIZE)
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
return files;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Workflows that will start a `pull_request` run for the head commit:
|
|
319
|
+
* `.github/workflows/*.y{a,}ml` at `sha`, triggered for this PR, not disabled.
|
|
320
|
+
*/
|
|
321
|
+
export async function listExpectedPullRequestWorkflows(cwd, pr, sha, baseBranch) {
|
|
322
|
+
let listing;
|
|
323
|
+
try {
|
|
324
|
+
listing = await ghApi(cwd, pr, `${repoPath(pr)}/contents/.github/workflows?ref=${sha}`);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof GitHubNotFoundError)
|
|
328
|
+
return [];
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
const entries = JSON.parse(listing);
|
|
332
|
+
if (!Array.isArray(entries))
|
|
333
|
+
return [];
|
|
334
|
+
const candidates = entries
|
|
335
|
+
.filter((e) => e.type === "file" && typeof e.path === "string")
|
|
336
|
+
.map((e) => e.path)
|
|
337
|
+
.filter((p) => /\.ya?ml$/i.test(p));
|
|
338
|
+
if (candidates.length === 0)
|
|
339
|
+
return [];
|
|
340
|
+
const disabled = await listDisabledWorkflowPaths(cwd, pr);
|
|
341
|
+
let changed = null;
|
|
342
|
+
const ctx = {
|
|
343
|
+
baseBranch,
|
|
344
|
+
changedFiles: () => (changed ??= listChangedFiles(cwd, pr)),
|
|
345
|
+
};
|
|
346
|
+
const expected = [];
|
|
347
|
+
for (const path of candidates) {
|
|
348
|
+
if (disabled.has(path))
|
|
349
|
+
continue;
|
|
350
|
+
const text = await ghApi(cwd, pr, `${repoPath(pr)}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${sha}`, "application/vnd.github.raw+json");
|
|
351
|
+
if ((await workflowExpectedForPullRequest(text, ctx)).expected)
|
|
352
|
+
expected.push({ path });
|
|
353
|
+
}
|
|
354
|
+
return expected;
|
|
355
|
+
}
|
package/dist/lib/logger.js
CHANGED
package/dist/lib/merge-config.js
CHANGED
|
@@ -5,7 +5,8 @@ export const MERGE_GATE_DEFAULTS = {
|
|
|
5
5
|
requiredChecks: [],
|
|
6
6
|
releaseRequiredChecks: [],
|
|
7
7
|
waitTimeoutSeconds: 900,
|
|
8
|
-
|
|
8
|
+
// Same default as `gh pr checks --watch --interval` (cli.github.com/manual/gh_pr_checks).
|
|
9
|
+
pollIntervalSeconds: 10,
|
|
9
10
|
emptyRollupGraceSeconds: 30,
|
|
10
11
|
allowNoChecks: false,
|
|
11
12
|
};
|
package/dist/lib/pr-create.js
CHANGED
|
@@ -3,6 +3,7 @@ import { promisify } from "node:util";
|
|
|
3
3
|
import { mergeRequestLabel } from "./git-host.js";
|
|
4
4
|
import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
|
|
5
5
|
import { isMergeRequestReadyToMerge, isPipelineActive, pipelineGateState, pipelineStatusFromHost, } from "./merge-request-readiness.js";
|
|
6
|
+
import { evaluateActionsRuns, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
|
|
6
7
|
import { dim, log } from "./logger.js";
|
|
7
8
|
import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
@@ -268,6 +269,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
|
|
|
268
269
|
hasConflicts: parsed.has_conflicts ?? null,
|
|
269
270
|
checks: null,
|
|
270
271
|
headSha: parsed.sha ?? null,
|
|
272
|
+
baseBranch: parsed.target_branch ?? null,
|
|
271
273
|
};
|
|
272
274
|
}
|
|
273
275
|
const { stdout } = await execFileAsync("gh", [
|
|
@@ -275,7 +277,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
|
|
|
275
277
|
"view",
|
|
276
278
|
ref,
|
|
277
279
|
"--json",
|
|
278
|
-
"state,mergedAt,mergeCommit,mergeable,headRefOid,statusCheckRollup",
|
|
280
|
+
"state,mergedAt,mergeCommit,mergeable,headRefOid,baseRefName,statusCheckRollup",
|
|
279
281
|
], { cwd, maxBuffer: 4 * 1024 * 1024 });
|
|
280
282
|
const parsed = JSON.parse(stdout.trim());
|
|
281
283
|
if (options.strict) {
|
|
@@ -312,6 +314,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
|
|
|
312
314
|
hasConflicts: mergeable === "CONFLICTING" ? true : mergeable === "MERGEABLE" ? false : null,
|
|
313
315
|
checks,
|
|
314
316
|
headSha: parsed.headRefOid ?? null,
|
|
317
|
+
baseBranch: parsed.baseRefName ?? null,
|
|
315
318
|
};
|
|
316
319
|
}
|
|
317
320
|
/** Poll host until MR/PR state is merged (or timeout). */
|
|
@@ -365,11 +368,56 @@ function evaluateDetailsGate(details, requiredChecks) {
|
|
|
365
368
|
};
|
|
366
369
|
}
|
|
367
370
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
371
|
+
/**
|
|
372
|
+
* Upper bound for the back-off after consecutive host read errors. GitHub asks
|
|
373
|
+
* clients that hit a secondary rate limit to wait at least one minute before
|
|
374
|
+
* retrying (docs.github.com/rest/using-the-rest-api/rate-limits-for-the-rest-api).
|
|
375
|
+
*/
|
|
376
|
+
const ERROR_BACKOFF_CAP_MS = 60_000;
|
|
377
|
+
/** Exponential back-off with full jitter in [base, min(cap, base·2^n)]. */
|
|
378
|
+
export function errorBackoffMs(baseMs, consecutiveErrors) {
|
|
379
|
+
const ceiling = Math.min(ERROR_BACKOFF_CAP_MS, baseMs * 2 ** Math.max(0, consecutiveErrors));
|
|
380
|
+
return baseMs + Math.random() * Math.max(0, ceiling - baseMs);
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* GitHub Actions view of the head commit (see `github-actions-gate.ts`).
|
|
384
|
+
* Expected workflows depend only on the head SHA, so they are cached per SHA.
|
|
385
|
+
*/
|
|
386
|
+
async function evaluateGitHubActions(cwd, url, details, expectedCache) {
|
|
387
|
+
const pr = parseGitHubPullUrl(url);
|
|
388
|
+
if (pr === null) {
|
|
389
|
+
throw new MergeGateError("PR", [
|
|
390
|
+
`cannot derive the repository from "${url}" to read its GitHub Actions runs`,
|
|
391
|
+
]);
|
|
392
|
+
}
|
|
393
|
+
const sha = details.headSha;
|
|
394
|
+
let expected = expectedCache.get(sha);
|
|
395
|
+
if (expected === undefined) {
|
|
396
|
+
expected = await listExpectedPullRequestWorkflows(cwd, pr, sha, details.baseBranch);
|
|
397
|
+
expectedCache.set(sha, expected);
|
|
398
|
+
}
|
|
399
|
+
const runs = await listWorkflowRunsForSha(cwd, pr, sha);
|
|
400
|
+
return {
|
|
401
|
+
result: evaluateActionsRuns(runs, expected.map((w) => w.path)),
|
|
402
|
+
runCount: runs.length,
|
|
403
|
+
expectedCount: expected.length,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
function shortSha(sha) {
|
|
407
|
+
return (sha ?? "?").slice(0, 7);
|
|
408
|
+
}
|
|
368
409
|
/**
|
|
369
410
|
* Merge gate: poll the MR/PR until every check is green and the host reports it
|
|
370
411
|
* mergeable. Refuses immediately on any failed check, on host output it cannot
|
|
371
412
|
* trust, and on "no checks at all" (unless allowed); refuses after the timeout
|
|
372
|
-
* when checks stay pending or a required check never appears.
|
|
413
|
+
* when checks stay pending or a required / expected check never appears.
|
|
414
|
+
*
|
|
415
|
+
* GitHub: besides `statusCheckRollup`, the head commit's GitHub Actions runs
|
|
416
|
+
* must all be finished and green, and every workflow that triggers on this
|
|
417
|
+
* PR's `pull_request` event must have a run for the head SHA — so checks left
|
|
418
|
+
* on the commit by other events never stand in for the PR's own CI. Before
|
|
419
|
+
* returning green, the head SHA is read again; if it moved, the new head is
|
|
420
|
+
* evaluated from scratch (the merge is also pinned with `--match-head-commit`).
|
|
373
421
|
*/
|
|
374
422
|
export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
375
423
|
const defaults = mergeGateTiming(MERGE_GATE_DEFAULTS);
|
|
@@ -382,10 +430,13 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
|
382
430
|
const label = mergeRequestLabel(host);
|
|
383
431
|
const start = Date.now();
|
|
384
432
|
const deadline = start + timeoutMs;
|
|
385
|
-
|
|
433
|
+
const expectedCache = new Map();
|
|
434
|
+
let lastLogged = "";
|
|
386
435
|
let warnedGitLabRequired = false;
|
|
436
|
+
let consecutiveErrors = 0;
|
|
387
437
|
for (;;) {
|
|
388
438
|
let problems = [];
|
|
439
|
+
let readError = false;
|
|
389
440
|
let details = null;
|
|
390
441
|
try {
|
|
391
442
|
details = await readMergeRequestDetails(cwd, host, url, { strict: true });
|
|
@@ -396,6 +447,7 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
|
396
447
|
}
|
|
397
448
|
const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
398
449
|
problems = [`could not read ${label} details from the host: ${msg}`];
|
|
450
|
+
readError = true;
|
|
399
451
|
}
|
|
400
452
|
if (details === null) {
|
|
401
453
|
// problems set in catch; keep polling (transient CLI / network error).
|
|
@@ -419,12 +471,39 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
|
419
471
|
if (details.hasConflicts === true) {
|
|
420
472
|
return { ok: false, problems: [`${label} has merge conflicts`] };
|
|
421
473
|
}
|
|
474
|
+
let actions = null;
|
|
475
|
+
if (!gitlab) {
|
|
476
|
+
try {
|
|
477
|
+
actions = await evaluateGitHubActions(cwd, url, details, expectedCache);
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
if (error instanceof MergeGateError)
|
|
481
|
+
return { ok: false, problems: error.problems };
|
|
482
|
+
const msg = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
483
|
+
problems = [`could not read GitHub Actions runs for ${shortSha(details.headSha)}: ${msg}`];
|
|
484
|
+
readError = true;
|
|
485
|
+
}
|
|
486
|
+
if (actions !== null && actions.result.failed.length > 0) {
|
|
487
|
+
return { ok: false, problems: [...actions.result.failed] };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const actionsPending = actions === null
|
|
491
|
+
? []
|
|
492
|
+
: [
|
|
493
|
+
...actions.result.pending,
|
|
494
|
+
...actions.result.missing.map((p) => `workflow ${p} (pull_request) has not started for ${shortSha(details.headSha)} yet`),
|
|
495
|
+
];
|
|
496
|
+
const actionsSilent = actions === null || (actions.runCount === 0 && actions.expectedCount === 0);
|
|
422
497
|
// "No checks at all" is judged here only when no required check could
|
|
423
|
-
// report it instead (GitHub with requiredChecks → "never ran" path)
|
|
424
|
-
|
|
498
|
+
// report it instead (GitHub with requiredChecks → "never ran" path) and
|
|
499
|
+
// no GitHub Actions run exists or is expected for the head commit.
|
|
500
|
+
const noChecksRule = !readError && ev.noChecks && actionsSilent && (gitlab || required.length === 0);
|
|
425
501
|
const inGrace = waitForPending && noChecksRule && Date.now() - start < graceMs;
|
|
426
|
-
if (
|
|
427
|
-
problems
|
|
502
|
+
if (readError) {
|
|
503
|
+
// problems set above; keep polling with back-off.
|
|
504
|
+
}
|
|
505
|
+
else if (ev.state === "pending" || actionsPending.length > 0) {
|
|
506
|
+
problems = [...ev.problems, ...actionsPending];
|
|
428
507
|
}
|
|
429
508
|
else if (inGrace) {
|
|
430
509
|
problems = ["no status checks reported yet"];
|
|
@@ -441,17 +520,42 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
|
441
520
|
else {
|
|
442
521
|
const readiness = mergeRequestReadinessFromDetails(details);
|
|
443
522
|
if (readiness !== null && isMergeRequestReadyToMerge(readiness)) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
523
|
+
// Race guard: the head may have moved while the checks were read.
|
|
524
|
+
let again = null;
|
|
525
|
+
try {
|
|
526
|
+
again = await readMergeRequestDetails(cwd, host, url, { strict: true });
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
if (error instanceof MergeRequestShapeError) {
|
|
530
|
+
return { ok: false, problems: [error.message] };
|
|
531
|
+
}
|
|
532
|
+
readError = true;
|
|
533
|
+
problems = [`could not re-read the ${label} head before merging`];
|
|
534
|
+
}
|
|
535
|
+
if (again !== null && again.state === "merged") {
|
|
536
|
+
return { ok: true, alreadyMerged: true, details: again };
|
|
537
|
+
}
|
|
538
|
+
if (again !== null && again.headSha === details.headSha && again.state === "open") {
|
|
539
|
+
if (noChecksRule) {
|
|
540
|
+
// Once, after the gate has decided to merge — not on every poll.
|
|
541
|
+
log.warn(`WARNING: merging ${label} with NO checks (merge.allowNoChecks is true). Nothing verified this change.`);
|
|
542
|
+
}
|
|
543
|
+
return { ok: true, alreadyMerged: false, details };
|
|
544
|
+
}
|
|
545
|
+
if (again !== null) {
|
|
546
|
+
problems = [
|
|
547
|
+
`${label} head moved from ${shortSha(details.headSha)} to ${shortSha(again.headSha)} while checks were read; re-checking the new head`,
|
|
548
|
+
];
|
|
447
549
|
}
|
|
448
|
-
return { ok: true, alreadyMerged: false, details };
|
|
449
550
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
551
|
+
else {
|
|
552
|
+
problems = [
|
|
553
|
+
`host does not report the ${label} as mergeable yet (${details.detailedMergeStatus || details.mergeStatus || "unknown"})`,
|
|
554
|
+
];
|
|
555
|
+
}
|
|
453
556
|
}
|
|
454
557
|
}
|
|
558
|
+
consecutiveErrors = readError ? consecutiveErrors + 1 : 0;
|
|
455
559
|
const now = Date.now();
|
|
456
560
|
if (!waitForPending || now >= deadline) {
|
|
457
561
|
const suffix = waitForPending
|
|
@@ -462,11 +566,13 @@ export async function waitForMergeGate(cwd, host, url, options = {}) {
|
|
|
462
566
|
problems: problems.map((p, i) => (i === problems.length - 1 ? `${p}${suffix}` : p)),
|
|
463
567
|
};
|
|
464
568
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
569
|
+
const summary = problems.join("; ");
|
|
570
|
+
if (summary !== lastLogged) {
|
|
571
|
+
log.info(dim(`Waiting for ${label} checks: ${summary}…`));
|
|
572
|
+
lastLogged = summary;
|
|
468
573
|
}
|
|
469
|
-
|
|
574
|
+
const delay = readError ? errorBackoffMs(intervalMs, consecutiveErrors) : intervalMs;
|
|
575
|
+
await sleep(Math.min(delay, deadline - now));
|
|
470
576
|
}
|
|
471
577
|
}
|
|
472
578
|
function isMergeHttp405(error) {
|
package/dist/lib/task-add-llm.js
CHANGED
|
@@ -63,7 +63,7 @@ export async function llmScaffoldTask(params) {
|
|
|
63
63
|
});
|
|
64
64
|
const parsed = JSON.parse(extractJsonObject(raw));
|
|
65
65
|
const title = typeof parsed.title === "string"
|
|
66
|
-
? parsed.title.replace(/^TASK-\d+\s*[
|
|
66
|
+
? parsed.title.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim()
|
|
67
67
|
: "";
|
|
68
68
|
const slug = typeof parsed.slug === "string" ? slugify(parsed.slug, "task") : slugify(title, "task");
|
|
69
69
|
const markdown = typeof parsed.markdown === "string" ? parsed.markdown.trim() : "";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dim, log } from "./logger.js";
|
|
2
2
|
import { gitAddPaths, gitPush, listUnstagedRelPaths, listWorkingTreeRelPaths, partitionPathsForAutoCommit, readGitInfo, runGit, } from "./git.js";
|
|
3
3
|
export function featCommitMessageFor(taskId, title) {
|
|
4
|
-
const slug = title.replace(/^TASK-\d+\s*[
|
|
4
|
+
const slug = title.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim() || taskId;
|
|
5
5
|
return `feat(${taskId.toLowerCase()}): ${slug}`;
|
|
6
6
|
}
|
|
7
7
|
export function docsCommitMessageFor(taskId, suffix) {
|
package/dist/lib/task-pr-llm.js
CHANGED
|
@@ -9,7 +9,7 @@ Body: markdown with Summary, Changes, Test plan sections. Facts only from the in
|
|
|
9
9
|
/** Normalize PR titles to Conventional Commits with the TASK id as scope. */
|
|
10
10
|
export function normalizeTaskPrTitle(taskId, rawTitle) {
|
|
11
11
|
const trimmed = rawTitle.trim().replace(/\s+/g, " ");
|
|
12
|
-
const withoutTaskPrefix = trimmed.replace(/^TASK-\d+\s*[
|
|
12
|
+
const withoutTaskPrefix = trimmed.replace(/^TASK-\d+\s*[:-]\s*/i, "").trim();
|
|
13
13
|
return taskScopedCommitMessage(taskId, withoutTaskPrefix || trimmed).slice(0, 72);
|
|
14
14
|
}
|
|
15
15
|
export async function generateTaskPrWithLlm(input, cwd, preference) {
|
package/dist/lib/task-start.js
CHANGED
|
@@ -21,7 +21,6 @@ export async function startTaskBranch(opts) {
|
|
|
21
21
|
const taskFile = opts.taskFile;
|
|
22
22
|
const integrationBranch = opts.integrationBranch;
|
|
23
23
|
const remote = opts.remote ?? "origin";
|
|
24
|
-
const meta = await readTaskFile(taskFile);
|
|
25
24
|
const git = await readGitInfo(cwd);
|
|
26
25
|
if (!git.isRepo) {
|
|
27
26
|
if (opts.dryRun) {
|
|
@@ -39,8 +38,9 @@ export async function startTaskBranch(opts) {
|
|
|
39
38
|
}
|
|
40
39
|
log.warn("Only governance paths are dirty — proceeding.");
|
|
41
40
|
}
|
|
41
|
+
// The branch name comes from the file name: a dry run plans for a TASK file
|
|
42
|
+
// that `task add --dry-run` never wrote, so nothing above reads the file.
|
|
42
43
|
const taskBranch = branchNameForTaskFile(taskFile);
|
|
43
|
-
const existingCtx = await readGitContext(taskFile);
|
|
44
44
|
const branchExists = await gitBranchExists(cwd, taskBranch);
|
|
45
45
|
if (opts.dryRun) {
|
|
46
46
|
const base = (await resolveIntegrationBaseRef(cwd, remote, integrationBranch)) ??
|
|
@@ -52,6 +52,8 @@ export async function startTaskBranch(opts) {
|
|
|
52
52
|
: `dry-run — would git fetch ${remote} ${integrationBranch}, then git switch -c ${taskBranch} --no-track ${base}`));
|
|
53
53
|
return true;
|
|
54
54
|
}
|
|
55
|
+
const meta = await readTaskFile(taskFile);
|
|
56
|
+
const existingCtx = await readGitContext(taskFile);
|
|
55
57
|
// Worktree-safe (D-006): never check out the integration branch — another
|
|
56
58
|
// worktree may own it. New task branches start from <remote>/<integration>.
|
|
57
59
|
let baseRef = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodtek/vibeops",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.4",
|
|
4
4
|
"description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@9.15.9",
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/
|
|
11
|
+
"url": "git+https://github.com/goodtek-xyz/vibeops.git"
|
|
12
12
|
},
|
|
13
|
-
"homepage": "https://github.com/
|
|
13
|
+
"homepage": "https://github.com/goodtek-xyz/vibeops#readme",
|
|
14
14
|
"bugs": {
|
|
15
|
-
"url": "https://github.com/
|
|
15
|
+
"url": "https://github.com/goodtek-xyz/vibeops/issues"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
"dev": "tsx src/cli.ts",
|
|
35
35
|
"build": "tsc -p tsconfig.json",
|
|
36
36
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
37
|
+
"lint": "eslint .",
|
|
37
38
|
"start": "node dist/cli.js",
|
|
38
|
-
"test": "pnpm build && node --test
|
|
39
|
-
"smoke": "pnpm typecheck && pnpm build && node --test
|
|
39
|
+
"test": "pnpm build && node --test tests/*.test.mjs",
|
|
40
|
+
"smoke": "pnpm typecheck && pnpm build && node --test tests/*.test.mjs && node scripts/smoke.mjs",
|
|
40
41
|
"clean": "rm -rf dist",
|
|
41
42
|
"prepack": "pnpm clean && pnpm build",
|
|
42
43
|
"publish:dry": "pnpm publish --dry-run --no-git-checks",
|
|
@@ -55,11 +56,15 @@
|
|
|
55
56
|
"dependencies": {
|
|
56
57
|
"@inquirer/prompts": "^8.4.3",
|
|
57
58
|
"commander": "^12.1.0",
|
|
58
|
-
"gray-matter": "^4.0.3"
|
|
59
|
+
"gray-matter": "^4.0.3",
|
|
60
|
+
"yaml": "^2.9.1"
|
|
59
61
|
},
|
|
60
62
|
"devDependencies": {
|
|
63
|
+
"@eslint/js": "10.0.1",
|
|
61
64
|
"@types/node": "^20.14.0",
|
|
65
|
+
"eslint": "10.11.0",
|
|
62
66
|
"tsx": "^4.19.0",
|
|
63
|
-
"typescript": "^5.5.4"
|
|
67
|
+
"typescript": "^5.5.4",
|
|
68
|
+
"typescript-eslint": "8.71.0"
|
|
64
69
|
}
|
|
65
70
|
}
|