@imdeadpool/guardex 7.0.41 → 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 +94 -13
- package/package.json +3 -1
- package/skills/gitguardex/SKILL.md +13 -0
- package/skills/guardex-merge-skills-to-dev/SKILL.md +59 -0
- package/skills/gx-act/SKILL.md +82 -0
- package/src/agents/cleanup-sessions.js +126 -0
- package/src/agents/finish.js +172 -0
- package/src/agents/inspect.js +202 -0
- package/src/agents/launch.js +249 -0
- package/src/agents/registry.js +133 -0
- package/src/agents/selection-panel.js +571 -0
- package/src/agents/sessions.js +151 -0
- package/src/agents/start.js +591 -0
- package/src/agents/status.js +146 -0
- package/src/agents/terminal.js +152 -0
- package/src/budget/index.js +344 -0
- package/src/ci-init/index.js +265 -0
- package/src/cli/args.js +357 -3
- 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 +85 -3613
- 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/cockpit/action-runner.js +3 -0
- package/src/cockpit/actions.js +80 -0
- package/src/cockpit/control.js +1121 -0
- package/src/cockpit/index.js +426 -0
- package/src/cockpit/kitty-layout.js +549 -0
- package/src/cockpit/kitty-tree.js +144 -0
- package/src/cockpit/logs-reader.js +182 -0
- package/src/cockpit/menu.js +204 -0
- package/src/cockpit/pane-actions.js +597 -0
- package/src/cockpit/pane-menu.js +387 -0
- package/src/cockpit/projects-finder.js +178 -0
- package/src/cockpit/render.js +215 -0
- package/src/cockpit/settings-render.js +128 -0
- package/src/cockpit/settings.js +124 -0
- package/src/cockpit/shortcuts.js +24 -0
- package/src/cockpit/sidebar.js +311 -0
- package/src/cockpit/state.js +72 -0
- package/src/cockpit/theme.js +128 -0
- package/src/cockpit/welcome.js +266 -0
- package/src/context.js +304 -43
- package/src/core/runtime.js +6 -1
- package/src/doctor/index.js +45 -15
- package/src/finish/index.js +186 -7
- package/src/finish/preflight.js +177 -0
- package/src/finish/review-gate.js +182 -0
- package/src/git/index.js +511 -4
- package/src/hooks/index.js +0 -64
- package/src/kitty/command.js +101 -0
- package/src/kitty/runtime.js +250 -0
- package/src/mcp/collect.js +370 -0
- package/src/mcp/server.js +157 -0
- package/src/output/index.js +68 -2
- package/src/pr-review.js +264 -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 +127 -10
- package/src/speckit/index.js +226 -0
- package/src/submodule/index.js +288 -0
- package/src/terminal/index.js +45 -0
- package/src/terminal/kitty.js +622 -0
- package/src/terminal/tmux.js +125 -0
- package/src/tmux/command.js +27 -0
- package/src/tmux/session.js +89 -0
- package/src/toolchain/index.js +20 -0
- package/templates/AGENTS.monorepo-apps.md +26 -0
- package/templates/AGENTS.multiagent-safety.md +63 -323
- package/templates/AGENTS.multiagent-safety.min.md +11 -0
- package/templates/codex/skills/gitguardex/SKILL.md +2 -0
- package/templates/codex/skills/gx-act/SKILL.md +82 -0
- package/templates/githooks/pre-commit +44 -20
- package/templates/github/workflows/README.md +87 -0
- package/templates/github/workflows/ci-full.yml +55 -0
- package/templates/github/workflows/ci.yml +56 -0
- package/templates/github/workflows/cr.yml +20 -1
- package/templates/scripts/agent-branch-finish.sh +519 -23
- package/templates/scripts/agent-branch-merge.sh +4 -1
- package/templates/scripts/agent-branch-start.sh +176 -24
- package/templates/scripts/agent-preflight.sh +115 -0
- package/templates/scripts/agent-worktree-prune.sh +96 -5
- package/templates/scripts/codex-agent.sh +41 -97
- package/templates/scripts/openspec/init-plan-workspace.sh +43 -0
- package/templates/scripts/review-bot-watch.sh +31 -2
- package/templates/scripts/agent-session-state.js +0 -171
- package/templates/scripts/install-vscode-active-agents-extension.js +0 -135
- package/templates/vscode/guardex-active-agents/README.md +0 -34
- package/templates/vscode/guardex-active-agents/extension.js +0 -3782
- package/templates/vscode/guardex-active-agents/fileicons/gitguardex-fileicons.json +0 -54
- package/templates/vscode/guardex-active-agents/fileicons/icons/agent.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/branch.svg +0 -7
- package/templates/vscode/guardex-active-agents/fileicons/icons/config.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/hook.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/openspec.svg +0 -5
- package/templates/vscode/guardex-active-agents/fileicons/icons/plan.svg +0 -4
- package/templates/vscode/guardex-active-agents/fileicons/icons/spec.svg +0 -5
- package/templates/vscode/guardex-active-agents/icon.png +0 -0
- package/templates/vscode/guardex-active-agents/media/active-agents-hivemind.svg +0 -14
- package/templates/vscode/guardex-active-agents/package.json +0 -169
- package/templates/vscode/guardex-active-agents/session-schema.js +0 -1348
package/src/pr-review.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
const {
|
|
2
|
+
fs,
|
|
3
|
+
path,
|
|
4
|
+
os,
|
|
5
|
+
GH_BIN,
|
|
6
|
+
} = require('./context');
|
|
7
|
+
const { run } = require('./core/runtime');
|
|
8
|
+
|
|
9
|
+
const TOOL_PREFIX = '[gitguardex]';
|
|
10
|
+
const VALID_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
|
11
|
+
|
|
12
|
+
function normalizeProvider(raw) {
|
|
13
|
+
const provider = String(raw || 'codex').trim().toLowerCase();
|
|
14
|
+
if (!['codex', 'claude'].includes(provider)) {
|
|
15
|
+
throw new Error(`Invalid provider: ${raw}`);
|
|
16
|
+
}
|
|
17
|
+
return provider;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function commandForProvider(provider, prompt) {
|
|
21
|
+
if (provider === 'claude') {
|
|
22
|
+
return { cmd: 'claude', args: ['-p', prompt] };
|
|
23
|
+
}
|
|
24
|
+
return { cmd: 'codex', args: ['exec', prompt] };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function compactReviewPrompt(diff) {
|
|
28
|
+
return [
|
|
29
|
+
'You are gitguardex-code-assist, a PR review runner.',
|
|
30
|
+
'Review this GitHub PR diff for correctness bugs, regressions, security issues, and missing tests.',
|
|
31
|
+
'Return JSON only. Shape:',
|
|
32
|
+
'{"findings":[{"path":"file","line":123,"severity":"low|medium|high|critical","message":"concise finding","suggestion":"optional replacement or fix"}]}',
|
|
33
|
+
'Rules: path and line must point to changed lines in the diff. Use an empty findings array when nothing is worth commenting.',
|
|
34
|
+
'',
|
|
35
|
+
'PR diff:',
|
|
36
|
+
diff,
|
|
37
|
+
].join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractJsonPayload(text) {
|
|
41
|
+
const raw = String(text || '').trim();
|
|
42
|
+
if (!raw) return { findings: [] };
|
|
43
|
+
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
44
|
+
const candidate = fenced ? fenced[1].trim() : raw;
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(candidate);
|
|
47
|
+
} catch (_error) {
|
|
48
|
+
const objectStart = candidate.indexOf('{');
|
|
49
|
+
const objectEnd = candidate.lastIndexOf('}');
|
|
50
|
+
if (objectStart >= 0 && objectEnd > objectStart) {
|
|
51
|
+
return JSON.parse(candidate.slice(objectStart, objectEnd + 1));
|
|
52
|
+
}
|
|
53
|
+
const arrayStart = candidate.indexOf('[');
|
|
54
|
+
const arrayEnd = candidate.lastIndexOf(']');
|
|
55
|
+
if (arrayStart >= 0 && arrayEnd > arrayStart) {
|
|
56
|
+
return { findings: JSON.parse(candidate.slice(arrayStart, arrayEnd + 1)) };
|
|
57
|
+
}
|
|
58
|
+
throw new Error('Review provider did not return parseable JSON findings');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function normalizeFinding(rawFinding) {
|
|
63
|
+
const pathValue = String(rawFinding?.path || '').trim();
|
|
64
|
+
const line = Number.parseInt(String(rawFinding?.line || ''), 10);
|
|
65
|
+
const severity = String(rawFinding?.severity || 'medium').trim().toLowerCase();
|
|
66
|
+
const message = String(rawFinding?.message || '').trim();
|
|
67
|
+
const suggestion = String(rawFinding?.suggestion || '').trim();
|
|
68
|
+
if (!pathValue || !Number.isInteger(line) || line <= 0 || !message) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
path: pathValue,
|
|
73
|
+
line,
|
|
74
|
+
severity: VALID_SEVERITIES.has(severity) ? severity : 'medium',
|
|
75
|
+
message,
|
|
76
|
+
suggestion,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeFindings(providerOutput) {
|
|
81
|
+
const payload = extractJsonPayload(providerOutput);
|
|
82
|
+
const rawFindings = Array.isArray(payload) ? payload : payload.findings;
|
|
83
|
+
if (!Array.isArray(rawFindings)) {
|
|
84
|
+
throw new Error('Review provider JSON must contain a findings array');
|
|
85
|
+
}
|
|
86
|
+
return rawFindings.map(normalizeFinding).filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findingBody(finding) {
|
|
90
|
+
const lines = [`**${finding.severity.toUpperCase()}** ${finding.message}`];
|
|
91
|
+
if (finding.suggestion) {
|
|
92
|
+
lines.push('', 'Suggested fix:', '```suggestion', finding.suggestion, '```');
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderMarkdownReview({ pr, provider, findings }) {
|
|
98
|
+
const lines = [
|
|
99
|
+
`# GitGuardex PR Review`,
|
|
100
|
+
'',
|
|
101
|
+
`- PR: #${pr}`,
|
|
102
|
+
`- Provider: ${provider}`,
|
|
103
|
+
`- Findings: ${findings.length}`,
|
|
104
|
+
'',
|
|
105
|
+
];
|
|
106
|
+
if (findings.length === 0) {
|
|
107
|
+
lines.push('No findings.');
|
|
108
|
+
} else {
|
|
109
|
+
for (const finding of findings) {
|
|
110
|
+
lines.push(`## ${finding.severity.toUpperCase()} ${finding.path}:${finding.line}`);
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push(finding.message);
|
|
113
|
+
if (finding.suggestion) {
|
|
114
|
+
lines.push('', '```suggestion', finding.suggestion, '```');
|
|
115
|
+
}
|
|
116
|
+
lines.push('');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n')}\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function defaultArtifactPath(repoRoot, pr) {
|
|
123
|
+
return path.join(repoRoot, '.gitguardex', 'pr-reviews', `pr-${pr}.md`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeArtifact(repoRoot, artifactPath, payload) {
|
|
127
|
+
const outputPath = artifactPath
|
|
128
|
+
? path.resolve(repoRoot, artifactPath)
|
|
129
|
+
: defaultArtifactPath(repoRoot, payload.pr);
|
|
130
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(outputPath, renderMarkdownReview(payload), 'utf8');
|
|
132
|
+
return outputPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function githubAuthAvailable(env = process.env, runner = run) {
|
|
136
|
+
if (env.GITHUB_TOKEN || env.GH_TOKEN) return true;
|
|
137
|
+
const result = runner(GH_BIN, ['auth', 'status'], { timeout: 15_000 });
|
|
138
|
+
return result.status === 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fetchPrDiff(pr, repoRoot, runner = run) {
|
|
142
|
+
const result = runner(GH_BIN, ['pr', 'diff', String(pr)], { cwd: repoRoot, timeout: 120_000 });
|
|
143
|
+
if (result.status !== 0) {
|
|
144
|
+
throw new Error(`gh pr diff ${pr} failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
|
|
145
|
+
}
|
|
146
|
+
return result.stdout || '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runProviderReview(provider, diff, repoRoot, timeoutMs, runner = run) {
|
|
150
|
+
const prompt = compactReviewPrompt(diff);
|
|
151
|
+
const command = commandForProvider(provider, prompt);
|
|
152
|
+
const result = runner(command.cmd, command.args, { cwd: repoRoot, timeout: timeoutMs });
|
|
153
|
+
if (result.status !== 0) {
|
|
154
|
+
throw new Error(`${provider} review failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
|
|
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
|
+
}
|
|
162
|
+
return normalizeFindings(result.stdout || '');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function postGithubReview(pr, findings, repoRoot, runner = run) {
|
|
166
|
+
const comments = findings.map((finding) => ({
|
|
167
|
+
path: finding.path,
|
|
168
|
+
line: finding.line,
|
|
169
|
+
side: 'RIGHT',
|
|
170
|
+
body: findingBody(finding),
|
|
171
|
+
}));
|
|
172
|
+
const body = findings.length > 0
|
|
173
|
+
? `GitGuardex code-assist found ${findings.length} issue(s).`
|
|
174
|
+
: 'GitGuardex code-assist found no issues worth inline comments.';
|
|
175
|
+
const payload = {
|
|
176
|
+
event: 'COMMENT',
|
|
177
|
+
body,
|
|
178
|
+
comments,
|
|
179
|
+
};
|
|
180
|
+
const inputPath = path.join(os.tmpdir(), `gitguardex-pr-review-${process.pid}-${Date.now()}.json`);
|
|
181
|
+
fs.writeFileSync(inputPath, JSON.stringify(payload), 'utf8');
|
|
182
|
+
try {
|
|
183
|
+
const result = runner(GH_BIN, [
|
|
184
|
+
'api',
|
|
185
|
+
`repos/:owner/:repo/pulls/${pr}/reviews`,
|
|
186
|
+
'--method',
|
|
187
|
+
'POST',
|
|
188
|
+
'--input',
|
|
189
|
+
inputPath,
|
|
190
|
+
], { cwd: repoRoot, timeout: 120_000 });
|
|
191
|
+
if (result.status !== 0) {
|
|
192
|
+
throw new Error(`gh api review post failed${result.stderr ? `\n${result.stderr.trim()}` : ''}`);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
} finally {
|
|
196
|
+
try {
|
|
197
|
+
fs.unlinkSync(inputPath);
|
|
198
|
+
} catch (_error) {
|
|
199
|
+
// best effort cleanup for temp API payload
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runPrReview(options, deps = {}) {
|
|
205
|
+
const runner = deps.run || run;
|
|
206
|
+
const repoRoot = path.resolve(options.target || process.cwd());
|
|
207
|
+
const provider = normalizeProvider(options.provider);
|
|
208
|
+
const pr = String(options.pr);
|
|
209
|
+
const diff = fetchPrDiff(pr, repoRoot, runner);
|
|
210
|
+
const findings = runProviderReview(provider, diff, repoRoot, options.timeoutMs, runner);
|
|
211
|
+
const payload = { pr, provider, findings };
|
|
212
|
+
|
|
213
|
+
if (!options.post) {
|
|
214
|
+
const artifactPath = writeArtifact(repoRoot, options.artifact, payload);
|
|
215
|
+
return { posted: false, artifactPath, findings };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!githubAuthAvailable(process.env, runner)) {
|
|
219
|
+
const artifactPath = writeArtifact(repoRoot, options.artifact, payload);
|
|
220
|
+
return { posted: false, artifactPath, findings, reason: 'github-auth-unavailable' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
postGithubReview(pr, findings, repoRoot, runner);
|
|
224
|
+
return { posted: true, artifactPath: '', findings };
|
|
225
|
+
}
|
|
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
|
+
|
|
243
|
+
function printPrReviewResult(result) {
|
|
244
|
+
if (result.posted) {
|
|
245
|
+
console.log(`${TOOL_PREFIX} Posted PR review with ${result.findings.length} finding(s).`);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (result.reason === 'github-auth-unavailable') {
|
|
249
|
+
console.log(`${TOOL_PREFIX} GitHub auth unavailable; wrote PR review artifact: ${result.artifactPath}`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
console.log(`${TOOL_PREFIX} Wrote PR review artifact: ${result.artifactPath}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
compactReviewPrompt,
|
|
257
|
+
commandForProvider,
|
|
258
|
+
extractJsonPayload,
|
|
259
|
+
normalizeFindings,
|
|
260
|
+
renderMarkdownReview,
|
|
261
|
+
runPrReview,
|
|
262
|
+
evaluateReviewGate,
|
|
263
|
+
printPrReviewResult,
|
|
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
|
}
|