pierre-review 0.1.46 → 0.1.48
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/dist/api/routes/claude-review.js +58 -2
- package/dist/api/routes/prs.js +4 -37
- package/dist/coding/activity.js +78 -0
- package/dist/coding/agent.js +310 -0
- package/dist/coding/git-ops.js +76 -0
- package/dist/coding/git.js +97 -0
- package/dist/coding/merge.js +544 -0
- package/dist/coding/schema.js +24 -0
- package/dist/config.js +20 -3
- package/dist/db/queries.js +5 -0
- package/dist/github/actions-logs.js +50 -0
- package/dist/github/client.js +18 -0
- package/dist/github/mutations.js +71 -1
- package/dist/pro/bind.js +35 -2
- package/dist/pro/contract.js +6 -1
- package/dist/review/agent.js +5 -4
- package/dist/review/auth.js +69 -19
- package/dist/review/clone-manager.js +60 -12
- package/dist/review/llm.js +21 -8
- package/dist/review/local-settings.js +3 -28
- package/dist/review/review-manager.js +63 -2
- package/package.json +1 -1
- package/public/assets/index-2sMvOWW3.css +10 -0
- package/public/assets/index-tr37C2Tv.js +1390 -0
- package/public/index.html +2 -2
- package/public/assets/index-CYgBb8KO.css +0 -10
- package/public/assets/index-TYZ3EpC2.js +0 -1378
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { config } from '../../config.js';
|
|
2
2
|
import { getClaudeReviewById, getClaudeReviewContext, getFindingPostContext, getLatestClaudeReview, listAllClaudeReviews, listClaudeReviewHistory, } from '../../db/queries.js';
|
|
3
3
|
import { markFindingPosted, markReviewPosted, updateFinding, updateReviewDraft, } from '../../review/persist.js';
|
|
4
|
-
import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, } from '../../review/review-manager.js';
|
|
4
|
+
import { getReviewStatus, listActiveReviews, requestReviewCancel, startReview, subscribeReviewStream, } from '../../review/review-manager.js';
|
|
5
5
|
import { detectClaudeAuth } from '../../review/auth.js';
|
|
6
6
|
import { hasUserAnthropicKey, setUserAnthropicKey, } from '../../review/local-settings.js';
|
|
7
7
|
import { buildAnchorIndex, buildReview, fallbackAnchor, fetchCurrentHeadSha, fetchPrDiff, findingCommentBody, prLevelFindingBody, stripNoiseFromDiff, submitGithubComment, submitGithubIssueComment, submitGithubReview, } from '../../review/post-review.js';
|
|
@@ -169,7 +169,7 @@ export async function claudeReviewRoutes(app) {
|
|
|
169
169
|
reply.status(202);
|
|
170
170
|
return { reviewId: result.reviewId, status: 'queued' };
|
|
171
171
|
});
|
|
172
|
-
// Live progress poll target.
|
|
172
|
+
// Live progress poll target (kept as a fallback + for the initial snapshot).
|
|
173
173
|
app.get('/api/prs/:id/claude-review/status', { schema: idParam }, async (req) => {
|
|
174
174
|
const { id } = req.params;
|
|
175
175
|
if (!config.claudeReviewEnabled) {
|
|
@@ -177,6 +177,62 @@ export async function claudeReviewRoutes(app) {
|
|
|
177
177
|
}
|
|
178
178
|
return await getReviewStatus(id);
|
|
179
179
|
});
|
|
180
|
+
// Live progress STREAM (SSE) — pushes each phase/activity/usage change in real
|
|
181
|
+
// time and one terminal `done`, so the UI progress bar tracks the run without
|
|
182
|
+
// polling. Local-only (Claude Review is gated off in cloud). We `hijack()` the
|
|
183
|
+
// reply and own the raw socket for the connection's lifetime.
|
|
184
|
+
app.get('/api/prs/:id/claude-review/stream', { schema: idParam }, async (req, reply) => {
|
|
185
|
+
const { id } = req.params;
|
|
186
|
+
reply.hijack();
|
|
187
|
+
const raw = reply.raw;
|
|
188
|
+
raw.writeHead(200, {
|
|
189
|
+
'Content-Type': 'text/event-stream',
|
|
190
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
191
|
+
Connection: 'keep-alive',
|
|
192
|
+
// Disable proxy buffering (nginx) so frames flush immediately.
|
|
193
|
+
'X-Accel-Buffering': 'no',
|
|
194
|
+
});
|
|
195
|
+
const send = (data) => {
|
|
196
|
+
if (!raw.writableEnded)
|
|
197
|
+
raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
198
|
+
};
|
|
199
|
+
if (!config.claudeReviewEnabled) {
|
|
200
|
+
send({ type: 'done', status: 'idle', reviewId: null });
|
|
201
|
+
raw.end();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
let closed = false;
|
|
205
|
+
let unsubscribe = null;
|
|
206
|
+
const heartbeat = setInterval(() => {
|
|
207
|
+
if (!raw.writableEnded)
|
|
208
|
+
raw.write(': hb\n\n');
|
|
209
|
+
}, 15000);
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
if (closed)
|
|
212
|
+
return;
|
|
213
|
+
closed = true;
|
|
214
|
+
clearInterval(heartbeat);
|
|
215
|
+
unsubscribe?.();
|
|
216
|
+
if (!raw.writableEnded)
|
|
217
|
+
raw.end();
|
|
218
|
+
};
|
|
219
|
+
// Subscribe BEFORE reading the snapshot so a terminal `done` emitted during
|
|
220
|
+
// setup can't slip through the gap (it'd be delivered to this callback). If
|
|
221
|
+
// the run already finished, the snapshot check below sends `done` itself.
|
|
222
|
+
unsubscribe = subscribeReviewStream(id, (e) => {
|
|
223
|
+
send(e);
|
|
224
|
+
if (e.type === 'done')
|
|
225
|
+
cleanup();
|
|
226
|
+
});
|
|
227
|
+
req.raw.on('close', cleanup);
|
|
228
|
+
// Initial snapshot so a client that connects mid-run gets current state at once.
|
|
229
|
+
const snap = await getReviewStatus(id);
|
|
230
|
+
send({ type: 'snapshot', ...snap });
|
|
231
|
+
if (snap.status !== 'running' && snap.status !== 'queued') {
|
|
232
|
+
send({ type: 'done', status: snap.status, reviewId: snap.reviewId });
|
|
233
|
+
cleanup();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
180
236
|
app.post('/api/prs/:id/claude-review/cancel', { schema: idParam }, async (req, reply) => {
|
|
181
237
|
const { id } = req.params;
|
|
182
238
|
if (!config.claudeReviewEnabled)
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { getAccessToken, getAccountUserId } from '../../auth/account.js';
|
|
3
|
-
import {
|
|
3
|
+
import { fetchActionsJobLog } from '../../github/actions-logs.js';
|
|
4
4
|
import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
5
5
|
import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
|
|
6
6
|
import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, submitPrReview, } from '../../github/mutations.js';
|
|
@@ -353,42 +353,9 @@ export async function prRoutes(app) {
|
|
|
353
353
|
reply.status(404);
|
|
354
354
|
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
355
355
|
}
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
reason,
|
|
360
|
-
text: '',
|
|
361
|
-
totalLines: 0,
|
|
362
|
-
returnedLines: 0,
|
|
363
|
-
});
|
|
364
|
-
try {
|
|
365
|
-
const token = await getAccessToken(accountId);
|
|
366
|
-
const res = await ghRestGetText(token, `/repos/${ctx.owner}/${ctx.name}/actions/jobs/${jobId}/logs`);
|
|
367
|
-
if (!res.ok) {
|
|
368
|
-
const reason = res.status === 404 || res.status === 410
|
|
369
|
-
? 'Logs are no longer available (expired, or the job was re-run).'
|
|
370
|
-
: res.status === 403
|
|
371
|
-
? 'No permission to read GitHub Actions logs for this repo.'
|
|
372
|
-
: `Couldn't fetch logs (GitHub returned ${res.status}).`;
|
|
373
|
-
return unavailable(reason);
|
|
374
|
-
}
|
|
375
|
-
// A job log can be many MB — normalise line endings, drop a trailing blank,
|
|
376
|
-
// and tail server-side so only the last N lines reach the browser. Guard the
|
|
377
|
-
// empty/blank body so it reports 0 lines (not [''] → a misleading "1 of 1").
|
|
378
|
-
const trimmed = res.text.replace(/\r\n/g, '\n').replace(/\n+$/, '');
|
|
379
|
-
const lines = trimmed === '' ? [] : trimmed.split('\n');
|
|
380
|
-
const tailed = lines.slice(-tailLines);
|
|
381
|
-
const result = {
|
|
382
|
-
available: true,
|
|
383
|
-
text: tailed.join('\n'),
|
|
384
|
-
totalLines: lines.length,
|
|
385
|
-
returnedLines: tailed.length,
|
|
386
|
-
};
|
|
387
|
-
return result;
|
|
388
|
-
}
|
|
389
|
-
catch {
|
|
390
|
-
return unavailable("Couldn't reach GitHub to fetch the logs.");
|
|
391
|
-
}
|
|
356
|
+
const token = await getAccessToken(accountId);
|
|
357
|
+
const result = await fetchActionsJobLog(token, ctx.owner, ctx.name, jobId, tail ?? 200);
|
|
358
|
+
return result;
|
|
392
359
|
});
|
|
393
360
|
}
|
|
394
361
|
//# sourceMappingURL=prs.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Live progress derivation for the coding agent — turns each assistant turn into a
|
|
2
|
+
// few short, human-readable lines (tool labels + clipped text). Mirrors the private
|
|
3
|
+
// helpers in review/agent.ts but understands the WRITE tools (Write/Edit/MultiEdit)
|
|
4
|
+
// the fixer uses. Defensive throughout: the SDK content/tool-input shapes vary, so
|
|
5
|
+
// every access is guarded and this never throws.
|
|
6
|
+
const TEXT_SNIPPET_CAP = 120;
|
|
7
|
+
const ARG_CAP = 80;
|
|
8
|
+
export function describeAssistantBlocks(message) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
const content = message?.message
|
|
11
|
+
?.content;
|
|
12
|
+
if (!Array.isArray(content))
|
|
13
|
+
return lines;
|
|
14
|
+
for (const raw of content) {
|
|
15
|
+
if (!raw || typeof raw !== 'object')
|
|
16
|
+
continue;
|
|
17
|
+
const block = raw;
|
|
18
|
+
if (block.type === 'tool_use') {
|
|
19
|
+
const name = typeof block.name === 'string' ? block.name : 'Tool';
|
|
20
|
+
const input = block.input && typeof block.input === 'object'
|
|
21
|
+
? block.input
|
|
22
|
+
: {};
|
|
23
|
+
lines.push(labelToolUse(name, input));
|
|
24
|
+
}
|
|
25
|
+
else if (block.type === 'text' && typeof block.text === 'string') {
|
|
26
|
+
const snippet = clip(block.text.replace(/\s+/g, ' ').trim(), TEXT_SNIPPET_CAP);
|
|
27
|
+
if (snippet)
|
|
28
|
+
lines.push(snippet);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return lines;
|
|
32
|
+
}
|
|
33
|
+
function labelToolUse(name, input) {
|
|
34
|
+
const str = (v) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : null;
|
|
35
|
+
switch (name) {
|
|
36
|
+
case 'Read': {
|
|
37
|
+
const p = str(input.file_path) ?? str(input.path);
|
|
38
|
+
return p ? `Read ${p}` : 'Read …';
|
|
39
|
+
}
|
|
40
|
+
case 'Write': {
|
|
41
|
+
const p = str(input.file_path) ?? str(input.path);
|
|
42
|
+
return p ? `Write ${p}` : 'Write …';
|
|
43
|
+
}
|
|
44
|
+
case 'Edit':
|
|
45
|
+
case 'MultiEdit': {
|
|
46
|
+
const p = str(input.file_path) ?? str(input.path);
|
|
47
|
+
return p ? `Edit ${p}` : 'Edit …';
|
|
48
|
+
}
|
|
49
|
+
case 'Glob': {
|
|
50
|
+
const p = str(input.pattern);
|
|
51
|
+
return p ? `Glob ${p}` : 'Glob …';
|
|
52
|
+
}
|
|
53
|
+
case 'Grep': {
|
|
54
|
+
const p = str(input.pattern);
|
|
55
|
+
return p ? `Grep "${clip(p, ARG_CAP)}"` : 'Grep …';
|
|
56
|
+
}
|
|
57
|
+
case 'Bash': {
|
|
58
|
+
const c = str(input.command);
|
|
59
|
+
return c ? `Bash ${clip(c, ARG_CAP)}` : 'Bash …';
|
|
60
|
+
}
|
|
61
|
+
case 'mcp__fix__submit_fix':
|
|
62
|
+
return 'Recording fix summary…';
|
|
63
|
+
case 'mcp__resolve__submit_resolution':
|
|
64
|
+
return 'Recording resolution…';
|
|
65
|
+
default: {
|
|
66
|
+
for (const key of Object.keys(input)) {
|
|
67
|
+
const v = str(input[key]);
|
|
68
|
+
if (v)
|
|
69
|
+
return `${name} ${clip(v, ARG_CAP)}`;
|
|
70
|
+
}
|
|
71
|
+
return `${name} …`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function clip(s, max) {
|
|
76
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=activity.js.map
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
import { getAccessToken } from '../auth/account.js';
|
|
4
|
+
import { cleanupCloneCache, prepWorktree, removeWorktreeLocked, } from '../review/clone-manager.js';
|
|
5
|
+
import { estimateCostUsd } from '../review/pricing.js';
|
|
6
|
+
import { recordUsage, sumModelUsage, sumUsageMap, } from '../review/usage.js';
|
|
7
|
+
import { submitFixShape, submitResolutionShape, } from './schema.js';
|
|
8
|
+
import { captureWorktreeDiff } from './git.js';
|
|
9
|
+
import { describeAssistantBlocks } from './activity.js';
|
|
10
|
+
// The WRITE tool surface for the fixer: the read tools PLUS Write/Edit/MultiEdit, and
|
|
11
|
+
// the submit_fix MCP tool for the prose summary. The agent NEVER runs git writes — the
|
|
12
|
+
// host commits/pushes — so git commit/push/config stay denied (as do rm/sudo).
|
|
13
|
+
const FIX_TOOLS = [
|
|
14
|
+
'Read',
|
|
15
|
+
'Glob',
|
|
16
|
+
'Grep',
|
|
17
|
+
'Bash',
|
|
18
|
+
'Write',
|
|
19
|
+
'Edit',
|
|
20
|
+
'MultiEdit',
|
|
21
|
+
'mcp__fix__submit_fix',
|
|
22
|
+
];
|
|
23
|
+
const DISALLOWED_TOOLS = [
|
|
24
|
+
'NotebookEdit',
|
|
25
|
+
'Bash(rm *)',
|
|
26
|
+
'Bash(sudo *)',
|
|
27
|
+
'Bash(git push *)',
|
|
28
|
+
'Bash(git commit *)',
|
|
29
|
+
'Bash(git config *)',
|
|
30
|
+
];
|
|
31
|
+
// The conflict-resolver's tool surface is deliberately NARROWER: no Bash at all, so it
|
|
32
|
+
// cannot tamper with the in-progress merge/rebase (no `git add`/`--continue`/`--abort`).
|
|
33
|
+
// It only reads + edits the conflicted files and reports via submit_resolution; the
|
|
34
|
+
// host stages and continues the operation.
|
|
35
|
+
const RESOLVE_TOOLS = [
|
|
36
|
+
'Read',
|
|
37
|
+
'Glob',
|
|
38
|
+
'Grep',
|
|
39
|
+
'Write',
|
|
40
|
+
'Edit',
|
|
41
|
+
'MultiEdit',
|
|
42
|
+
'mcp__resolve__submit_resolution',
|
|
43
|
+
];
|
|
44
|
+
const RESOLVE_DISALLOWED_TOOLS = ['Bash', 'NotebookEdit'];
|
|
45
|
+
// Models that accept the `effort` option (Haiku 4.5 rejects it — the API 400s).
|
|
46
|
+
const EFFORT_CAPABLE_MODELS = new Set([
|
|
47
|
+
'claude-opus-4-8',
|
|
48
|
+
'claude-sonnet-4-6',
|
|
49
|
+
]);
|
|
50
|
+
const ACTIVITY_LOG_CAP = 25;
|
|
51
|
+
function emptyUsage() {
|
|
52
|
+
return {
|
|
53
|
+
inputTokens: 0,
|
|
54
|
+
outputTokens: 0,
|
|
55
|
+
cacheReadTokens: 0,
|
|
56
|
+
cacheCreationTokens: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The shared Claude Agent SDK run core used by BOTH the fixer and the conflict
|
|
61
|
+
* resolver: it OWNS the sandbox config (`permissionMode:'bypassPermissions'`,
|
|
62
|
+
* `settingSources:[]`, budget/turn caps, whitelisted tools) and streams activity, but
|
|
63
|
+
* knows nothing about worktree prep, diff capture, or git — the callers own those.
|
|
64
|
+
*
|
|
65
|
+
* Auth: runs on the AMBIENT Claude env WITHOUT mutating process.env (unlike Claude
|
|
66
|
+
* Review's applyClaudeReviewAuth) — the AI-Fix jobs are concurrency 1, and mutating
|
|
67
|
+
* env would race a concurrent review manager. Whatever credential is ambient is used.
|
|
68
|
+
*/
|
|
69
|
+
export async function runAgentInWorktree(opts) {
|
|
70
|
+
const model = opts.model;
|
|
71
|
+
const usageByUuid = new Map();
|
|
72
|
+
let result = null;
|
|
73
|
+
let maxTurns = opts.maxTurns;
|
|
74
|
+
if (model === 'claude-haiku-4-5') {
|
|
75
|
+
maxTurns = Math.ceil(maxTurns * config.reviewHaikuTurnMultiplier);
|
|
76
|
+
}
|
|
77
|
+
const effort = EFFORT_CAPABLE_MODELS.has(model)
|
|
78
|
+
? config.reviewEffort
|
|
79
|
+
: undefined;
|
|
80
|
+
const q = query({
|
|
81
|
+
prompt: opts.prompt,
|
|
82
|
+
options: {
|
|
83
|
+
model,
|
|
84
|
+
...(effort ? { effort } : {}),
|
|
85
|
+
...(opts.systemPrompt ? { systemPrompt: opts.systemPrompt } : {}),
|
|
86
|
+
cwd: opts.worktreePath,
|
|
87
|
+
permissionMode: 'bypassPermissions',
|
|
88
|
+
allowedTools: opts.allowedTools,
|
|
89
|
+
disallowedTools: opts.disallowedTools,
|
|
90
|
+
maxTurns,
|
|
91
|
+
maxBudgetUsd: opts.maxBudgetUsd,
|
|
92
|
+
settingSources: [],
|
|
93
|
+
mcpServers: opts.mcpServers,
|
|
94
|
+
abortController: opts.abortController,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
const activity = [];
|
|
98
|
+
const pushActivity = (line) => {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
if (!trimmed)
|
|
101
|
+
return;
|
|
102
|
+
activity.push(trimmed);
|
|
103
|
+
if (activity.length > ACTIVITY_LOG_CAP)
|
|
104
|
+
activity.shift();
|
|
105
|
+
};
|
|
106
|
+
for await (const message of q) {
|
|
107
|
+
if (message.type === 'assistant') {
|
|
108
|
+
try {
|
|
109
|
+
recordUsage(usageByUuid, message);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* usage shape varies — never let it break the run */
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
for (const line of describeAssistantBlocks(message))
|
|
116
|
+
pushActivity(line);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* never let progress derivation break the run */
|
|
120
|
+
}
|
|
121
|
+
const live = sumUsageMap(usageByUuid);
|
|
122
|
+
opts.onActivity?.([...activity], {
|
|
123
|
+
...live,
|
|
124
|
+
estCostUsd: estimateCostUsd(model, live),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (message.type === 'result') {
|
|
128
|
+
result = message;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (opts.abortController.signal.aborted) {
|
|
132
|
+
return {
|
|
133
|
+
result,
|
|
134
|
+
usage: emptyUsage(),
|
|
135
|
+
costUsd: null,
|
|
136
|
+
numTurns: null,
|
|
137
|
+
aborted: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const usage = sumModelUsage(result) ?? sumUsageMap(usageByUuid);
|
|
141
|
+
const hasUsage = usage.inputTokens + usage.outputTokens + usage.cacheReadTokens > 0;
|
|
142
|
+
const costUsd = result?.total_cost_usd ?? (hasUsage ? estimateCostUsd(model, usage) : null);
|
|
143
|
+
return {
|
|
144
|
+
result,
|
|
145
|
+
usage: {
|
|
146
|
+
inputTokens: usage.inputTokens,
|
|
147
|
+
outputTokens: usage.outputTokens,
|
|
148
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
149
|
+
cacheCreationTokens: usage.cacheCreationTokens,
|
|
150
|
+
},
|
|
151
|
+
costUsd,
|
|
152
|
+
numTurns: result?.num_turns ?? null,
|
|
153
|
+
aborted: false,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Run the write-capable coding agent against a PR's head in an ephemeral worktree and
|
|
158
|
+
* return the captured changeset (a binary-safe unified diff) + a prose summary. This
|
|
159
|
+
* is the implementation behind ctx.coding.generateFix; it owns the worktree prep +
|
|
160
|
+
* diff capture + teardown, and delegates the sandboxed SDK run to runAgentInWorktree.
|
|
161
|
+
* Never commits or pushes — the host's git-ops does that later from the STORED patch.
|
|
162
|
+
*/
|
|
163
|
+
export async function runCodingAgent(args) {
|
|
164
|
+
const { owner, name, prNumber, baseSha, abortController, onProgress } = args;
|
|
165
|
+
const model = args.model;
|
|
166
|
+
const token = await getAccessToken(args.accountId);
|
|
167
|
+
let repoCloneDir = null;
|
|
168
|
+
let worktreePath = null;
|
|
169
|
+
try {
|
|
170
|
+
onProgress({ phase: 'cloning' });
|
|
171
|
+
({ repoCloneDir, worktreePath } = await prepWorktree(owner, name, prNumber, baseSha, token));
|
|
172
|
+
let captured = null;
|
|
173
|
+
const server = createSdkMcpServer({
|
|
174
|
+
name: 'fix',
|
|
175
|
+
version: '1.0.0',
|
|
176
|
+
tools: [
|
|
177
|
+
tool('submit_fix', 'Report the fix you applied. Call this EXACTLY once, at the very end, after editing files.', submitFixShape, async (a) => {
|
|
178
|
+
const p = a;
|
|
179
|
+
captured = { summary: p.summary, commitMessage: p.commitMessage };
|
|
180
|
+
return { content: [{ type: 'text', text: 'Fix recorded.' }] };
|
|
181
|
+
}),
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
onProgress({ phase: 'fixing' });
|
|
185
|
+
const outcome = await runAgentInWorktree({
|
|
186
|
+
worktreePath,
|
|
187
|
+
model,
|
|
188
|
+
systemPrompt: args.systemPrompt,
|
|
189
|
+
prompt: args.prompt,
|
|
190
|
+
allowedTools: FIX_TOOLS,
|
|
191
|
+
disallowedTools: DISALLOWED_TOOLS,
|
|
192
|
+
mcpServers: { fix: server },
|
|
193
|
+
maxTurns: args.maxTurns ?? config.aiFixMaxTurns,
|
|
194
|
+
maxBudgetUsd: args.maxBudgetUsd ?? config.aiFixBudgetUsd,
|
|
195
|
+
abortController,
|
|
196
|
+
onActivity: (activity, usage) => onProgress({ phase: 'fixing', recentActivity: activity, usage }),
|
|
197
|
+
});
|
|
198
|
+
// A user cancel aborts the SDK iterator; surface a clean aborted result (the
|
|
199
|
+
// manager marks the row cancelled — no worktree changes are captured).
|
|
200
|
+
if (outcome.aborted || abortController.signal.aborted) {
|
|
201
|
+
return {
|
|
202
|
+
summary: '',
|
|
203
|
+
commitMessage: '',
|
|
204
|
+
patch: '',
|
|
205
|
+
filesChanged: [],
|
|
206
|
+
baseSha,
|
|
207
|
+
usage: emptyUsage(),
|
|
208
|
+
costUsd: null,
|
|
209
|
+
numTurns: null,
|
|
210
|
+
aborted: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
onProgress({ phase: 'capturing' });
|
|
214
|
+
const { patch, filesChanged } = await captureWorktreeDiff(worktreePath);
|
|
215
|
+
if (patch.length > config.aiFixPatchMaxBytes) {
|
|
216
|
+
throw new Error(`fix patch too large (${patch.length} bytes > ${config.aiFixPatchMaxBytes})`);
|
|
217
|
+
}
|
|
218
|
+
// A cast so the closure-assigned `captured` reads back as its declared union.
|
|
219
|
+
// (TS flow-narrows it to `null` because its only assignment is inside the tool
|
|
220
|
+
// callback, which it can't prove ran — a plain annotation wouldn't break that.)
|
|
221
|
+
const fix = captured;
|
|
222
|
+
return {
|
|
223
|
+
summary: fix?.summary ??
|
|
224
|
+
(filesChanged.length
|
|
225
|
+
? `Applied changes to ${filesChanged.length} file(s).`
|
|
226
|
+
: 'The agent made no changes.'),
|
|
227
|
+
commitMessage: fix?.commitMessage ?? 'AI fix',
|
|
228
|
+
patch,
|
|
229
|
+
filesChanged,
|
|
230
|
+
baseSha,
|
|
231
|
+
usage: outcome.usage,
|
|
232
|
+
costUsd: outcome.costUsd,
|
|
233
|
+
numTurns: outcome.numTurns,
|
|
234
|
+
aborted: false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
if (repoCloneDir && worktreePath) {
|
|
239
|
+
await removeWorktreeLocked(owner, name, repoCloneDir, worktreePath).catch(() => { });
|
|
240
|
+
}
|
|
241
|
+
if (repoCloneDir) {
|
|
242
|
+
setImmediate(() => {
|
|
243
|
+
try {
|
|
244
|
+
cleanupCloneCache();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
/* advisory cleanup — never surface */
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Run the conflict-resolution agent in an already-prepared, mid-merge/rebase worktree.
|
|
255
|
+
* The agent edits the conflicted files to remove every marker (preserving both sides'
|
|
256
|
+
* intent) and reports via submit_resolution. It has NO Bash/git access, so it cannot
|
|
257
|
+
* touch the in-progress operation — the host stages + continues + verifies afterward.
|
|
258
|
+
*/
|
|
259
|
+
export async function runConflictResolver(args) {
|
|
260
|
+
let captured = null;
|
|
261
|
+
const server = createSdkMcpServer({
|
|
262
|
+
name: 'resolve',
|
|
263
|
+
version: '1.0.0',
|
|
264
|
+
tools: [
|
|
265
|
+
tool('submit_resolution', 'Report how you resolved the conflicts. Call this EXACTLY once, at the very end, after every conflict marker is gone.', submitResolutionShape, async (a) => {
|
|
266
|
+
const p = a;
|
|
267
|
+
captured = { summary: p.summary };
|
|
268
|
+
return { content: [{ type: 'text', text: 'Resolution recorded.' }] };
|
|
269
|
+
}),
|
|
270
|
+
],
|
|
271
|
+
});
|
|
272
|
+
const fileList = args.conflictFiles.map((f) => `- ${f}`).join('\n');
|
|
273
|
+
const prompt = [
|
|
274
|
+
'You are resolving Git merge/rebase conflicts in this repository.',
|
|
275
|
+
'The working tree has conflict markers (<<<<<<<, =======, >>>>>>>) in these files:',
|
|
276
|
+
fileList || '(git reports conflicted files)',
|
|
277
|
+
'',
|
|
278
|
+
args.contextNote ? `Context: ${args.contextNote}` : '',
|
|
279
|
+
'',
|
|
280
|
+
'For EACH conflicted file: open it, resolve every conflict by preserving the',
|
|
281
|
+
'intent of BOTH sides (combine them correctly — do not just pick one blindly',
|
|
282
|
+
'unless one side is clearly obsolete), and remove ALL conflict markers. Do not',
|
|
283
|
+
'change code outside the conflict regions. Do not run any git commands.',
|
|
284
|
+
'When every marker in every file is gone, call submit_resolution with a short',
|
|
285
|
+
'summary of how you reconciled them.',
|
|
286
|
+
]
|
|
287
|
+
.filter((l) => l !== '')
|
|
288
|
+
.join('\n');
|
|
289
|
+
const outcome = await runAgentInWorktree({
|
|
290
|
+
worktreePath: args.worktreePath,
|
|
291
|
+
model: args.model,
|
|
292
|
+
systemPrompt: args.systemPrompt,
|
|
293
|
+
prompt,
|
|
294
|
+
allowedTools: RESOLVE_TOOLS,
|
|
295
|
+
disallowedTools: RESOLVE_DISALLOWED_TOOLS,
|
|
296
|
+
mcpServers: { resolve: server },
|
|
297
|
+
maxTurns: args.maxTurns ?? config.aiFixMaxTurns,
|
|
298
|
+
maxBudgetUsd: args.maxBudgetUsd ?? config.aiFixBudgetUsd,
|
|
299
|
+
abortController: args.abortController,
|
|
300
|
+
onActivity: (activity) => args.onActivity?.(activity),
|
|
301
|
+
});
|
|
302
|
+
const res = captured;
|
|
303
|
+
return {
|
|
304
|
+
summary: res?.summary ?? 'Resolved merge conflicts.',
|
|
305
|
+
usage: outcome.usage,
|
|
306
|
+
costUsd: outcome.costUsd,
|
|
307
|
+
aborted: outcome.aborted || args.abortController.signal.aborted,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=agent.js.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { getAccessToken, getAccountById } from '../auth/account.js';
|
|
2
|
+
import { cleanupCloneCache, prepWorktree, removeWorktreeLocked, } from '../review/clone-manager.js';
|
|
3
|
+
import { createPullRequest, fetchPrHeadInfo, } from '../github/mutations.js';
|
|
4
|
+
import { applyPatchToWorktree, codedError, commitAll, pushRef, } from './git.js';
|
|
5
|
+
// The implementation behind ctx.coding.applyAndPush. STATELESS: it re-preps a fresh
|
|
6
|
+
// worktree at the patch's exact base commit and `git apply`s the stored patch, so a
|
|
7
|
+
// fix survives a restart and works on ephemeral cloud disk. Owns the head-moved guard
|
|
8
|
+
// (existing-branch only) and does all git writes (the agent never can).
|
|
9
|
+
export async function applyAndPush(args) {
|
|
10
|
+
const { accountId, owner, name, prNumber, baseSha, patch, commitMessage, target } = args;
|
|
11
|
+
const token = await getAccessToken(accountId);
|
|
12
|
+
const account = await getAccountById(accountId);
|
|
13
|
+
const authorName = account?.displayName || account?.githubLogin || 'pierre-review';
|
|
14
|
+
const authorEmail = account?.githubLogin
|
|
15
|
+
? `${account.githubLogin}@users.noreply.github.com`
|
|
16
|
+
: 'pierre-review@users.noreply.github.com';
|
|
17
|
+
// Head-moved / un-pushable-fork guard applies ONLY to the existing-branch path
|
|
18
|
+
// (pushing onto the PR's own head). A new branch is self-contained off baseSha and
|
|
19
|
+
// is immune to the head having advanced.
|
|
20
|
+
if (target.kind === 'existing') {
|
|
21
|
+
const info = await fetchPrHeadInfo(token, owner, name, prNumber);
|
|
22
|
+
if (info.headSha !== baseSha) {
|
|
23
|
+
throw codedError('HEAD_MOVED', `the PR head advanced (now ${info.headSha.slice(0, 7)}, fix was built on ${baseSha.slice(0, 7)})`);
|
|
24
|
+
}
|
|
25
|
+
if (info.isFork && !info.maintainerCanModify) {
|
|
26
|
+
throw codedError('PUSH_DENIED', 'the PR head is a fork this account cannot push to — push to a new branch instead');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let repoCloneDir = null;
|
|
30
|
+
let worktreePath = null;
|
|
31
|
+
try {
|
|
32
|
+
({ repoCloneDir, worktreePath } = await prepWorktree(owner, name, prNumber, baseSha, token));
|
|
33
|
+
await applyPatchToWorktree(worktreePath, patch);
|
|
34
|
+
const commitSha = await commitAll(worktreePath, {
|
|
35
|
+
message: commitMessage,
|
|
36
|
+
authorName,
|
|
37
|
+
authorEmail,
|
|
38
|
+
});
|
|
39
|
+
if (target.kind === 'existing') {
|
|
40
|
+
await pushRef(worktreePath, owner, name, token, 'HEAD', target.headRef);
|
|
41
|
+
return { pushedBranch: target.headRef, commitSha };
|
|
42
|
+
}
|
|
43
|
+
// New branch → push then open a PR against the base.
|
|
44
|
+
await pushRef(worktreePath, owner, name, token, 'HEAD', target.branch);
|
|
45
|
+
const pr = await createPullRequest(token, {
|
|
46
|
+
owner,
|
|
47
|
+
name,
|
|
48
|
+
head: target.branch,
|
|
49
|
+
base: target.base,
|
|
50
|
+
title: target.title,
|
|
51
|
+
body: target.body,
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
pushedBranch: target.branch,
|
|
55
|
+
commitSha,
|
|
56
|
+
prNumber: pr.number,
|
|
57
|
+
prUrl: pr.url,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (repoCloneDir && worktreePath) {
|
|
62
|
+
await removeWorktreeLocked(owner, name, repoCloneDir, worktreePath).catch(() => { });
|
|
63
|
+
}
|
|
64
|
+
if (repoCloneDir) {
|
|
65
|
+
setImmediate(() => {
|
|
66
|
+
try {
|
|
67
|
+
cleanupCloneCache();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* advisory cleanup — never surface */
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=git-ops.js.map
|