@imdeadpool/guardex 7.0.43 → 7.1.0
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/README.md +26 -0
- package/package.json +2 -1
- package/skills/gx-act/SKILL.md +82 -0
- package/src/agents/inspect.js +17 -4
- package/src/agents/launch.js +10 -1
- package/src/agents/status.js +9 -6
- package/src/budget/index.js +2 -1
- package/src/cli/args.js +52 -2
- package/src/cli/commands/agents.js +364 -0
- package/src/cli/commands/bootstrap.js +92 -0
- package/src/cli/commands/branch.js +127 -0
- package/src/cli/commands/claude.js +674 -0
- package/src/cli/commands/doctor.js +268 -0
- package/src/cli/commands/finish.js +26 -0
- package/src/cli/commands/mcp.js +122 -0
- package/src/cli/commands/misc.js +304 -0
- package/src/cli/commands/pr.js +439 -0
- package/src/cli/commands/prompt.js +92 -0
- package/src/cli/commands/release.js +305 -0
- package/src/cli/commands/report.js +244 -0
- package/src/cli/commands/review.js +32 -0
- package/src/cli/commands/setup.js +242 -0
- package/src/cli/commands/status.js +338 -0
- package/src/cli/commands/watch.js +234 -0
- package/src/cli/main.js +68 -3726
- package/src/cli/shared/repo-env.js +161 -0
- package/src/cli/shared/sandbox.js +417 -0
- package/src/cli/shared/scaffolding.js +535 -0
- package/src/cli/shared/toolchain-shims.js +420 -0
- package/src/context.js +229 -11
- package/src/core/runtime.js +6 -1
- package/src/doctor/index.js +42 -13
- package/src/finish/index.js +147 -5
- package/src/finish/preflight.js +177 -0
- package/src/finish/review-gate.js +182 -0
- package/src/git/index.js +446 -4
- package/src/hooks/index.js +0 -64
- package/src/mcp/collect.js +370 -0
- package/src/mcp/server.js +157 -0
- package/src/output/index.js +67 -1
- package/src/pr-review.js +23 -0
- package/src/pr.js +381 -0
- package/src/sandbox/index.js +13 -2
- package/src/scaffold/agent-worktree-prep.js +213 -0
- package/src/scaffold/index.js +108 -10
- package/src/speckit/index.js +226 -0
- package/src/terminal/index.js +1 -76
- package/src/terminal/tmux.js +0 -1
- package/src/toolchain/index.js +20 -0
- package/templates/AGENTS.monorepo-apps.md +26 -0
- package/templates/AGENTS.multiagent-safety.md +61 -347
- package/templates/AGENTS.multiagent-safety.min.md +11 -0
- package/templates/codex/skills/gx-act/SKILL.md +82 -0
- package/templates/githooks/pre-commit +22 -19
- package/templates/scripts/agent-branch-finish.sh +8 -30
- package/templates/scripts/agent-branch-merge.sh +4 -1
- package/templates/scripts/agent-branch-start.sh +88 -3
- package/templates/scripts/agent-preflight.sh +31 -5
- package/templates/scripts/agent-worktree-prune.sh +1 -1
- package/templates/scripts/codex-agent.sh +0 -91
- package/src/agents/detect.js +0 -160
- package/src/cockpit/keybindings.js +0 -224
- package/src/cockpit/layout.js +0 -224
package/src/finish/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
const { TOOL_NAME, LOCK_FILE_RELATIVE, path, fs } = require('../context');
|
|
3
|
+
const { isTerseMode } = require('../output');
|
|
2
4
|
const { run, runPackageAsset } = require('../core/runtime');
|
|
3
5
|
const {
|
|
4
6
|
resolveRepoRoot,
|
|
@@ -27,7 +29,51 @@ const {
|
|
|
27
29
|
parseSyncArgs,
|
|
28
30
|
} = require('../cli/args');
|
|
29
31
|
const submoduleModule = require('../submodule');
|
|
30
|
-
|
|
32
|
+
const { runPreflight, summarizePreflight } = require('./preflight');
|
|
33
|
+
const { runReviewGate } = require('./review-gate');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options recognized by {@link autoCommitWorktreeForFinish} and the public
|
|
37
|
+
* {@link finish} entry. Mirrors the relevant subset of `parseFinishArgs`'s
|
|
38
|
+
* output.
|
|
39
|
+
*
|
|
40
|
+
* @typedef {Object} FinishOptions
|
|
41
|
+
* @property {boolean} [noAutoCommit] When true, refuse to auto-commit dirty worktrees.
|
|
42
|
+
* @property {boolean} [dryRun] When true, surface intended actions without performing them.
|
|
43
|
+
* @property {string} [commitMessage] Override for the auto-commit message.
|
|
44
|
+
* @property {boolean} [advanceSubmodules] Run `submoduleModule.advance` against the worktree before finish.
|
|
45
|
+
* @property {boolean} [waitForMerge] Forward `--wait-for-merge` to `agent-branch-finish`.
|
|
46
|
+
* @property {boolean} [cleanup] Forward `--cleanup` (vs `--no-cleanup`) to `agent-branch-finish`.
|
|
47
|
+
* @property {'pr'|'direct'|'auto'} [mergeMode] Merge selection forwarded to `agent-branch-finish`.
|
|
48
|
+
* @property {boolean} [keepRemote] Forward `--keep-remote-branch` to `agent-branch-finish`.
|
|
49
|
+
* @property {boolean} [parentGitlinkCommit] Toggle for `--parent-gitlink-commit`.
|
|
50
|
+
* @property {boolean} [failFast] Stop the loop after the first failing branch.
|
|
51
|
+
* @property {boolean} [all] Include already-merged branches in the candidate list.
|
|
52
|
+
* @property {string} [branch] Single branch to finish (skips discovery).
|
|
53
|
+
* @property {string} [base] Explicit base branch override.
|
|
54
|
+
* @property {string} [target] Path inside the target repo (defaults to cwd).
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Outcome of an auto-commit attempt for a single branch.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {Object} AutoCommitResult
|
|
61
|
+
* @property {boolean} changed True when the worktree had local changes.
|
|
62
|
+
* @property {boolean} committed True only when a commit was created.
|
|
63
|
+
* @property {boolean} [dryRun] True when `--dry-run` short-circuited the commit.
|
|
64
|
+
* @property {string} [message] Commit message used (when `committed` is true).
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Claim agent file locks for every file the worktree is about to commit,
|
|
69
|
+
* including pending deletions. No-op when there is nothing to claim.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} repoRoot Repo root for the lock tool to operate on.
|
|
72
|
+
* @param {string} worktreePath Worktree whose changes are being committed.
|
|
73
|
+
* @param {string} branch Agent branch that should own the new claims.
|
|
74
|
+
* @returns {void}
|
|
75
|
+
* @throws {Error} When the lock-claim or allow-delete subprocess exits non-zero.
|
|
76
|
+
*/
|
|
31
77
|
function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
|
|
32
78
|
const changedFiles = uniquePreserveOrder([
|
|
33
79
|
...gitOutputLines(worktreePath, ['diff', '--name-only', '--', '.', ':(exclude).omx/state/agent-file-locks.json']),
|
|
@@ -84,6 +130,17 @@ function claimLocksForAutoCommit(repoRoot, worktreePath, branch) {
|
|
|
84
130
|
}
|
|
85
131
|
}
|
|
86
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Stage and commit pending work in `worktreePath` (if any) before the
|
|
135
|
+
* finish flow takes over. Honors `--no-auto-commit` and `--dry-run`.
|
|
136
|
+
*
|
|
137
|
+
* @param {string} repoRoot Repo root for lock-tool dispatch.
|
|
138
|
+
* @param {string} worktreePath Worktree to commit in.
|
|
139
|
+
* @param {string} branch Agent branch the worktree is checked out on.
|
|
140
|
+
* @param {FinishOptions} options Finish options (only auto-commit fields are read).
|
|
141
|
+
* @returns {AutoCommitResult} What happened (or would happen, in dry-run).
|
|
142
|
+
* @throws {Error} When `--no-auto-commit` is set with dirty state, or when git add/commit fails.
|
|
143
|
+
*/
|
|
87
144
|
function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
|
|
88
145
|
const hasChanges = worktreeHasLocalChanges(worktreePath);
|
|
89
146
|
if (!hasChanges) {
|
|
@@ -134,6 +191,15 @@ function autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options) {
|
|
|
134
191
|
return { changed: true, committed: true, message: commitMessage };
|
|
135
192
|
}
|
|
136
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Run the worktree-prune sweep with options parsed from `rawArgs`. In watch
|
|
196
|
+
* mode loops forever (or until `--once`), printing the cycle header and
|
|
197
|
+
* delegating each cycle to the `worktreePrune` package asset.
|
|
198
|
+
*
|
|
199
|
+
* @param {ReadonlyArray<string>} rawArgs CLI argv slice for the cleanup command.
|
|
200
|
+
* @returns {void}
|
|
201
|
+
* @throws {Error} When the underlying prune subprocess exits non-zero or the watch sleep fails.
|
|
202
|
+
*/
|
|
137
203
|
function cleanup(rawArgs) {
|
|
138
204
|
const activeCwd = process.cwd();
|
|
139
205
|
const options = parseCleanupArgs(rawArgs);
|
|
@@ -204,6 +270,14 @@ function cleanup(rawArgs) {
|
|
|
204
270
|
process.exitCode = 0;
|
|
205
271
|
}
|
|
206
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Dispatch to the `branchMerge` package asset with options parsed from
|
|
275
|
+
* `rawArgs`. Pipes stdout/stderr through to the parent process.
|
|
276
|
+
*
|
|
277
|
+
* @param {ReadonlyArray<string>} rawArgs CLI argv slice for the merge command.
|
|
278
|
+
* @returns {void}
|
|
279
|
+
* @throws {Error} When the merge subprocess exits non-zero.
|
|
280
|
+
*/
|
|
207
281
|
function merge(rawArgs) {
|
|
208
282
|
const options = parseMergeArgs(rawArgs);
|
|
209
283
|
const repoRoot = resolveRepoRoot(options.target);
|
|
@@ -239,6 +313,17 @@ function merge(rawArgs) {
|
|
|
239
313
|
process.exitCode = 0;
|
|
240
314
|
}
|
|
241
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Drive the finish flow across one or many agent branches: discover
|
|
318
|
+
* candidates, auto-commit dirty worktrees, optionally advance submodules,
|
|
319
|
+
* and invoke `agent-branch-finish` for each branch. Aggregates per-branch
|
|
320
|
+
* outcomes and prints a single summary line at the end.
|
|
321
|
+
*
|
|
322
|
+
* @param {ReadonlyArray<string>} rawArgs CLI argv slice for the finish command.
|
|
323
|
+
* @param {Partial<FinishOptions>} [defaults] Defaults merged before CLI overrides.
|
|
324
|
+
* @returns {void}
|
|
325
|
+
* @throws {Error} When `--branch` references an unknown ref, or when any branch fails to finish (after the loop completes).
|
|
326
|
+
*/
|
|
242
327
|
function finish(rawArgs, defaults = {}) {
|
|
243
328
|
const activeCwd = process.cwd();
|
|
244
329
|
const options = parseFinishArgs(rawArgs, defaults);
|
|
@@ -286,12 +371,19 @@ function finish(rawArgs, defaults = {}) {
|
|
|
286
371
|
let succeeded = 0;
|
|
287
372
|
let failed = 0;
|
|
288
373
|
let autoCommitted = 0;
|
|
374
|
+
const terse = isTerseMode();
|
|
289
375
|
|
|
290
376
|
for (const candidate of candidates) {
|
|
291
377
|
const { branch, baseBranch, worktreePath } = candidate;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
378
|
+
// In terse mode, defer the "Finishing X -> Y" line until we know whether
|
|
379
|
+
// we also need to announce an auto-commit, then emit a single combined
|
|
380
|
+
// line per branch. Keep branch + base + worktree path so agents still see
|
|
381
|
+
// the load-bearing literals.
|
|
382
|
+
if (!terse) {
|
|
383
|
+
console.log(
|
|
384
|
+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}...`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
295
387
|
|
|
296
388
|
try {
|
|
297
389
|
let commitState = { changed: false, committed: false };
|
|
@@ -299,7 +391,17 @@ function finish(rawArgs, defaults = {}) {
|
|
|
299
391
|
commitState = autoCommitWorktreeForFinish(repoRoot, worktreePath, branch, options);
|
|
300
392
|
}
|
|
301
393
|
|
|
302
|
-
if (
|
|
394
|
+
if (terse) {
|
|
395
|
+
const suffix = commitState.committed
|
|
396
|
+
? ' [auto-committed]'
|
|
397
|
+
: (commitState.changed && commitState.dryRun ? ' [dry-run: would auto-commit]' : '');
|
|
398
|
+
console.log(
|
|
399
|
+
`[${TOOL_NAME}] Finishing '${branch}' -> '${baseBranch}'${worktreePath ? ` (${worktreePath})` : ''}${suffix}`,
|
|
400
|
+
);
|
|
401
|
+
if (commitState.committed) {
|
|
402
|
+
autoCommitted += 1;
|
|
403
|
+
}
|
|
404
|
+
} else if (commitState.committed) {
|
|
303
405
|
autoCommitted += 1;
|
|
304
406
|
console.log(`[${TOOL_NAME}] Auto-committed '${branch}' before finish.`);
|
|
305
407
|
} else if (commitState.changed && commitState.dryRun) {
|
|
@@ -358,6 +460,35 @@ function finish(rawArgs, defaults = {}) {
|
|
|
358
460
|
continue;
|
|
359
461
|
}
|
|
360
462
|
|
|
463
|
+
// Preflight: typecheck + lint touched workspace packages before opening
|
|
464
|
+
// a PR. Only enforced for PR-mode finishes; bypass with --skip-preflight.
|
|
465
|
+
if (options.mergeMode === 'pr' && !options.skipPreflight) {
|
|
466
|
+
const preflight = runPreflight(repoRoot, worktreePath, branch, baseBranch, {
|
|
467
|
+
verbose: !terse,
|
|
468
|
+
});
|
|
469
|
+
console.log(`[${TOOL_NAME}] ${summarizePreflight(preflight)}`);
|
|
470
|
+
if (preflight.status === 'failed') {
|
|
471
|
+
for (const f of preflight.failures) {
|
|
472
|
+
console.error(`[${TOOL_NAME}] preflight failure: ${f.label} (exit ${f.status})`);
|
|
473
|
+
if (f.stderr && f.stderr.trim()) {
|
|
474
|
+
console.error(f.stderr.trim());
|
|
475
|
+
} else if (f.stdout && f.stdout.trim()) {
|
|
476
|
+
console.error(f.stdout.trim());
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
throw new Error(
|
|
480
|
+
`preflight failed for ${preflight.failures.length} script(s). Fix the failures or rerun with --skip-preflight to bypass.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Opt-in merge gate (--gate-review / gx ship): enforce a clean AI review +
|
|
486
|
+
// green CI + GitHub-mergeable verdict BEFORE the shell merge runs. Throws on
|
|
487
|
+
// failure, which the catch below turns into a finish failure (no merge).
|
|
488
|
+
if (options.mergeMode === 'pr' && options.gateReview) {
|
|
489
|
+
runReviewGate({ repoRoot, branch, baseBranch, options });
|
|
490
|
+
}
|
|
491
|
+
|
|
361
492
|
const finishResult = runPackageAsset('branchFinish', finishArgs, {
|
|
362
493
|
cwd: repoRoot,
|
|
363
494
|
stdio: 'pipe',
|
|
@@ -394,6 +525,17 @@ function finish(rawArgs, defaults = {}) {
|
|
|
394
525
|
process.exitCode = 0;
|
|
395
526
|
}
|
|
396
527
|
|
|
528
|
+
/**
|
|
529
|
+
* Sync the current (or all) agent branches against the configured base
|
|
530
|
+
* branch using either rebase or merge. Supports `--check`, `--dry-run`,
|
|
531
|
+
* `--json`, and `--all-agent-branches` modes. Temporarily resets the lock
|
|
532
|
+
* registry around the sync operation when it is dirty so it does not block
|
|
533
|
+
* the rebase/merge, then restores the saved contents.
|
|
534
|
+
*
|
|
535
|
+
* @param {ReadonlyArray<string>} rawArgs CLI argv slice for the sync command.
|
|
536
|
+
* @returns {void}
|
|
537
|
+
* @throws {Error} When the working tree is dirty (without `--allow-dirty`), the lock reset fails, or the underlying rebase/merge fails.
|
|
538
|
+
*/
|
|
397
539
|
function sync(rawArgs) {
|
|
398
540
|
const options = parseSyncArgs(rawArgs);
|
|
399
541
|
const repoRoot = resolveRepoRoot(options.target);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pre-ship preflight: before `gx finish --via-pr` opens a PR, walk the diff
|
|
4
|
+
// against the base branch, find which `apps/<pkg>/` workspace packages were
|
|
5
|
+
// touched, and run their `typecheck` + `lint` scripts. If any fail, abort the
|
|
6
|
+
// PR creation — keeps main green so the user's root-worktree dev server (the
|
|
7
|
+
// one they're "visualizing" against) never breaks.
|
|
8
|
+
//
|
|
9
|
+
// Bypass with `--skip-preflight`. Non-monorepo repos (no `apps/<pkg>/package.json`)
|
|
10
|
+
// are silently no-ops.
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawnSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const PREFLIGHT_SCRIPTS = ['typecheck', 'lint'];
|
|
17
|
+
|
|
18
|
+
function readJson(file) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function detectAppPackages(repoRoot) {
|
|
27
|
+
const appsRoot = path.join(repoRoot, 'apps');
|
|
28
|
+
let stat;
|
|
29
|
+
try {
|
|
30
|
+
stat = fs.statSync(appsRoot);
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!stat.isDirectory()) return [];
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(appsRoot, { withFileTypes: true });
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const packages = [];
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
if (!e.isDirectory()) continue;
|
|
44
|
+
const pkgPath = path.join(appsRoot, e.name, 'package.json');
|
|
45
|
+
const pkg = readJson(pkgPath);
|
|
46
|
+
if (!pkg) continue;
|
|
47
|
+
packages.push({
|
|
48
|
+
dir: `apps/${e.name}`,
|
|
49
|
+
name: pkg.name || e.name,
|
|
50
|
+
scripts: pkg.scripts || {},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return packages;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function detectTouchedDirs(workingDir, baseBranch, branch) {
|
|
57
|
+
const ref = baseBranch
|
|
58
|
+
? `${baseBranch}...${branch || 'HEAD'}`
|
|
59
|
+
: (branch || 'HEAD');
|
|
60
|
+
const diff = spawnSync('git', ['-C', workingDir, 'diff', '--name-only', ref], {
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
timeout: 15_000,
|
|
63
|
+
});
|
|
64
|
+
if (diff.status !== 0) {
|
|
65
|
+
// Try a fallback range with the local base.
|
|
66
|
+
const fallback = spawnSync(
|
|
67
|
+
'git',
|
|
68
|
+
['-C', workingDir, 'diff', '--name-only', 'HEAD'],
|
|
69
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 15_000 },
|
|
70
|
+
);
|
|
71
|
+
if (fallback.status !== 0) return null;
|
|
72
|
+
return (fallback.stdout || '').toString().split('\n').filter(Boolean);
|
|
73
|
+
}
|
|
74
|
+
return (diff.stdout || '').toString().split('\n').filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pickPackageManager(repoRoot) {
|
|
78
|
+
// Prefer pnpm if a lockfile exists; fall back to npm.
|
|
79
|
+
if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
80
|
+
if (fs.existsSync(path.join(repoRoot, 'yarn.lock'))) return 'yarn';
|
|
81
|
+
return 'npm';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildScriptInvocation(pm, pkgName, script) {
|
|
85
|
+
if (pm === 'pnpm') return { cmd: 'pnpm', args: ['--filter', pkgName, script] };
|
|
86
|
+
if (pm === 'yarn') return { cmd: 'yarn', args: ['workspace', pkgName, 'run', script] };
|
|
87
|
+
return { cmd: 'npm', args: ['--workspace', pkgName, 'run', script] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function runPreflight(repoRoot, worktreePath, branch, baseBranch, options = {}) {
|
|
91
|
+
const workingDir = worktreePath || repoRoot;
|
|
92
|
+
const packages = detectAppPackages(repoRoot);
|
|
93
|
+
if (packages.length === 0) {
|
|
94
|
+
return { status: 'skipped', reason: 'no-monorepo', failures: [], ran: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const touchedFiles = detectTouchedDirs(workingDir, baseBranch, branch);
|
|
98
|
+
if (touchedFiles === null) {
|
|
99
|
+
return {
|
|
100
|
+
status: 'skipped',
|
|
101
|
+
reason: 'diff-unavailable',
|
|
102
|
+
failures: [],
|
|
103
|
+
ran: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const touchedPkgs = packages.filter((pkg) =>
|
|
108
|
+
touchedFiles.some((file) => file.startsWith(pkg.dir + '/')),
|
|
109
|
+
);
|
|
110
|
+
if (touchedPkgs.length === 0) {
|
|
111
|
+
return {
|
|
112
|
+
status: 'skipped',
|
|
113
|
+
reason: 'no-app-changes',
|
|
114
|
+
failures: [],
|
|
115
|
+
ran: [],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const pm = pickPackageManager(repoRoot);
|
|
120
|
+
const ran = [];
|
|
121
|
+
const failures = [];
|
|
122
|
+
for (const pkg of touchedPkgs) {
|
|
123
|
+
for (const script of PREFLIGHT_SCRIPTS) {
|
|
124
|
+
if (!pkg.scripts[script]) continue;
|
|
125
|
+
const { cmd, args } = buildScriptInvocation(pm, pkg.name, script);
|
|
126
|
+
const label = `${pkg.name}:${script}`;
|
|
127
|
+
if (options.verbose) {
|
|
128
|
+
process.stdout.write(`[preflight] running ${label}…\n`);
|
|
129
|
+
}
|
|
130
|
+
const result = spawnSync(cmd, args, {
|
|
131
|
+
cwd: repoRoot,
|
|
132
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
timeout: 5 * 60_000,
|
|
134
|
+
});
|
|
135
|
+
const stdout = (result.stdout || '').toString();
|
|
136
|
+
const stderr = (result.stderr || '').toString();
|
|
137
|
+
const ok = !result.error && result.status === 0;
|
|
138
|
+
ran.push({ label, ok, status: result.status, cmd, args });
|
|
139
|
+
if (!ok) {
|
|
140
|
+
failures.push({
|
|
141
|
+
label,
|
|
142
|
+
status: result.status,
|
|
143
|
+
stdout: stdout.slice(-2000),
|
|
144
|
+
stderr: stderr.slice(-2000),
|
|
145
|
+
error: result.error ? result.error.message : null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
status: failures.length === 0 ? 'ok' : 'failed',
|
|
153
|
+
reason: failures.length === 0 ? 'all-passed' : 'script-failures',
|
|
154
|
+
packageManager: pm,
|
|
155
|
+
touched: touchedPkgs.map((p) => p.name),
|
|
156
|
+
ran,
|
|
157
|
+
failures,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function summarizePreflight(result) {
|
|
162
|
+
if (result.status === 'skipped') {
|
|
163
|
+
return `[preflight] skipped (${result.reason})`;
|
|
164
|
+
}
|
|
165
|
+
const tail = result.ran
|
|
166
|
+
.map((r) => `${r.ok ? '✓' : '✗'} ${r.label}`)
|
|
167
|
+
.join(', ');
|
|
168
|
+
return `[preflight] ${result.status} — ${tail}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
detectAppPackages,
|
|
173
|
+
detectTouchedDirs,
|
|
174
|
+
pickPackageManager,
|
|
175
|
+
runPreflight,
|
|
176
|
+
summarizePreflight,
|
|
177
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Opt-in merge gate for `gx branch finish --gate-review` / `gx ship`.
|
|
2
|
+
//
|
|
3
|
+
// `gx branch finish` trusts server-side branch protection for the actual merge,
|
|
4
|
+
// which can fail open (it merged PR #610 to main with red preflight tests). When
|
|
5
|
+
// `--gate-review` is set, this module enforces a REAL local gate BEFORE the merge
|
|
6
|
+
// runs: a clean AI review (fail-closed) AND green CI AND GitHub reporting the PR
|
|
7
|
+
// mergeable under branch protection. It throws to block; the finish() catch then
|
|
8
|
+
// skips the merge for that branch. Synchronous, to match finish().
|
|
9
|
+
|
|
10
|
+
const { run } = require('../core/runtime');
|
|
11
|
+
const { TOOL_NAME } = require('../context');
|
|
12
|
+
const pr = require('../pr');
|
|
13
|
+
const prReview = require('../pr-review');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_GATE_TIMEOUT_SECONDS = 1800; // 30 min — CI can be slow.
|
|
16
|
+
const DEFAULT_GATE_POLL_SECONDS = 15;
|
|
17
|
+
const DEFAULT_NO_CHECKS_GRACE_SECONDS = 60; // let CI register check runs after promote.
|
|
18
|
+
// GitHub mergeStateStatus values that mean "mergeable under current protection".
|
|
19
|
+
const MERGEABLE_STATES = new Set(['CLEAN', 'HAS_HOOKS']);
|
|
20
|
+
// mergeStateStatus values that mean "GitHub will not allow this merge as-is".
|
|
21
|
+
// UNSTABLE = a non-required check is failing/pending; BLOCKED = required review/
|
|
22
|
+
// check unmet; DIRTY = conflicts; BEHIND = base moved. All fail closed.
|
|
23
|
+
const BLOCKED_STATES = new Set(['DIRTY', 'BLOCKED', 'BEHIND', 'UNSTABLE']);
|
|
24
|
+
|
|
25
|
+
function gateLog(message) {
|
|
26
|
+
console.log(`[${TOOL_NAME}] [gate] ${message}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Poll the PR's CI until it settles. Fail-closed: red checks, timeout, or a
|
|
31
|
+
* check-less PR (after a grace window) all return a non-green status the caller
|
|
32
|
+
* turns into a block. A just-promoted PR whose checks have not registered yet
|
|
33
|
+
* keeps polling rather than being misread as check-less.
|
|
34
|
+
*
|
|
35
|
+
* Fail-closed semantics:
|
|
36
|
+
* - any failed OR cancelled check blocks (a cancelled required check is NOT a pass);
|
|
37
|
+
* - GitHub's mergeStateStatus is authoritative — BLOCKED/DIRTY/BEHIND/UNSTABLE block;
|
|
38
|
+
* - when GitHub gives no verdict (mss absent/UNKNOWN) we require EVERY check to be an
|
|
39
|
+
* explicit success (no `other` states like ACTION_REQUIRED slipping through);
|
|
40
|
+
* - a check-less PR is only declared `no-checks` after a grace window, so a freshly
|
|
41
|
+
* promoted PR whose checks have not registered yet is not misread.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{status: 'green'|'checks-failed'|'merge-blocked'|'no-checks'|'timeout'|'no-pr', pr?: object}}
|
|
44
|
+
*/
|
|
45
|
+
function waitForGreenCi(repoRoot, branch, options = {}) {
|
|
46
|
+
const timeoutSeconds = options.timeoutSeconds || DEFAULT_GATE_TIMEOUT_SECONDS;
|
|
47
|
+
const pollSeconds = options.pollSeconds || DEFAULT_GATE_POLL_SECONDS;
|
|
48
|
+
const requireChecks = options.requireChecks !== false;
|
|
49
|
+
const graceSeconds = options.noChecksGraceSeconds || DEFAULT_NO_CHECKS_GRACE_SECONDS;
|
|
50
|
+
const sleep = options.sleep || ((seconds) => run('sleep', [String(seconds)], { cwd: repoRoot }));
|
|
51
|
+
const now = options.now || (() => Date.now());
|
|
52
|
+
const getStatus = options.getStatus || ((r, b) => pr.getPullRequestStatus(r, b));
|
|
53
|
+
|
|
54
|
+
const start = now();
|
|
55
|
+
const deadline = start + timeoutSeconds * 1000;
|
|
56
|
+
const graceDeadline = start + graceSeconds * 1000;
|
|
57
|
+
|
|
58
|
+
// eslint-disable-next-line no-constant-condition
|
|
59
|
+
while (true) {
|
|
60
|
+
const snap = getStatus(repoRoot, branch);
|
|
61
|
+
if (!snap) return { status: 'no-pr' };
|
|
62
|
+
const c = snap.checks;
|
|
63
|
+
// A failed or cancelled check is terminal and never a pass.
|
|
64
|
+
if (c.failed > 0 || c.cancelled > 0) return { status: 'checks-failed', pr: snap };
|
|
65
|
+
|
|
66
|
+
const mss = snap.mergeStateStatus;
|
|
67
|
+
// GitHub says this can't merge as-is and won't self-resolve within a finish run.
|
|
68
|
+
if (mss && BLOCKED_STATES.has(mss)) return { status: 'merge-blocked', pr: snap };
|
|
69
|
+
|
|
70
|
+
const settled = c.pending === 0;
|
|
71
|
+
const mergeable = !snap.isDraft && snap.mergeable === 'MERGEABLE';
|
|
72
|
+
const hasChecks = c.total > 0;
|
|
73
|
+
// Trust GitHub's CLEAN/HAS_HOOKS verdict; with no verdict, demand all-success
|
|
74
|
+
// (every check SUCCESS, zero `other`/ambiguous states).
|
|
75
|
+
const trusted = mss
|
|
76
|
+
? MERGEABLE_STATES.has(mss)
|
|
77
|
+
: (c.other === 0 && c.success === c.total);
|
|
78
|
+
|
|
79
|
+
if (settled && mergeable && hasChecks && trusted) return { status: 'green', pr: snap };
|
|
80
|
+
if (settled && mergeable && !hasChecks && (mss ? MERGEABLE_STATES.has(mss) : true)) {
|
|
81
|
+
if (!requireChecks) return { status: 'green', pr: snap };
|
|
82
|
+
// No checks yet — give CI a grace window to create check runs before
|
|
83
|
+
// concluding the PR is genuinely check-less (avoids the promote->merge race).
|
|
84
|
+
if (now() >= graceDeadline) return { status: 'no-checks', pr: snap };
|
|
85
|
+
}
|
|
86
|
+
if (now() >= deadline) return { status: 'timeout', pr: snap };
|
|
87
|
+
sleep(pollSeconds);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Enforce the merge gate for `branch`. Throws (blocking the merge) unless the PR
|
|
93
|
+
* passes a clean AI review AND green CI AND GitHub reports it mergeable. Returns
|
|
94
|
+
* `{ prNumber }` on pass; the caller then proceeds to the real merge.
|
|
95
|
+
*/
|
|
96
|
+
function runReviewGate({ repoRoot, branch, baseBranch, options = {} }, deps = {}) {
|
|
97
|
+
const openPullRequest = deps.openPullRequest || pr.openPullRequest;
|
|
98
|
+
const runPrReview = deps.runPrReview || prReview.runPrReview;
|
|
99
|
+
const markReady = deps.markPullRequestReady || pr.markPullRequestReady;
|
|
100
|
+
const evaluate = deps.evaluateReviewGate || prReview.evaluateReviewGate;
|
|
101
|
+
const waitGreen = deps.waitForGreenCi || waitForGreenCi;
|
|
102
|
+
|
|
103
|
+
const provider = options.reviewProvider || 'codex';
|
|
104
|
+
const requireChecks = !options.allowNoChecks;
|
|
105
|
+
|
|
106
|
+
// 1. Ensure a PR exists (push + open as draft so CI is deferred until the
|
|
107
|
+
// review passes and we explicitly promote).
|
|
108
|
+
const opened = openPullRequest({ repoRoot, branch, base: baseBranch, push: true });
|
|
109
|
+
const prNumber = opened.pr.number;
|
|
110
|
+
gateLog(`PR #${prNumber}: enforcing review + CI gate before merge`);
|
|
111
|
+
|
|
112
|
+
// 2. AI review — FAIL CLOSED. A provider error / timeout / unparseable output
|
|
113
|
+
// throws here; convert it to a block, never a silent pass.
|
|
114
|
+
let review;
|
|
115
|
+
try {
|
|
116
|
+
review = runPrReview({ target: repoRoot, pr: prNumber, provider, post: true });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`review gate: AI review did not complete (${err.message}). Refusing to merge. `
|
|
120
|
+
+ 'Fix the provider/auth issue or rerun with --skip-review-gate.',
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const verdict = evaluate(review.findings);
|
|
124
|
+
if (!verdict.clean) {
|
|
125
|
+
const detail = verdict.blocking
|
|
126
|
+
.map((f) => ` - ${String(f.severity).toUpperCase()} ${f.path}:${f.line} ${f.message}`)
|
|
127
|
+
.join('\n');
|
|
128
|
+
throw new Error(
|
|
129
|
+
`review gate: ${verdict.blocking.length} blocking finding(s). Refusing to merge.\n${detail}\n`
|
|
130
|
+
+ 'Fix the findings or rerun with --skip-review-gate.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
gateLog(
|
|
134
|
+
`PR #${prNumber}: review clean (${review.findings.length} non-blocking finding(s))`
|
|
135
|
+
+ (review.reason === 'github-auth-unavailable' ? ' [not posted: github-auth-unavailable]' : ''),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// 3. Promote draft -> ready so required CI checks fire.
|
|
139
|
+
markReady(repoRoot, prNumber);
|
|
140
|
+
|
|
141
|
+
// 4. Wait for CI to settle green + GitHub to report mergeable. waitForGreenCi
|
|
142
|
+
// is fully fail-closed (failed/cancelled checks, blocked mergeStateStatus,
|
|
143
|
+
// timeout, and check-less PRs all return non-green statuses).
|
|
144
|
+
const ci = waitGreen(repoRoot, branch, {
|
|
145
|
+
timeoutSeconds: options.gateTimeoutSeconds,
|
|
146
|
+
pollSeconds: options.gatePollSeconds,
|
|
147
|
+
requireChecks,
|
|
148
|
+
});
|
|
149
|
+
if (ci.status === 'checks-failed') {
|
|
150
|
+
throw new Error(`review gate: CI checks failed/cancelled on PR #${prNumber}. Refusing to merge.`);
|
|
151
|
+
}
|
|
152
|
+
if (ci.status === 'merge-blocked') {
|
|
153
|
+
const mss = (ci.pr && ci.pr.mergeStateStatus) || 'BLOCKED';
|
|
154
|
+
throw new Error(
|
|
155
|
+
`review gate: GitHub reports mergeStateStatus=${mss} for PR #${prNumber} `
|
|
156
|
+
+ '(not mergeable under branch protection). Refusing to merge.',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (ci.status === 'no-checks') {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`review gate: PR #${prNumber} has no CI checks configured. Refusing to merge an `
|
|
162
|
+
+ 'unverified PR. Pass --allow-no-checks to override.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (ci.status === 'timeout') {
|
|
166
|
+
throw new Error(`review gate: timed out waiting for CI to go green on PR #${prNumber}.`);
|
|
167
|
+
}
|
|
168
|
+
if (ci.status !== 'green') {
|
|
169
|
+
throw new Error(`review gate: PR #${prNumber} not in a mergeable state (${ci.status}).`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const mss = ci.pr && ci.pr.mergeStateStatus;
|
|
173
|
+
gateLog(`PR #${prNumber}: review clean + CI green${mss ? ` + mergeStateStatus=${mss}` : ''} — proceeding to merge`);
|
|
174
|
+
return { prNumber };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
runReviewGate,
|
|
179
|
+
waitForGreenCi,
|
|
180
|
+
DEFAULT_GATE_TIMEOUT_SECONDS,
|
|
181
|
+
MERGEABLE_STATES,
|
|
182
|
+
};
|