@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/pr-review.js
CHANGED
|
@@ -153,6 +153,12 @@ function runProviderReview(provider, diff, repoRoot, timeoutMs, runner = run) {
|
|
|
153
153
|
if (result.status !== 0) {
|
|
154
154
|
throw new Error(`${provider} review failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
|
|
155
155
|
}
|
|
156
|
+
// A compliant provider always emits the findings JSON object (empty array when
|
|
157
|
+
// nothing is wrong). Empty stdout means the review did NOT run — fail closed so
|
|
158
|
+
// a silent no-op is never mistaken for "clean" by the merge gate.
|
|
159
|
+
if (!(result.stdout || '').trim()) {
|
|
160
|
+
throw new Error(`${provider} review returned no output (review did not run)`);
|
|
161
|
+
}
|
|
156
162
|
return normalizeFindings(result.stdout || '');
|
|
157
163
|
}
|
|
158
164
|
|
|
@@ -218,6 +224,22 @@ function runPrReview(options, deps = {}) {
|
|
|
218
224
|
return { posted: true, artifactPath: '', findings };
|
|
219
225
|
}
|
|
220
226
|
|
|
227
|
+
/**
|
|
228
|
+
* Decide whether a set of review findings clears the merge gate. Blocks on any
|
|
229
|
+
* finding whose severity is in `blockSeverities` (high + critical by default;
|
|
230
|
+
* low/medium are advisory). Pure + synchronous so it is trivially testable.
|
|
231
|
+
*
|
|
232
|
+
* Fail-closed semantics live in the CALLER: an absent/empty `findings` here is
|
|
233
|
+
* treated as clean, so the caller must independently treat a review that did
|
|
234
|
+
* NOT run (provider threw / timed out) as a block, never as clean.
|
|
235
|
+
*/
|
|
236
|
+
function evaluateReviewGate(findings, { blockSeverities = ['high', 'critical'] } = {}) {
|
|
237
|
+
const block = new Set(blockSeverities.map((s) => String(s).toLowerCase()));
|
|
238
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
239
|
+
const blocking = list.filter((f) => f && block.has(String(f.severity).toLowerCase()));
|
|
240
|
+
return { clean: blocking.length === 0, blocking };
|
|
241
|
+
}
|
|
242
|
+
|
|
221
243
|
function printPrReviewResult(result) {
|
|
222
244
|
if (result.posted) {
|
|
223
245
|
console.log(`${TOOL_PREFIX} Posted PR review with ${result.findings.length} finding(s).`);
|
|
@@ -237,5 +259,6 @@ module.exports = {
|
|
|
237
259
|
normalizeFindings,
|
|
238
260
|
renderMarkdownReview,
|
|
239
261
|
runPrReview,
|
|
262
|
+
evaluateReviewGate,
|
|
240
263
|
printPrReviewResult,
|
|
241
264
|
};
|
package/src/pr.js
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// PR-from-worktree helpers.
|
|
2
|
+
//
|
|
3
|
+
// Centralizes the operations that the agent + worktree flow needs to drive a
|
|
4
|
+
// pull request: detecting the PR for the current branch, opening one
|
|
5
|
+
// idempotently, pushing + syncing, enabling auto-merge, and polling CI / merge
|
|
6
|
+
// state. The agent-branch-finish.sh script historically owned most of this;
|
|
7
|
+
// these helpers expose the same primitives to the JS CLI so `gx pr ...` can be
|
|
8
|
+
// driven without invoking the shell flow.
|
|
9
|
+
|
|
10
|
+
const { GH_BIN } = require('./context');
|
|
11
|
+
const { run } = require('./core/runtime');
|
|
12
|
+
const {
|
|
13
|
+
resolveRepoRoot,
|
|
14
|
+
currentBranchName,
|
|
15
|
+
hasOriginRemote,
|
|
16
|
+
detectDefaultBaseBranch,
|
|
17
|
+
} = require('./git');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
20
|
+
const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
class PrError extends Error {
|
|
23
|
+
constructor(message, { code = 'pr-error', cause = null } = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'PrError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (cause) this.cause = cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ghAvailable() {
|
|
32
|
+
const result = run(GH_BIN, ['--version'], { allowFailure: true });
|
|
33
|
+
return result.status === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ghAuthStatus() {
|
|
37
|
+
const result = run(GH_BIN, ['auth', 'status'], { allowFailure: true });
|
|
38
|
+
return {
|
|
39
|
+
authenticated: result.status === 0,
|
|
40
|
+
output: ((result.stdout || '') + (result.stderr || '')).trim(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ghJson(repoRoot, args) {
|
|
45
|
+
const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
return { ok: false, error: (result.stderr || result.stdout || '').trim(), data: null };
|
|
48
|
+
}
|
|
49
|
+
const raw = (result.stdout || '').trim();
|
|
50
|
+
if (!raw) return { ok: true, data: null, error: null };
|
|
51
|
+
try {
|
|
52
|
+
return { ok: true, data: JSON.parse(raw), error: null };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return { ok: false, data: null, error: `gh JSON parse: ${error.message}` };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the PR for `branch` if any exists on the origin remote.
|
|
60
|
+
* Returns null when no PR is open (or any are merged / closed only).
|
|
61
|
+
*/
|
|
62
|
+
function findOpenPrForBranch(repoRoot, branch) {
|
|
63
|
+
const result = ghJson(repoRoot, [
|
|
64
|
+
'pr', 'list',
|
|
65
|
+
'--head', branch,
|
|
66
|
+
'--state', 'open',
|
|
67
|
+
'--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title,statusCheckRollup',
|
|
68
|
+
'--limit', '5',
|
|
69
|
+
]);
|
|
70
|
+
if (!result.ok) {
|
|
71
|
+
throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
|
|
72
|
+
}
|
|
73
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
74
|
+
if (items.length === 0) return null;
|
|
75
|
+
return items[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the most recent PR for `branch` regardless of state. Useful when
|
|
80
|
+
* the agent has already merged a previous PR for this branch.
|
|
81
|
+
*/
|
|
82
|
+
function findLatestPrForBranch(repoRoot, branch) {
|
|
83
|
+
const result = ghJson(repoRoot, [
|
|
84
|
+
'pr', 'list',
|
|
85
|
+
'--head', branch,
|
|
86
|
+
'--state', 'all',
|
|
87
|
+
'--json', 'number,url,state,isDraft,mergedAt,headRefName,baseRefName,title',
|
|
88
|
+
'--limit', '5',
|
|
89
|
+
]);
|
|
90
|
+
if (!result.ok) {
|
|
91
|
+
throw new PrError(`gh pr list failed: ${result.error}`, { code: 'gh-list-failed' });
|
|
92
|
+
}
|
|
93
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
94
|
+
if (items.length === 0) return null;
|
|
95
|
+
return items[0];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List ALL open PRs for the repo on origin in a single gh call, so callers can
|
|
100
|
+
* correlate many branches to their PRs without one gh round-trip per branch.
|
|
101
|
+
* Best-effort: never throws. Returns `{ prs, error }` so callers can tell a
|
|
102
|
+
* genuine "no open PRs" (`error: null`) from a failed lookup (gh missing /
|
|
103
|
+
* unauthenticated / offline → `error` set, `prs: []`).
|
|
104
|
+
* NB: `limit` (default 100) bounds correlation COVERAGE, not just payload size —
|
|
105
|
+
* a repo with more than `limit` open PRs may leave some lanes showing pr:null.
|
|
106
|
+
*/
|
|
107
|
+
function listOpenPrsForRepo(repoRoot, { limit = 100 } = {}) {
|
|
108
|
+
const result = ghJson(repoRoot, [
|
|
109
|
+
'pr', 'list',
|
|
110
|
+
'--state', 'open',
|
|
111
|
+
'--json', 'number,url,state,isDraft,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,title',
|
|
112
|
+
'--limit', String(limit),
|
|
113
|
+
]);
|
|
114
|
+
if (!result.ok) return { prs: [], error: result.error || 'gh pr list failed' };
|
|
115
|
+
if (!Array.isArray(result.data)) return { prs: [], error: null };
|
|
116
|
+
return { prs: result.data, error: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pushBranch(repoRoot, branch, { setUpstream = true } = {}) {
|
|
120
|
+
const args = ['push'];
|
|
121
|
+
if (setUpstream) args.push('-u');
|
|
122
|
+
args.push('origin', branch);
|
|
123
|
+
const result = run('git', args, { cwd: repoRoot, allowFailure: true });
|
|
124
|
+
return {
|
|
125
|
+
ok: result.status === 0,
|
|
126
|
+
output: ((result.stdout || '') + (result.stderr || '')).trim(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function defaultPrTitleFromCommit(repoRoot, branch) {
|
|
131
|
+
const result = run('git', ['log', '-1', '--pretty=%s', branch], {
|
|
132
|
+
cwd: repoRoot, allowFailure: true,
|
|
133
|
+
});
|
|
134
|
+
if (result.status !== 0) return branch;
|
|
135
|
+
const subject = (result.stdout || '').trim();
|
|
136
|
+
return subject || branch;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function defaultPrBodyFromCommits(repoRoot, branch, baseBranch) {
|
|
140
|
+
const result = run('git', [
|
|
141
|
+
'log',
|
|
142
|
+
`${baseBranch}..${branch}`,
|
|
143
|
+
'--pretty=format:- %s',
|
|
144
|
+
], { cwd: repoRoot, allowFailure: true });
|
|
145
|
+
const list = (result.stdout || '').trim();
|
|
146
|
+
return [
|
|
147
|
+
'## Summary',
|
|
148
|
+
list || '- (no commits between base and branch yet)',
|
|
149
|
+
'',
|
|
150
|
+
'## Test plan',
|
|
151
|
+
'- [ ] verified locally',
|
|
152
|
+
].join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create (or fetch existing) PR for the current worktree branch. Idempotent:
|
|
157
|
+
* if an open PR is already associated with the branch, returns it without
|
|
158
|
+
* creating a duplicate.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} options
|
|
161
|
+
* @param {string} options.repoRoot
|
|
162
|
+
* @param {string} options.branch
|
|
163
|
+
* @param {string} [options.base]
|
|
164
|
+
* @param {string} [options.title]
|
|
165
|
+
* @param {string} [options.body]
|
|
166
|
+
* @param {boolean} [options.draft]
|
|
167
|
+
* @param {boolean} [options.push]
|
|
168
|
+
*/
|
|
169
|
+
function openPullRequest(options) {
|
|
170
|
+
const repoRoot = options.repoRoot;
|
|
171
|
+
const branch = options.branch;
|
|
172
|
+
if (!repoRoot) throw new PrError('repoRoot required', { code: 'bad-arg' });
|
|
173
|
+
if (!branch) throw new PrError('branch required', { code: 'bad-arg' });
|
|
174
|
+
|
|
175
|
+
if (!ghAvailable()) {
|
|
176
|
+
throw new PrError('gh CLI not installed. Install from https://cli.github.com/', {
|
|
177
|
+
code: 'gh-missing',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
const auth = ghAuthStatus();
|
|
181
|
+
if (!auth.authenticated) {
|
|
182
|
+
throw new PrError(
|
|
183
|
+
`GitHub CLI is not authenticated. Run 'gh auth login' or set GITHUB_TOKEN.\n${auth.output}`,
|
|
184
|
+
{ code: 'gh-unauthenticated' },
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (!hasOriginRemote(repoRoot)) {
|
|
188
|
+
throw new PrError('No `origin` remote configured for this repo', {
|
|
189
|
+
code: 'no-origin',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Try to find an existing open PR first.
|
|
194
|
+
const existing = findOpenPrForBranch(repoRoot, branch);
|
|
195
|
+
if (existing) {
|
|
196
|
+
return { created: false, pr: existing };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (options.push !== false) {
|
|
200
|
+
const push = pushBranch(repoRoot, branch);
|
|
201
|
+
if (!push.ok) {
|
|
202
|
+
throw new PrError(`git push failed:\n${push.output}`, { code: 'push-failed' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const base = options.base || detectDefaultBaseBranch(repoRoot);
|
|
207
|
+
const title = options.title || defaultPrTitleFromCommit(repoRoot, branch);
|
|
208
|
+
const body = options.body || defaultPrBodyFromCommits(repoRoot, branch, base);
|
|
209
|
+
|
|
210
|
+
const args = [
|
|
211
|
+
'pr', 'create',
|
|
212
|
+
'--base', base,
|
|
213
|
+
'--head', branch,
|
|
214
|
+
'--title', title,
|
|
215
|
+
'--body', body,
|
|
216
|
+
];
|
|
217
|
+
if (options.draft !== false) args.push('--draft');
|
|
218
|
+
|
|
219
|
+
const result = run(GH_BIN, args, { cwd: repoRoot, allowFailure: true });
|
|
220
|
+
if (result.status !== 0) {
|
|
221
|
+
throw new PrError(
|
|
222
|
+
`gh pr create failed:\n${(result.stderr || result.stdout || '').trim()}`,
|
|
223
|
+
{ code: 'gh-create-failed' },
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const created = findOpenPrForBranch(repoRoot, branch);
|
|
228
|
+
if (!created) {
|
|
229
|
+
throw new PrError('PR appears to have been created but could not be located on origin', {
|
|
230
|
+
code: 'pr-lookup-failed',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return { created: true, pr: created };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Fetch the live status of the PR for `branch`. Returns null when no open PR
|
|
238
|
+
* exists. Status checks are summarized.
|
|
239
|
+
*/
|
|
240
|
+
function getPullRequestStatus(repoRoot, branch) {
|
|
241
|
+
const pr = findOpenPrForBranch(repoRoot, branch);
|
|
242
|
+
if (!pr) return null;
|
|
243
|
+
|
|
244
|
+
const checks = Array.isArray(pr.statusCheckRollup) ? pr.statusCheckRollup : [];
|
|
245
|
+
const summary = checks.reduce(
|
|
246
|
+
(acc, check) => {
|
|
247
|
+
const state = String(check?.conclusion || check?.status || '').toUpperCase();
|
|
248
|
+
if (state === 'SUCCESS') acc.success += 1;
|
|
249
|
+
else if (state === 'FAILURE' || state === 'ERROR' || state === 'TIMED_OUT') acc.failed += 1;
|
|
250
|
+
else if (state === 'PENDING' || state === 'IN_PROGRESS' || state === 'QUEUED') acc.pending += 1;
|
|
251
|
+
else if (state === 'CANCELLED') acc.cancelled += 1;
|
|
252
|
+
else acc.other += 1;
|
|
253
|
+
return acc;
|
|
254
|
+
},
|
|
255
|
+
{ success: 0, failed: 0, pending: 0, cancelled: 0, other: 0, total: checks.length },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
number: pr.number,
|
|
260
|
+
url: pr.url,
|
|
261
|
+
state: pr.state,
|
|
262
|
+
isDraft: pr.isDraft,
|
|
263
|
+
mergeable: pr.mergeable,
|
|
264
|
+
mergeStateStatus: pr.mergeStateStatus,
|
|
265
|
+
reviewDecision: pr.reviewDecision,
|
|
266
|
+
title: pr.title,
|
|
267
|
+
head: pr.headRefName,
|
|
268
|
+
base: pr.baseRefName,
|
|
269
|
+
checks: summary,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function enableAutoMerge(repoRoot, prNumber, { strategy = 'squash' } = {}) {
|
|
274
|
+
const flag = strategy === 'merge'
|
|
275
|
+
? '--merge'
|
|
276
|
+
: strategy === 'rebase' ? '--rebase' : '--squash';
|
|
277
|
+
const result = run(GH_BIN, [
|
|
278
|
+
'pr', 'merge', String(prNumber),
|
|
279
|
+
'--auto', flag, '--delete-branch',
|
|
280
|
+
], { cwd: repoRoot, allowFailure: true });
|
|
281
|
+
return {
|
|
282
|
+
ok: result.status === 0,
|
|
283
|
+
output: ((result.stdout || '') + (result.stderr || '')).trim(),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function markPullRequestReady(repoRoot, prNumber) {
|
|
288
|
+
const result = run(GH_BIN, ['pr', 'ready', String(prNumber)], {
|
|
289
|
+
cwd: repoRoot, allowFailure: true,
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
ok: result.status === 0,
|
|
293
|
+
output: ((result.stdout || '') + (result.stderr || '')).trim(),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Watch a PR's CI + merge state until it merges, fails, or times out.
|
|
299
|
+
*
|
|
300
|
+
* Returns a structured result; does not throw on non-success states. Used by
|
|
301
|
+
* `gx pr watch`. `onEvent` is called for every poll tick with the current
|
|
302
|
+
* status snapshot, so callers can render progress.
|
|
303
|
+
*/
|
|
304
|
+
async function watchPullRequest(options) {
|
|
305
|
+
const {
|
|
306
|
+
repoRoot,
|
|
307
|
+
branch,
|
|
308
|
+
intervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
309
|
+
timeoutMs = DEFAULT_POLL_TIMEOUT_MS,
|
|
310
|
+
onEvent = () => {},
|
|
311
|
+
} = options;
|
|
312
|
+
|
|
313
|
+
const deadline = Date.now() + timeoutMs;
|
|
314
|
+
|
|
315
|
+
// eslint-disable-next-line no-constant-condition
|
|
316
|
+
while (true) {
|
|
317
|
+
// Check open PRs first.
|
|
318
|
+
const open = findOpenPrForBranch(repoRoot, branch);
|
|
319
|
+
if (!open) {
|
|
320
|
+
const latest = findLatestPrForBranch(repoRoot, branch);
|
|
321
|
+
if (latest && latest.state === 'MERGED') {
|
|
322
|
+
onEvent({ phase: 'merged', pr: latest });
|
|
323
|
+
return { status: 'merged', pr: latest };
|
|
324
|
+
}
|
|
325
|
+
if (latest && latest.state === 'CLOSED') {
|
|
326
|
+
onEvent({ phase: 'closed', pr: latest });
|
|
327
|
+
return { status: 'closed', pr: latest };
|
|
328
|
+
}
|
|
329
|
+
onEvent({ phase: 'no-pr', pr: null });
|
|
330
|
+
return { status: 'no-pr', pr: null };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const snapshot = getPullRequestStatus(repoRoot, branch);
|
|
334
|
+
onEvent({ phase: 'polling', pr: snapshot });
|
|
335
|
+
|
|
336
|
+
if (snapshot && snapshot.checks.failed > 0) {
|
|
337
|
+
return { status: 'checks-failed', pr: snapshot };
|
|
338
|
+
}
|
|
339
|
+
if (
|
|
340
|
+
snapshot
|
|
341
|
+
&& !snapshot.isDraft
|
|
342
|
+
&& snapshot.mergeable === 'MERGEABLE'
|
|
343
|
+
&& snapshot.checks.pending === 0
|
|
344
|
+
&& snapshot.checks.failed === 0
|
|
345
|
+
&& snapshot.checks.total > 0
|
|
346
|
+
) {
|
|
347
|
+
// CI is green and PR is non-draft + mergeable; the merge itself will be
|
|
348
|
+
// driven by auto-merge or a separate `gh pr merge` invocation.
|
|
349
|
+
onEvent({ phase: 'ready', pr: snapshot });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (Date.now() >= deadline) {
|
|
353
|
+
return { status: 'timeout', pr: snapshot };
|
|
354
|
+
}
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function resolveRepoAndBranch(target) {
|
|
360
|
+
const repoRoot = resolveRepoRoot(target || process.cwd());
|
|
361
|
+
const branch = currentBranchName(repoRoot);
|
|
362
|
+
return { repoRoot, branch };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
PrError,
|
|
367
|
+
ghAvailable,
|
|
368
|
+
ghAuthStatus,
|
|
369
|
+
findOpenPrForBranch,
|
|
370
|
+
findLatestPrForBranch,
|
|
371
|
+
listOpenPrsForRepo,
|
|
372
|
+
pushBranch,
|
|
373
|
+
openPullRequest,
|
|
374
|
+
getPullRequestStatus,
|
|
375
|
+
enableAutoMerge,
|
|
376
|
+
markPullRequestReady,
|
|
377
|
+
watchPullRequest,
|
|
378
|
+
resolveRepoAndBranch,
|
|
379
|
+
defaultPrTitleFromCommit,
|
|
380
|
+
defaultPrBodyFromCommits,
|
|
381
|
+
};
|
package/src/sandbox/index.js
CHANGED
|
@@ -13,6 +13,14 @@ const {
|
|
|
13
13
|
gitRefExists,
|
|
14
14
|
ensureRepoBranch,
|
|
15
15
|
} = require('../git');
|
|
16
|
+
const { prepareAgentWorktree } = require('../scaffold/agent-worktree-prep');
|
|
17
|
+
|
|
18
|
+
function formatWorktreePrepOps(operations) {
|
|
19
|
+
if (!operations || operations.length === 0) return '';
|
|
20
|
+
return operations
|
|
21
|
+
.map((op) => `[agent-branch-start] worktree-prep ${op.status} ${op.file}${op.note ? ' — ' + op.note : ''}`)
|
|
22
|
+
.join('\n') + '\n';
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
function hasGuardexBootstrapFiles(repoRoot) {
|
|
18
26
|
const required = [
|
|
@@ -195,6 +203,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
|
195
203
|
}
|
|
196
204
|
}
|
|
197
205
|
|
|
206
|
+
const prepOps = prepareAgentWorktree(blocked.repoRoot, selectedWorktreePath);
|
|
198
207
|
return {
|
|
199
208
|
metadata: {
|
|
200
209
|
branch: selectedBranch,
|
|
@@ -202,7 +211,8 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) {
|
|
|
202
211
|
},
|
|
203
212
|
stdout:
|
|
204
213
|
`[agent-branch-start] Created branch: ${selectedBranch}\n` +
|
|
205
|
-
`[agent-branch-start] Worktree: ${selectedWorktreePath}\n
|
|
214
|
+
`[agent-branch-start] Worktree: ${selectedWorktreePath}\n` +
|
|
215
|
+
formatWorktreePrepOps(prepOps),
|
|
206
216
|
stderr: addResult.stderr || '',
|
|
207
217
|
};
|
|
208
218
|
}
|
|
@@ -246,9 +256,10 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) {
|
|
|
246
256
|
return startProtectedBaseSandboxFallback(blocked, sandboxSuffix);
|
|
247
257
|
}
|
|
248
258
|
|
|
259
|
+
const prepOps = prepareAgentWorktree(blocked.repoRoot, worktreePath);
|
|
249
260
|
return {
|
|
250
261
|
metadata,
|
|
251
|
-
stdout: startResult.stdout || '',
|
|
262
|
+
stdout: (startResult.stdout || '') + formatWorktreePrepOps(prepOps),
|
|
252
263
|
stderr: startResult.stderr || '',
|
|
253
264
|
};
|
|
254
265
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Prepares a freshly-created agent worktree for monorepos that have `apps/*`
|
|
4
|
+
// packages. Two jobs:
|
|
5
|
+
//
|
|
6
|
+
// 1. Symlink the root's `apps/<pkg>/.env` (and friends) into the worktree
|
|
7
|
+
// so backend / storefront / etc. can boot with the same secrets without
|
|
8
|
+
// asking the user to copy gitignored env files manually.
|
|
9
|
+
//
|
|
10
|
+
// 2. Pick a free port per app and write it into the worktree's
|
|
11
|
+
// `apps/<pkg>/.env.local` (which both Vite and Medusa's loadEnv read with
|
|
12
|
+
// higher precedence than `.env`). This stops agent dev servers from
|
|
13
|
+
// colliding with whatever's running in the root worktree on the default
|
|
14
|
+
// port.
|
|
15
|
+
//
|
|
16
|
+
// Both jobs are best-effort: if `apps/` doesn't exist, or there are no env
|
|
17
|
+
// files / no package.json in a subfolder, we silently skip — non-monorepo
|
|
18
|
+
// repos see no change.
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const ENV_FILE_CANDIDATES = [
|
|
25
|
+
'.env',
|
|
26
|
+
'.env.local',
|
|
27
|
+
'.env.development',
|
|
28
|
+
'.env.development.local',
|
|
29
|
+
'.env.production',
|
|
30
|
+
'.env.production.local',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// Port pool by detected app role. Storefronts get the Vite/Next range,
|
|
34
|
+
// backends get the Medusa range, everything else gets a generic mid-range.
|
|
35
|
+
const PORT_POOLS = {
|
|
36
|
+
storefront: 5174,
|
|
37
|
+
backend: 9101,
|
|
38
|
+
default: 8100,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function detectAppPackages(repoRoot) {
|
|
42
|
+
const appsRoot = path.join(repoRoot, 'apps');
|
|
43
|
+
let stat;
|
|
44
|
+
try {
|
|
45
|
+
stat = fs.statSync(appsRoot);
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
if (!stat.isDirectory()) return [];
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = fs.readdirSync(appsRoot, { withFileTypes: true });
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return entries
|
|
57
|
+
.filter((e) => e.isDirectory())
|
|
58
|
+
.map((e) => e.name)
|
|
59
|
+
.filter((name) => fs.existsSync(path.join(appsRoot, name, 'package.json')));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function inferAppRole(appName) {
|
|
63
|
+
const n = appName.toLowerCase();
|
|
64
|
+
if (n.includes('storefront') || n.includes('frontend') || n.includes('web')) {
|
|
65
|
+
return 'storefront';
|
|
66
|
+
}
|
|
67
|
+
if (n.includes('backend') || n.includes('api') || n.includes('server')) {
|
|
68
|
+
return 'backend';
|
|
69
|
+
}
|
|
70
|
+
return 'default';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isPortFree(port) {
|
|
74
|
+
// Use `lsof` if available — it's on macOS and most Linux distros. Fall
|
|
75
|
+
// back to assuming free when lsof isn't installed (e.g. minimal Alpine
|
|
76
|
+
// CI image); the dev server will fail loudly if it isn't.
|
|
77
|
+
const probe = spawnSync('lsof', ['-iTCP:' + port, '-sTCP:LISTEN', '-t'], {
|
|
78
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
79
|
+
timeout: 2000,
|
|
80
|
+
});
|
|
81
|
+
if (probe.error) return true;
|
|
82
|
+
const out = (probe.stdout && probe.stdout.toString().trim()) || '';
|
|
83
|
+
return out === '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pickFreePort(start) {
|
|
87
|
+
for (let p = start; p < start + 200; p++) {
|
|
88
|
+
if (isPortFree(p)) return p;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function symlinkAppEnvFiles(repoRoot, worktreePath, appName) {
|
|
94
|
+
const operations = [];
|
|
95
|
+
const rootAppDir = path.join(repoRoot, 'apps', appName);
|
|
96
|
+
const wtAppDir = path.join(worktreePath, 'apps', appName);
|
|
97
|
+
if (!fs.existsSync(wtAppDir)) {
|
|
98
|
+
return operations;
|
|
99
|
+
}
|
|
100
|
+
for (const candidate of ENV_FILE_CANDIDATES) {
|
|
101
|
+
const rootEnv = path.join(rootAppDir, candidate);
|
|
102
|
+
const wtEnv = path.join(wtAppDir, candidate);
|
|
103
|
+
if (!fs.existsSync(rootEnv)) continue;
|
|
104
|
+
// Don't overwrite an existing file/symlink in the worktree.
|
|
105
|
+
let alreadyExists = false;
|
|
106
|
+
try {
|
|
107
|
+
fs.lstatSync(wtEnv);
|
|
108
|
+
alreadyExists = true;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (err.code !== 'ENOENT') throw err;
|
|
111
|
+
}
|
|
112
|
+
if (alreadyExists) {
|
|
113
|
+
operations.push({
|
|
114
|
+
status: 'unchanged',
|
|
115
|
+
file: `apps/${appName}/${candidate}`,
|
|
116
|
+
note: 'already present in worktree',
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
fs.symlinkSync(rootEnv, wtEnv);
|
|
122
|
+
operations.push({
|
|
123
|
+
status: 'linked',
|
|
124
|
+
file: `apps/${appName}/${candidate}`,
|
|
125
|
+
note: `→ ${path.relative(worktreePath, rootEnv)}`,
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
operations.push({
|
|
129
|
+
status: 'failed',
|
|
130
|
+
file: `apps/${appName}/${candidate}`,
|
|
131
|
+
note: `symlink failed: ${err.message}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return operations;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assignAgentPort(repoRoot, worktreePath, appName, takenPorts) {
|
|
139
|
+
const wtAppDir = path.join(worktreePath, 'apps', appName);
|
|
140
|
+
if (!fs.existsSync(wtAppDir)) {
|
|
141
|
+
return { status: 'skipped', file: `apps/${appName}`, note: 'no app dir in worktree' };
|
|
142
|
+
}
|
|
143
|
+
const role = inferAppRole(appName);
|
|
144
|
+
const base = PORT_POOLS[role] || PORT_POOLS.default;
|
|
145
|
+
let port = pickFreePort(base);
|
|
146
|
+
// Bump past anything we've already assigned this run.
|
|
147
|
+
while (port !== null && takenPorts.has(port)) {
|
|
148
|
+
port = pickFreePort(port + 1);
|
|
149
|
+
}
|
|
150
|
+
if (port === null) {
|
|
151
|
+
return {
|
|
152
|
+
status: 'failed',
|
|
153
|
+
file: `apps/${appName}/.env.local`,
|
|
154
|
+
note: 'no free port found in pool',
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
takenPorts.add(port);
|
|
158
|
+
|
|
159
|
+
const envLocalPath = path.join(wtAppDir, '.env.local');
|
|
160
|
+
let existing = '';
|
|
161
|
+
try {
|
|
162
|
+
existing = fs.readFileSync(envLocalPath, 'utf8');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err.code !== 'ENOENT') throw err;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// If a .env.local already exists, replace the PORT= line if present,
|
|
168
|
+
// otherwise append. Keep everything else the user might have added.
|
|
169
|
+
const portLine = `PORT=${port}`;
|
|
170
|
+
let next;
|
|
171
|
+
if (/^PORT=/m.test(existing)) {
|
|
172
|
+
next = existing.replace(/^PORT=.*$/m, portLine);
|
|
173
|
+
} else {
|
|
174
|
+
const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
|
|
175
|
+
next = `${existing}${sep}${portLine}\n`;
|
|
176
|
+
// Header on fresh files so the user knows what wrote this.
|
|
177
|
+
if (existing.length === 0) {
|
|
178
|
+
next = `# Written by gitguardex on worktree creation — agent dev server port.\n${portLine}\n`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
fs.writeFileSync(envLocalPath, next, 'utf8');
|
|
182
|
+
return {
|
|
183
|
+
status: 'wrote',
|
|
184
|
+
file: `apps/${appName}/.env.local`,
|
|
185
|
+
note: `PORT=${port} (${role} pool)`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function prepareAgentWorktree(repoRoot, worktreePath) {
|
|
190
|
+
if (!repoRoot || !worktreePath) return [];
|
|
191
|
+
if (repoRoot === worktreePath) return [];
|
|
192
|
+
if (!fs.existsSync(worktreePath)) return [];
|
|
193
|
+
const apps = detectAppPackages(repoRoot);
|
|
194
|
+
if (apps.length === 0) return [];
|
|
195
|
+
|
|
196
|
+
const operations = [];
|
|
197
|
+
const takenPorts = new Set();
|
|
198
|
+
for (const appName of apps) {
|
|
199
|
+
operations.push(...symlinkAppEnvFiles(repoRoot, worktreePath, appName));
|
|
200
|
+
operations.push(assignAgentPort(repoRoot, worktreePath, appName, takenPorts));
|
|
201
|
+
}
|
|
202
|
+
return operations;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
detectAppPackages,
|
|
207
|
+
inferAppRole,
|
|
208
|
+
isPortFree,
|
|
209
|
+
pickFreePort,
|
|
210
|
+
symlinkAppEnvFiles,
|
|
211
|
+
assignAgentPort,
|
|
212
|
+
prepareAgentWorktree,
|
|
213
|
+
};
|