pierre-review 0.1.47 → 0.1.49

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.
@@ -1,4 +1,4 @@
1
- import { getActivity, getConsolidatedFeed, listClaudeReviewsByRepo } from '../../db/queries.js';
1
+ import { getActivity, getConsolidatedFeed, listClaudeReviewsByRepo, markFeedSeen, } from '../../db/queries.js';
2
2
  import { accountIdOf } from '../plugins/auth.js';
3
3
  function parseIntList(raw) {
4
4
  if (!raw)
@@ -35,6 +35,13 @@ export async function activityRoutes(app) {
35
35
  allowBotIds: parseIntList(q.allowBotIds),
36
36
  });
37
37
  });
38
+ // Mark the Activity Feed as seen (bumps the account's server-side "seen" marker to
39
+ // now). Called when the user views the feed; resets the "new FYI since last here"
40
+ // count that drives the Welcome-back banner. Account-scoped; no body.
41
+ app.post('/api/activity/feed/mark-seen', async (req) => {
42
+ const at = await markFeedSeen(accountIdOf(req));
43
+ return { feedLastSeenAt: at.toISOString() };
44
+ });
38
45
  // Repo-scoped Claude-review history for the Activity single-repo console. Ownership +
39
46
  // feature-gating are handled inside the query (an unowned repo / disabled feature
40
47
  // both return an empty list), so the caller never leaks another account's data.
@@ -2,7 +2,7 @@ import { config } from '../../config.js';
2
2
  import { accountToLocalUser } from '../../auth/account.js';
3
3
  import { accountIdOf } from '../plugins/auth.js';
4
4
  import { getProCapabilities } from '../../pro/contract.js';
5
- import { dismissMyTurn, getCompletedDismissals, getMyTurn, undismissMyTurn, } from '../../db/queries.js';
5
+ import { countNewMyTurnFeedItems, dismissMyTurn, getCompletedDismissals, getFeedLastSeenAt, getMyTurn, markFeedSeen, undismissMyTurn, } from '../../db/queries.js';
6
6
  const dismissSchema = {
7
7
  body: {
8
8
  type: 'object',
@@ -19,8 +19,16 @@ const dismissSchema = {
19
19
  };
20
20
  export async function meRoutes(app) {
21
21
  app.get('/api/me', async (req) => {
22
+ const accountId = accountIdOf(req);
22
23
  const user = accountToLocalUser(req.account);
23
- const myTurn = await getMyTurn(accountIdOf(req));
24
+ const myTurn = await getMyTurn(accountId);
25
+ // Feed "seen" marker + how many FYI items are new since. Only counted once a
26
+ // baseline exists (feedLastSeenAt set by the first feed view) so a fresh account
27
+ // never sees a scary first-load number.
28
+ const feedLastSeen = await getFeedLastSeenAt(accountId);
29
+ const newFeedItems = feedLastSeen
30
+ ? await countNewMyTurnFeedItems(accountId, feedLastSeen)
31
+ : 0;
24
32
  return {
25
33
  user,
26
34
  counts: {
@@ -31,6 +39,8 @@ export async function meRoutes(app) {
31
39
  watchedRepoPrs: myTurn.watchedRepoPrs.length,
32
40
  claudeReviewsToAction: myTurn.claudeReviewsToAction.length,
33
41
  },
42
+ feedLastSeenAt: feedLastSeen ? feedLastSeen.toISOString() : null,
43
+ newFeedItems,
34
44
  claudeReviewEnabled: config.claudeReviewEnabled,
35
45
  deploymentMode: config.deploymentMode,
36
46
  pro: getProCapabilities(),
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
3
- import { ghRestGetText } from '../../github/client.js';
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
- import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, submitPrReview, } from '../../github/mutations.js';
6
+ import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, rerunWorkflowRun, submitPrReview, } from '../../github/mutations.js';
7
7
  import { hydratePrDetail } from '../../sync/hydrate-detail.js';
8
8
  import { accountIdOf } from '../plugins/auth.js';
9
9
  // GitHub anchors a file in the PR "Files changed" diff by the SHA-256 of its
@@ -56,6 +56,18 @@ const markViewedSchema = {
56
56
  properties: { sha: { type: 'string' } },
57
57
  },
58
58
  };
59
+ const ciRerunSchema = {
60
+ ...idParamSchema,
61
+ body: {
62
+ type: 'object',
63
+ required: ['runId', 'mode'],
64
+ additionalProperties: false,
65
+ properties: {
66
+ runId: { type: 'integer', minimum: 1 },
67
+ mode: { type: 'string', enum: ['failed', 'all'] },
68
+ },
69
+ },
70
+ };
59
71
  const markAllViewedSchema = {
60
72
  body: {
61
73
  type: 'object',
@@ -353,41 +365,44 @@ export async function prRoutes(app) {
353
365
  reply.status(404);
354
366
  return { error: 'NotFound', message: `PR ${id} not found` };
355
367
  }
356
- const tailLines = Math.min(Math.max(tail ?? 200, 1), 1000);
357
- const unavailable = (reason) => ({
358
- available: false,
359
- reason,
360
- text: '',
361
- totalLines: 0,
362
- returnedLines: 0,
363
- });
368
+ const token = await getAccessToken(accountId);
369
+ const result = await fetchActionsJobLog(token, ctx.owner, ctx.name, jobId, tail ?? 200);
370
+ return result;
371
+ });
372
+ // Re-trigger a GitHub Actions workflow run for this PR. The `runId` comes from
373
+ // CheckRun.runId (Actions checks only). Server re-checks write access (WRITE/
374
+ // MAINTAIN/ADMIN, matching viewerCanPush — no author exclusion, unlike approve),
375
+ // then queues the rerun via the per-account token (local + cloud). GitHub runs it
376
+ // asynchronously; the refreshed check states arrive on the next sync.
377
+ app.post('/api/prs/:id/ci/rerun', { schema: ciRerunSchema }, async (req, reply) => {
378
+ const { id } = req.params;
379
+ const { runId, mode } = req.body;
380
+ const accountId = accountIdOf(req);
381
+ const ctx = await getPrWriteContext(id, accountId);
382
+ if (!ctx) {
383
+ reply.status(404);
384
+ return { error: 'NotFound', message: `PR ${id} not found` };
385
+ }
386
+ const canRerun = ['WRITE', 'MAINTAIN', 'ADMIN'].includes(ctx.viewerPermission ?? '');
387
+ if (!canRerun) {
388
+ reply.status(403);
389
+ return {
390
+ error: 'NotPermitted',
391
+ message: 'You need write access to this repo to re-run CI.',
392
+ };
393
+ }
364
394
  try {
365
395
  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
- };
396
+ await rerunWorkflowRun(token, ctx.owner, ctx.name, runId, mode);
397
+ const result = { status: 'queued', runId, mode };
387
398
  return result;
388
399
  }
389
- catch {
390
- return unavailable("Couldn't reach GitHub to fetch the logs.");
400
+ catch (err) {
401
+ reply.status(502);
402
+ return {
403
+ error: 'GitHubError',
404
+ message: err instanceof Error ? err.message : String(err),
405
+ };
391
406
  }
392
407
  });
393
408
  }
@@ -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