@goodtek/vibeops 3.1.7 → 3.1.9

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 CHANGED
@@ -4,6 +4,19 @@ All notable changes to VibeOps are documented here.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 3.1.9 - 2026-10-17
8
+
9
+ ### Fixed
10
+
11
+ - **The merge gate was blocked by GitHub's dynamic workflow runs.** A failed Dependabot run on the head commit (`event: dynamic`, `dynamic/dependabot/dependabot-updates`) refused a PR whose CI was green — and such runs cannot be re-run. Dynamic runs and `(dynamic)` check runs are now ignored; a `(dynamic)` check named in `requiredChecks` is still required (D-011, TASK-043).
12
+
13
+ ## 3.1.8 - 2026-10-17
14
+
15
+ ### Changed
16
+
17
+ - **Time values follow official documentation** (D-009, TASK-033). `merge.waitTimeoutSeconds` default 900 → 21600 (GitHub-hosted job limit; max 432000, the self-hosted limit) — the wait still ends as soon as checks finish. `merge.emptyRollupGraceSeconds` default 30 → 0: name CI outside GitHub Actions in `requiredChecks`. `task merge` / `task release` confirm the merge with one read instead of polling (the merge APIs answer synchronously). `scripts/npm-publish.sh` waits for the registry with back-off and jitter within a few-minute budget.
18
+ - **`vibeops init` makes the integration branch the default branch** of a repository it creates on GitHub (`gh repo edit --default-branch`) or GitLab (project API `default_branch`), so PRs and clones start from `develop`. Existing repositories are not changed; on failure the manual command is printed (TASK-035).
19
+
7
20
  ## 3.1.7 - 2026-10-17
8
21
 
9
22
  ### Fixed
package/README.md CHANGED
@@ -141,6 +141,7 @@ vibeops llm use auto # auto | codex-oauth | cursor-agent | openai
141
141
  ## Flags (common)
142
142
 
143
143
  - **`init`**: `--clients`, `--yes`, `--dry-run`, `--force`, `--git`, `--initial-commit`, `--git-policy gitflow|trunk`, `--integration-branch`, `--production-branch`, `--git-host github|gitlab`, `--allow-no-remote`, `--cwd`
144
+ When `init` creates the repository on GitHub / GitLab (interactive), it pushes `main` and the integration branch and makes the integration branch (`develop` with the GitFlow preset) the repository's default branch; an existing repository's default branch is not changed.
144
145
  - **`task add`**: `--dry-run`, `--non-interactive --idea "…"`
145
146
  - **`task del`**: `--dry-run`, `--force`, `--no-remote-delete`, `--no-close-mr`
146
147
  - **`task ship`**: `-m/--message`, `--new-cycle`, `--no-commit`, `--dry-run`, `--no-pr`, `--non-interactive`, `--allow-open-mr`, `--no-integrate`, `--recreate-branch`, `--skip-llm`
@@ -217,11 +218,13 @@ Before calling `gh pr merge` / `glab mr merge`:
217
218
  - **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
219
  - **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
220
  - 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.
221
+ - **Dynamic workflows are not CI:** runs GitHub generates on the fly — `event: dynamic`, e.g. `dynamic/dependabot/dependabot-updates`, the jobs that open Dependabot PRs — and check runs suffixed `(dynamic)` are ignored. GitHub documents them as "generated for a specific run and … not stored in your repository's `.github/workflows` directory" ([Dependabot on GitHub Actions runners](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/about-dependabot-on-github-actions-runners)); a failed one says nothing about the commit and cannot be re-run. A `(dynamic)` check named in `requiredChecks` is still required.
220
222
  - **GitLab:** the MR head pipeline must be `success` or `skipped`. `failed`, `canceled`, `manual` and unknown statuses refuse; `created` / `pending` / `running` / `scheduled` / … wait.
221
223
  - **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.
222
224
  - **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`.
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
+ - **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), list such checks in `requiredChecks` so the gate waits for them by name (`emptyRollupGraceSeconds`, default 0, can re-poll an empty list first).
226
+ - **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 21600 s, the GitHub-hosted job limit). Host read errors back off exponentially with jitter (capped at 60 s, GitHub's guidance for rate-limited clients).
227
+ - **After the merge:** `gh pr merge` / `glab mr merge` return the result synchronously (GitHub `merged: true`, GitLab `state: "merged"`), so VibeOps reads the PR/MR once to confirm. Not merged at that point (merge queue / auto-merge) is reported, not waited for.
225
228
  - **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
229
  - **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.
227
230
  - **`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).
@@ -233,9 +236,9 @@ Before calling `gh pr merge` / `glab mr merge`:
233
236
  "merge": {
234
237
  "requiredChecks": ["build", "lint*"],
235
238
  "releaseRequiredChecks": ["release smoke"],
236
- "waitTimeoutSeconds": 900,
239
+ "waitTimeoutSeconds": 21600,
237
240
  "pollIntervalSeconds": 10,
238
- "emptyRollupGraceSeconds": 30,
241
+ "emptyRollupGraceSeconds": 0,
239
242
  "allowNoChecks": false
240
243
  }
241
244
  }
@@ -245,9 +248,9 @@ Before calling `gh pr merge` / `glab mr merge`:
245
248
  |-----|---------|---------|---------|
246
249
  | `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`. |
247
250
  | `releaseRequiredChecks` | `[]` | array of non-empty strings | Same for `task release` (integration → production). Shown by `task release --dry-run`. |
248
- | `waitTimeoutSeconds` | `900` | integer 1–7200 | Max wait for pending checks. |
249
- | `pollIntervalSeconds` | `10` | integer 1–300, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
250
- | `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). |
251
+ | `waitTimeoutSeconds` | `21600` | integer 1–432000 | Upper bound for pending checks. The wait ends as soon as every check finished; the bound only stops a check that never does. Default = the 6-hour job limit of GitHub-hosted runners; self-hosted runners allow up to 5 days ([GitHub Actions limits](https://docs.github.com/actions/reference/limits)). |
252
+ | `pollIntervalSeconds` | `10` | integer ≥ 1, ≤ timeout | Poll interval while waiting (same default as `gh pr checks --watch`). |
253
+ | `emptyRollupGraceSeconds` | `0` | integer ≥ 0, ≤ timeout | Only when no GitHub Actions workflow is expected: how long an empty check list is re-polled before "no checks" applies. Default `0`: name CI outside GitHub Actions in `requiredChecks` so the gate waits for it by name, instead of guessing how long it takes to report. |
251
254
  | `allowNoChecks` | `false` | boolean | Merge a PR that has no checks at all (repo without CI). Prints one warning per such merge. |
252
255
 
253
256
  - Check names are exact (`CheckRun.name` / `StatusContext.context`) or a `*` glob (`"lint*"`, `"* · linux"`). Required checks are GitHub-only; GitLab gates on the head pipeline.
@@ -3,7 +3,7 @@ import { install, printReport } from "../bootstrap/installer.js";
3
3
  import { buildConfig, isVibeopsProject, readConfig, writeConfig } from "../lib/config.js";
4
4
  import { gitAddAll, gitBranchExists, gitCheckout, gitCommit, gitCreateBranch, gitInit, gitPullFastForwardOnly, gitPush, gitRemoteBranchExists, gitRemoteUrl, gitSetDefaultBranch, gitStatusPorcelain, hasAnyCommit, isGitRepository, } from "../lib/git.js";
5
5
  import { formatGitPolicySummary, askGitPolicy, parseGitPolicyArg, resolvePreset } from "../lib/git-policy.js";
6
- import { ensureOriginRemote } from "../lib/git-remote.js";
6
+ import { ensureOriginRemote, setRemoteDefaultBranch } from "../lib/git-remote.js";
7
7
  import { askInitClients, formatClientsList, parseClientsArg, } from "../lib/init-clients.js";
8
8
  import { askInput, askYesNo } from "../lib/inquirer-helpers.js";
9
9
  import { cyan, dim, green, log, yellow } from "../lib/logger.js";
@@ -272,6 +272,19 @@ export async function initCommand(options = {}) {
272
272
  await gitCheckout(projectRoot, gitConfig.integrationBranch);
273
273
  await gitPush(projectRoot, remoteName, gitConfig.integrationBranch, true);
274
274
  log.ok(`pushed ${gitConfig.integrationBranch} → ${remoteName}`);
275
+ // A repository init just created defaults to the integration branch, so PRs and
276
+ // clones start from it; an existing repository's setting is left to its owner.
277
+ if (remote.created === true) {
278
+ if (await setRemoteDefaultBranch(projectRoot, gitConfig.host, gitConfig.integrationBranch)) {
279
+ log.ok(`default branch → ${gitConfig.integrationBranch}`);
280
+ }
281
+ else {
282
+ log.warn(`Could not set the default branch to ${gitConfig.integrationBranch}.`);
283
+ log.info(dim(gitConfig.host === "gitlab"
284
+ ? ` glab api --method PUT projects/:id --raw-field default_branch=${gitConfig.integrationBranch}`
285
+ : ` gh repo edit --default-branch ${gitConfig.integrationBranch}`));
286
+ }
287
+ }
275
288
  }
276
289
  pushedBaseline = true;
277
290
  }
@@ -94,6 +94,15 @@ export function checkNameMatches(name, pattern) {
94
94
  * - any pending, or a required check not (yet) present → pending
95
95
  * - otherwise green (including "no checks at all" when nothing is required)
96
96
  */
97
+ /**
98
+ * Check runs GitHub suffixes with " (dynamic)" belong to dynamic workflows (see
99
+ * `isDynamicRun` in github-actions-gate.ts), not to the commit's CI: dropped unless a required
100
+ * check pattern names them.
101
+ */
102
+ export function withoutDynamicChecks(checks, requiredChecks = []) {
103
+ return checks.filter((c) => !(c.kind === "CheckRun" && c.name.endsWith(" (dynamic)")) ||
104
+ requiredChecks.some((p) => checkNameMatches(c.name, p)));
105
+ }
97
106
  export function evaluateCheckGate(checks, requiredChecks = []) {
98
107
  const failed = checks.filter((c) => c.verdict === "failed");
99
108
  const pending = checks.filter((c) => c.verdict === "pending");
@@ -145,10 +145,33 @@ async function createOnHost(cwd, remote, host, defaultBranch) {
145
145
  await runGit(cwd, ["remote", "rename", "origin", remote]);
146
146
  }
147
147
  }
148
- return { remote, url, host };
148
+ return { remote, url, host, created: true };
149
149
  }
150
150
  const url = await createGithubRepo(cwd, name);
151
- return { remote, url, host };
151
+ return { remote, url, host, created: true };
152
+ }
153
+ /**
154
+ * Make `branch` the host's default branch (official CLIs: `gh repo edit --default-branch`,
155
+ * cli.github.com/manual/gh_repo_edit; GitLab project API `default_branch` via `glab api`,
156
+ * docs.gitlab.com/api/projects/#edit-a-project; `:id` placeholder: docs.gitlab.com/cli/api/).
157
+ * Returns false (caller prints the manual step) when the CLI call fails.
158
+ */
159
+ export async function setRemoteDefaultBranch(cwd, host, branch) {
160
+ try {
161
+ if (host === "gitlab") {
162
+ await execFileAsync("glab", ["api", "--method", "PUT", "projects/:id", "--raw-field", `default_branch=${branch}`], { cwd, maxBuffer: 1024 * 1024 });
163
+ }
164
+ else {
165
+ await execFileAsync("gh", ["repo", "edit", "--default-branch", branch], {
166
+ cwd,
167
+ maxBuffer: 1024 * 1024,
168
+ });
169
+ }
170
+ return true;
171
+ }
172
+ catch {
173
+ return false;
174
+ }
152
175
  }
153
176
  /**
154
177
  * Ensures `origin` (or named remote) exists.
@@ -171,6 +171,17 @@ export async function workflowExpectedForPullRequest(yamlText, ctx) {
171
171
  return { expected: true };
172
172
  }
173
173
  const RUN_PASS = new Set(["success", "neutral", "skipped"]);
174
+ /**
175
+ * GitHub-generated runs that are not the commit's CI: "dynamic workflows are generated for a
176
+ * specific run and are not stored in your repository's `.github/workflows` directory" — e.g.
177
+ * the jobs that generate Dependabot pull requests, `dynamic/dependabot/dependabot-updates`
178
+ * (docs.github.com/code-security/dependabot/working-with-dependabot/about-dependabot-on-github-actions-runners).
179
+ * Their result says nothing about the head commit, and a failed one cannot be re-run, so the
180
+ * gate ignores them (required checks named in `.vibeops.json` are still judged by name).
181
+ */
182
+ export function isDynamicRun(run) {
183
+ return run.event === "dynamic" || run.path.startsWith("dynamic/");
184
+ }
174
185
  function runPath(run) {
175
186
  return run.path.split("@")[0];
176
187
  }
@@ -180,11 +191,13 @@ function describeRun(run, detail) {
180
191
  /**
181
192
  * Latest run per (workflow, event) must be completed and passing; every
182
193
  * expected workflow must have a `pull_request` run. Unknown statuses /
183
- * conclusions fail closed.
194
+ * conclusions fail closed. Dynamic runs ({@link isDynamicRun}) are ignored.
184
195
  */
185
196
  export function evaluateActionsRuns(runs, expectedPaths) {
186
197
  const latest = new Map();
187
198
  for (const run of runs) {
199
+ if (isDynamicRun(run))
200
+ continue;
188
201
  const key = `${runPath(run)}\u0000${run.event}`;
189
202
  const prev = latest.get(key);
190
203
  if (prev === undefined || run.id > prev.id)
@@ -1,4 +1,10 @@
1
1
  import { gitIsAncestor, runGit } from "./git.js";
2
+ /** Fetch `<remote>/<baseBranch>` into its remote-tracking ref and return that ref. Throws on failure. */
3
+ export async function fetchBaseBranch(cwd, remote, baseBranch) {
4
+ const baseRef = `refs/remotes/${remote}/${baseBranch}`;
5
+ await runGit(cwd, ["fetch", "--no-tags", remote, `+refs/heads/${baseBranch}:${baseRef}`]);
6
+ return baseRef;
7
+ }
2
8
  /**
3
9
  * The MR/PR head must contain the current tip of its base branch.
4
10
  *
@@ -7,12 +13,6 @@ import { gitIsAncestor, runGit } from "./git.js";
7
13
  * into the base since; a squash merge then lands that revert. Fetches the base
8
14
  * branch and the head commit first; any fetch / read failure fails closed.
9
15
  */
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
- }
16
16
  export async function checkBaseInHead(opts) {
17
17
  const { cwd, remote, baseBranch, headSha } = opts;
18
18
  const baseRef = `refs/remotes/${remote}/${baseBranch}`;
@@ -1,20 +1,32 @@
1
1
  import { join } from "node:path";
2
2
  import { readTextOrNull } from "./filesystem.js";
3
3
  import { VIBEOPS_CONFIG_FILE } from "./paths.js";
4
+ /**
5
+ * Job execution limits of GitHub Actions (docs.github.com/actions/reference/limits):
6
+ * "up to 6 hours" per job on GitHub-hosted runners, "up to 5 days" on self-hosted ones.
7
+ * A pending check cannot legitimately outlast its job, so these bound the wait.
8
+ */
9
+ const GITHUB_HOSTED_JOB_LIMIT_SECONDS = 6 * 60 * 60;
10
+ const SELF_HOSTED_JOB_LIMIT_SECONDS = 5 * 24 * 60 * 60;
4
11
  export const MERGE_GATE_DEFAULTS = {
5
12
  requiredChecks: [],
6
13
  releaseRequiredChecks: [],
7
- waitTimeoutSeconds: 900,
14
+ // The wait ends as soon as the host reports every check finished; this only bounds a
15
+ // check that never finishes. Self-hosted runners: raise it up to the 5-day job limit.
16
+ waitTimeoutSeconds: GITHUB_HOSTED_JOB_LIMIT_SECONDS,
8
17
  // Same default as `gh pr checks --watch --interval` (cli.github.com/manual/gh_pr_checks).
9
18
  pollIntervalSeconds: 10,
10
- emptyRollupGraceSeconds: 30,
19
+ // No grace by default: CI outside GitHub Actions is awaited by name via `requiredChecks`
20
+ // (like required status checks in branch protection), not by guessing how long it takes
21
+ // to report.
22
+ emptyRollupGraceSeconds: 0,
11
23
  allowNoChecks: false,
12
24
  };
13
- /** Inclusive bounds for the numeric settings (seconds). */
25
+ /** Inclusive bounds for the numeric settings (seconds); cross-checks below keep them ≤ the wait. */
14
26
  export const MERGE_GATE_LIMITS = {
15
- waitTimeoutSeconds: { min: 1, max: 7_200 },
16
- pollIntervalSeconds: { min: 1, max: 300 },
17
- emptyRollupGraceSeconds: { min: 0, max: 600 },
27
+ waitTimeoutSeconds: { min: 1, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
28
+ pollIntervalSeconds: { min: 1, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
29
+ emptyRollupGraceSeconds: { min: 0, max: SELF_HOSTED_JOB_LIMIT_SECONDS },
18
30
  };
19
31
  const KNOWN_KEYS = new Set([
20
32
  "requiredChecks",
@@ -91,6 +103,9 @@ export function parseMergeConfig(raw) {
91
103
  }
92
104
  cfg.allowNoChecks = o.allowNoChecks;
93
105
  }
106
+ if (cfg.emptyRollupGraceSeconds > cfg.waitTimeoutSeconds) {
107
+ throw new MergeConfigError("`emptyRollupGraceSeconds` must not exceed `waitTimeoutSeconds`.");
108
+ }
94
109
  if (cfg.pollIntervalSeconds > cfg.waitTimeoutSeconds) {
95
110
  throw new MergeConfigError("`pollIntervalSeconds` must not exceed `waitTimeoutSeconds`.");
96
111
  }
@@ -156,7 +156,9 @@ export async function refreshCodexOAuthTokens(refreshToken) {
156
156
  }
157
157
  /**
158
158
  * Returns a usable access token, refreshing and persisting to auth.json when
159
- * the JWT is near expiry (same pattern as Hermes `resolve_codex_runtime_credentials`).
159
+ * the JWT is near expiry. `skewSeconds` is clock-skew leeway on the `exp` claim:
160
+ * RFC 7519 §4.1.4 allows "some small leeway, usually no more than a few minutes, to
161
+ * account for clock skew" (rfc-editor.org/rfc/rfc7519#section-4.1.4); 120 s stays within it.
160
162
  */
161
163
  export async function resolveCodexOAuthAccessToken(skewSeconds = 120) {
162
164
  const loaded = await readAuthPayload();
@@ -1,9 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { mergeRequestLabel } from "./git-host.js";
4
- import { classifyRollup, evaluateCheckGate, formatCheckGateProblems, } from "./check-rollup.js";
4
+ import { classifyRollup, evaluateCheckGate, withoutDynamicChecks, 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
+ import { evaluateActionsRuns, isDynamicRun, listExpectedPullRequestWorkflows, listWorkflowRunsForSha, parseGitHubPullUrl, } from "./github-actions-gate.js";
7
7
  import { dim, log } from "./logger.js";
8
8
  import { checkBaseInHead } from "./merge-base-freshness.js";
9
9
  import { MERGE_GATE_DEFAULTS, mergeGateTiming } from "./merge-config.js";
@@ -292,7 +292,10 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
292
292
  throw new MergeRequestShapeError("gh", problems);
293
293
  }
294
294
  const checks = classifyRollup(parsed.statusCheckRollup);
295
- const gate = evaluateCheckGate(checks);
295
+ // Dynamic workflows' check runs are not the commit's CI (see `withoutDynamicChecks`);
296
+ // required ones are still judged by name in the merge gate.
297
+ const ciChecks = withoutDynamicChecks(checks);
298
+ const gate = evaluateCheckGate(ciChecks);
296
299
  let pipelineStatus = "none";
297
300
  if (gate.state === "red") {
298
301
  pipelineStatus = "failed";
@@ -300,7 +303,7 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
300
303
  else if (gate.state === "pending") {
301
304
  pipelineStatus = "running";
302
305
  }
303
- else if (checks.length > 0) {
306
+ else if (ciChecks.length > 0) {
304
307
  pipelineStatus = "success";
305
308
  }
306
309
  const mergeable = parsed.mergeable?.trim().toUpperCase() ?? "";
@@ -318,20 +321,6 @@ export async function readMergeRequestDetails(cwd, host, url, options) {
318
321
  baseBranch: parsed.baseRefName ?? null,
319
322
  };
320
323
  }
321
- /** Poll host until MR/PR state is merged (or timeout). */
322
- export async function waitForMergeRequestMerged(cwd, host, url, options = {}) {
323
- const timeoutMs = options.timeoutMs ?? 120_000;
324
- const intervalMs = options.intervalMs ?? 2_000;
325
- const deadline = Date.now() + timeoutMs;
326
- while (Date.now() < deadline) {
327
- const details = await getMergeRequestDetails(cwd, host, url);
328
- if (details?.state === "merged" && details.mergedAt !== null) {
329
- return true;
330
- }
331
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
332
- }
333
- return false;
334
- }
335
324
  function mergeRequestReadinessFromDetails(details) {
336
325
  if (details === null)
337
326
  return null;
@@ -354,11 +343,12 @@ export class MergeGateError extends Error {
354
343
  }
355
344
  function evaluateDetailsGate(details, requiredChecks) {
356
345
  if (details.checks !== null) {
357
- const gate = evaluateCheckGate(details.checks, requiredChecks);
346
+ const checks = withoutDynamicChecks(details.checks, requiredChecks);
347
+ const gate = evaluateCheckGate(checks, requiredChecks);
358
348
  return {
359
349
  state: gate.state,
360
350
  problems: formatCheckGateProblems(gate),
361
- noChecks: details.checks.length === 0,
351
+ noChecks: checks.length === 0,
362
352
  };
363
353
  }
364
354
  const state = pipelineGateState(details.pipelineStatus);
@@ -397,7 +387,7 @@ async function evaluateGitHubActions(cwd, url, details, expectedCache) {
397
387
  expected = await listExpectedPullRequestWorkflows(cwd, pr, sha, details.baseBranch);
398
388
  expectedCache.set(sha, expected);
399
389
  }
400
- const runs = await listWorkflowRunsForSha(cwd, pr, sha);
390
+ const runs = (await listWorkflowRunsForSha(cwd, pr, sha)).filter((r) => !isDynamicRun(r));
401
391
  return {
402
392
  result: evaluateActionsRuns(runs, expected.map((w) => w.path)),
403
393
  runCount: runs.length,
@@ -1,22 +1,21 @@
1
1
  import { mergeRequestLabel } from "./git-host.js";
2
- import { getMergeRequestDetails, waitForMergeRequestMerged, } from "./pr-create.js";
2
+ import { getMergeRequestDetails } from "./pr-create.js";
3
+ /**
4
+ * Read the MR/PR once after `gh pr merge` / `glab mr merge` returned. Both merge APIs
5
+ * answer synchronously — GitHub `200` with `merged: true`
6
+ * (docs.github.com/rest/pulls/pulls#merge-a-pull-request), GitLab the merge request with
7
+ * `state: "merged"` (docs.gitlab.com/api/merge_requests/#merge-a-merge-request; VibeOps
8
+ * merges with `--auto-merge=false`) — so there is nothing to poll for. Not merged here
9
+ * means the host queued it (merge queue / auto-merge): report, do not wait.
10
+ */
3
11
  export async function assertMergeRequestMerged(input) {
4
12
  const label = mergeRequestLabel(input.host);
5
- if (input.wait !== false) {
6
- const merged = await waitForMergeRequestMerged(input.cwd, input.host, input.url);
7
- if (!merged) {
8
- return {
9
- ok: false,
10
- message: `${label} merge did not complete (still open or auto-merge pending). Wait for Merged on the host, or rerun \`vibeops task merge\`.`,
11
- };
12
- }
13
- }
14
13
  const details = await getMergeRequestDetails(input.cwd, input.host, input.url);
15
14
  if (details?.state !== "merged" || details.mergedAt === null) {
16
15
  const state = details?.state ?? "unknown";
17
16
  return {
18
17
  ok: false,
19
- message: `${label} is ${state}, not merged into ${input.integrationBranch}. Do not run task sync until the host shows Merged.`,
18
+ message: `${label} is ${state}, not merged into ${input.integrationBranch} (merge queue or auto-merge pending?). Wait for Merged on the host before task sync, or rerun \`vibeops task merge\`.`,
20
19
  };
21
20
  }
22
21
  return { ok: true };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodtek/vibeops",
3
- "version": "3.1.7",
3
+ "version": "3.1.9",
4
4
  "description": "Minimal CLI rails for Cursor-based TASK-driven vibe coding.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.15.9",
@@ -62,7 +62,7 @@
62
62
  "devDependencies": {
63
63
  "@eslint/js": "10.0.1",
64
64
  "@types/node": "^20.14.0",
65
- "eslint": "10.11.0",
65
+ "eslint": "10.12.0",
66
66
  "tsx": "^4.23.15",
67
67
  "typescript": "^5.5.4",
68
68
  "typescript-eslint": "8.71.0"