@motivation-labs/crosscheck 0.13.0 → 0.14.0-beta.10

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.
@@ -8,6 +8,7 @@ import { runFixStep } from '../reviewers/fix.js';
8
8
  import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
9
9
  import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING } from '../lib/verdict.js';
10
10
  import { createGithubClient, postReviewComment, getLastCrossCheckCommentId } from '../github/client.js';
11
+ import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js';
11
12
  import { log as fileLog, logError } from '../lib/logger.js';
12
13
  import { loadWorkflow, evaluateWhen } from '../lib/workflow.js';
13
14
  const MAX_CROSSCHECK_COMMITS = 5;
@@ -98,443 +99,494 @@ export async function runWorkflow(ctx) {
98
99
  const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange } = ctx;
99
100
  const steps = ctx.steps ?? loadWorkflow(process.cwd());
100
101
  const results = {};
101
- for (const step of steps) {
102
- const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
103
- if (exceedsMaxRounds(effectiveType, step.type, step.max_rounds, ctx.round)) {
104
- fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
105
- results[step.name] = { skipped: true };
106
- if (effectiveType === 'fix')
107
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
108
- else if (effectiveType === 'recheck')
109
- onPhaseChange('', { phase: 'rechecked' });
110
- else if (effectiveType === 'conflict-resolve')
111
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
112
- continue;
113
- }
114
- // Evaluate when condition skip step if false
115
- if (step.when && !evaluateWhen(step.when, results)) {
116
- fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'when_condition' });
117
- results[step.name] = { skipped: true };
118
- if (effectiveType === 'fix')
119
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
120
- else if (effectiveType === 'recheck')
121
- onPhaseChange('', { phase: 'rechecked' });
122
- else if (effectiveType === 'conflict-resolve')
123
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
124
- continue;
125
- }
126
- if (effectiveType === 'review' || effectiveType === 'recheck') {
127
- const isRecheck = effectiveType === 'recheck';
128
- const reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
129
- if (!reviewer) {
130
- fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
102
+ // SHAs the workflow pushed AND set a `crosscheck/review` pending status on.
103
+ // Each one must be released in the finally below — otherwise the pending
104
+ // status would stay forever on GitHub (the 15-min staleness check is
105
+ // internal to crosscheck's lock detection and does not clear the status,
106
+ // which can block PRs in repos where `crosscheck/review` is required).
107
+ //
108
+ // Use the caller's array if provided so the command-layer signal handler
109
+ // can iterate the same list and release these shas if SIGINT/SIGTERM fires
110
+ // mid-workflow (process.exit there bypasses our finally below).
111
+ const pushedShasNeedingRelease = ctx.pushedShas ?? [];
112
+ let workflowFailed = false;
113
+ try {
114
+ for (const step of steps) {
115
+ const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
116
+ if (exceedsMaxRounds(effectiveType, step.type, step.max_rounds, ctx.round)) {
117
+ fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
131
118
  results[step.name] = { skipped: true };
119
+ if (effectiveType === 'fix')
120
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
121
+ else if (effectiveType === 'recheck')
122
+ onPhaseChange('', { phase: 'rechecked' });
123
+ else if (effectiveType === 'conflict-resolve')
124
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
132
125
  continue;
133
126
  }
134
- fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...(ctx.round !== undefined && { round: ctx.round }) });
135
- const startPhase = isRecheck ? 'rechecking' : 'reviewing';
136
- const donePhase = isRecheck ? 'rechecked' : 'reviewed';
137
- onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
138
- let rawReview;
139
- let tokensUsed;
140
- if (reviewer === 'codex') {
141
- ;
142
- ({ review: rawReview, tokensUsed } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions));
143
- }
144
- else {
145
- ;
146
- ({ review: rawReview, tokensUsed } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions));
147
- }
148
- const { verdict, clean } = parseVerdict(rawReview);
149
- if (verdict === null) {
150
- fileLog({ level: 'warn', event: 'verdict_parse_failed', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, output_length: rawReview.length });
151
- }
152
- const commentBody = verdict === null
153
- ? `${NULL_VERDICT_WARNING}\n\n${clean}`
154
- : prependVerdictToComment(clean, verdict);
155
- const commentCount = countComments(rawReview);
156
- fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, verdict, duration_ms: Date.now() - ctx.reviewStart, tokens_used: tokensUsed, ...(ctx.round !== undefined && { round: ctx.round }) });
157
- // Recheck verdict is stored separately to preserve the original review's commentCount on the board
158
- const phaseUpdate = isRecheck
159
- ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: config.quality.tier }
160
- : { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier: config.quality.tier };
161
- if (ctx.dryRun) {
162
- onPhaseChange('dry-run — comment not posted', phaseUpdate);
163
- log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
164
- results[step.name] = { verdict, commentBody };
165
- }
166
- else {
167
- onPhaseChange(isRecheck ? 'posting recheck...' : 'posting comment...', phaseUpdate);
168
- const octokit = createGithubClient(token);
169
- // For rechecks: look up the original review comment ID so the recheck
170
- // can link back to it. Check in-run results first (single-run pipelines),
171
- // then fall back to GitHub (cross-run: recheck triggered by a new push).
172
- let priorReviewId;
173
- if (isRecheck) {
174
- priorReviewId = Object.values(results).reverse().find(r => r.commentId !== undefined)?.commentId;
175
- if (priorReviewId === undefined) {
176
- priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
177
- }
178
- }
179
- const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck);
180
- const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
181
- fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
182
- results[step.name] = { verdict, commentBody, commentUrl, commentId };
183
- }
184
- }
185
- else if (effectiveType === 'fix') {
186
- const skipFix = (reason) => {
187
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
127
+ // Evaluate when condition skip step if false
128
+ if (step.when && !evaluateWhen(step.when, results)) {
129
+ fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'when_condition' });
188
130
  results[step.name] = { skipped: true };
189
- fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
190
- };
191
- if (ctx.dryRun) {
192
- skipFix('dry_run');
193
- continue;
194
- }
195
- // Migration gate: honor legacy opt-out fields while users migrate to workflow.yml.
196
- const legacyDisabled = config.post_review.auto_fix.enabled === false
197
- || config.post_review.auto_fix.trigger === 'never';
198
- if (legacyDisabled) {
199
- log(chalk.yellow(`⚠ auto_fix.enabled/trigger are deprecated — remove them from config and add a "when:" condition to the fix step in workflow.yml instead`));
200
- skipFix('legacy_auto_fix_disabled');
201
- continue;
202
- }
203
- // Find the most recent review result that has a comment body
204
- const reviewResult = Object.values(results).reverse().find(r => r.commentBody);
205
- if (!reviewResult?.commentBody) {
206
- skipFix('no_review_comment');
207
- continue;
208
- }
209
- // Vendor is resolved from the workflow step's reviewer field, same as review/recheck steps.
210
- // Use 'origin' to fix with the same vendor that authored the PR (recommended default).
211
- const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
212
- if (!vendor) {
213
- skipFix('no_vendor');
214
- continue;
215
- }
216
- // Codex fix not yet implemented — skip gracefully
217
- if (vendor === 'codex') {
218
- skipFix('codex_fix_unsupported');
219
- continue;
220
- }
221
- // Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
222
- // Scope to commits ahead of base so long-lived branches (e.g. staging)
223
- // don't count [crosscheck] commits from previously merged PRs.
224
- const existingCount = countCrosscheckCommitsForPR(tmpDir, pr.base.ref);
225
- if (existingCount >= MAX_CROSSCHECK_COMMITS) {
226
- log(chalk.yellow(`⚠ PR #${prNumber}: ${MAX_CROSSCHECK_COMMITS} [crosscheck] commits already — stopping auto-fix`));
227
- skipFix('commit_limit_reached');
131
+ if (effectiveType === 'fix')
132
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
133
+ else if (effectiveType === 'recheck')
134
+ onPhaseChange('', { phase: 'rechecked' });
135
+ else if (effectiveType === 'conflict-resolve')
136
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
228
137
  continue;
229
138
  }
230
- onPhaseChange(`${vendor} fixing...`, { phase: 'fixing' });
231
- let appliedCount = 0;
232
- let fixTokensUsed;
233
- let fixErr = undefined;
234
- try {
235
- ;
236
- ({ appliedCount, tokensUsed: fixTokensUsed } = await runFixStep(tmpDir, pr.base.ref, pr.title, reviewResult.commentBody, step.instructions ?? '', config));
237
- }
238
- catch (err) {
239
- logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1 }, err);
240
- fixErr = err;
139
+ if (effectiveType === 'review' || effectiveType === 'recheck') {
140
+ const isRecheck = effectiveType === 'recheck';
141
+ const reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
142
+ if (!reviewer) {
143
+ fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
144
+ results[step.name] = { skipped: true };
145
+ continue;
146
+ }
147
+ fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...(ctx.round !== undefined && { round: ctx.round }) });
148
+ const startPhase = isRecheck ? 'rechecking' : 'reviewing';
149
+ const donePhase = isRecheck ? 'rechecked' : 'reviewed';
150
+ onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
151
+ let rawReview;
152
+ let tokensUsed;
153
+ if (reviewer === 'codex') {
154
+ ;
155
+ ({ review: rawReview, tokensUsed } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions));
156
+ }
157
+ else {
158
+ ;
159
+ ({ review: rawReview, tokensUsed } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions));
160
+ }
161
+ const { verdict, clean } = parseVerdict(rawReview);
162
+ if (verdict === null) {
163
+ fileLog({ level: 'warn', event: 'verdict_parse_failed', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, output_length: rawReview.length });
164
+ }
165
+ const commentBody = verdict === null
166
+ ? `${NULL_VERDICT_WARNING}\n\n${clean}`
167
+ : prependVerdictToComment(clean, verdict);
168
+ const commentCount = countComments(rawReview);
169
+ fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, verdict, duration_ms: Date.now() - ctx.reviewStart, tokens_used: tokensUsed, ...(ctx.round !== undefined && { round: ctx.round }) });
170
+ // Recheck verdict is stored separately to preserve the original review's commentCount on the board
171
+ const phaseUpdate = isRecheck
172
+ ? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: config.quality.tier }
173
+ : { verdict, commentCount, phase: donePhase, crTokens: tokensUsed, crReviewer: reviewer, qualityTier: config.quality.tier };
174
+ if (ctx.dryRun) {
175
+ onPhaseChange('dry-run — comment not posted', phaseUpdate);
176
+ log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
177
+ results[step.name] = { verdict, commentBody };
178
+ }
179
+ else {
180
+ onPhaseChange(isRecheck ? 'posting recheck...' : 'posting comment...', phaseUpdate);
181
+ const octokit = createGithubClient(token);
182
+ // For rechecks: look up the original review comment ID so the recheck
183
+ // can link back to it. Check in-run results first (single-run pipelines),
184
+ // then fall back to GitHub (cross-run: recheck triggered by a new push).
185
+ let priorReviewId;
186
+ if (isRecheck) {
187
+ priorReviewId = Object.values(results).reverse().find(r => r.commentId !== undefined)?.commentId;
188
+ if (priorReviewId === undefined) {
189
+ priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
190
+ }
191
+ }
192
+ const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck);
193
+ const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
194
+ fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
195
+ results[step.name] = { verdict, commentBody, commentUrl, commentId };
196
+ }
241
197
  }
242
- if (fixErr !== undefined && isRetryableFixError(fixErr)) {
243
- log(chalk.yellow(`⚠ fix step failed retrying in 2 min...`));
244
- onPhaseChange('fix retry in 2 min...', { phase: 'fixing' });
245
- fileLog({ level: 'info', event: 'fix_retry_scheduled', repo: `${owner}/${repoName}`, pr: prNumber });
246
- await new Promise(resolve => setTimeout(resolve, FIX_RETRY_DELAY_MS));
247
- onPhaseChange(`${vendor} fixing (retry)...`, { phase: 'fixing' });
198
+ else if (effectiveType === 'fix') {
199
+ const skipFix = (reason) => {
200
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
201
+ results[step.name] = { skipped: true };
202
+ fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
203
+ };
204
+ if (ctx.dryRun) {
205
+ skipFix('dry_run');
206
+ continue;
207
+ }
208
+ // Migration gate: honor legacy opt-out fields while users migrate to workflow.yml.
209
+ const legacyDisabled = config.post_review.auto_fix.enabled === false
210
+ || config.post_review.auto_fix.trigger === 'never';
211
+ if (legacyDisabled) {
212
+ log(chalk.yellow(`⚠ auto_fix.enabled/trigger are deprecated — remove them from config and add a "when:" condition to the fix step in workflow.yml instead`));
213
+ skipFix('legacy_auto_fix_disabled');
214
+ continue;
215
+ }
216
+ // Find the most recent review result that has a comment body
217
+ const reviewResult = Object.values(results).reverse().find(r => r.commentBody);
218
+ if (!reviewResult?.commentBody) {
219
+ skipFix('no_review_comment');
220
+ continue;
221
+ }
222
+ // Vendor is resolved from the workflow step's reviewer field, same as review/recheck steps.
223
+ // Use 'origin' to fix with the same vendor that authored the PR (recommended default).
224
+ const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
225
+ if (!vendor) {
226
+ skipFix('no_vendor');
227
+ continue;
228
+ }
229
+ // Codex fix not yet implemented — skip gracefully
230
+ if (vendor === 'codex') {
231
+ skipFix('codex_fix_unsupported');
232
+ continue;
233
+ }
234
+ // Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
235
+ // Scope to commits ahead of base so long-lived branches (e.g. staging)
236
+ // don't count [crosscheck] commits from previously merged PRs.
237
+ const existingCount = countCrosscheckCommitsForPR(tmpDir, pr.base.ref);
238
+ if (existingCount >= MAX_CROSSCHECK_COMMITS) {
239
+ log(chalk.yellow(`⚠ PR #${prNumber}: ${MAX_CROSSCHECK_COMMITS} [crosscheck] commits already — stopping auto-fix`));
240
+ skipFix('commit_limit_reached');
241
+ continue;
242
+ }
243
+ onPhaseChange(`${vendor} fixing...`, { phase: 'fixing' });
244
+ let appliedCount = 0;
245
+ let fixTokensUsed;
246
+ let fixErr = undefined;
248
247
  try {
249
248
  ;
250
249
  ({ appliedCount, tokensUsed: fixTokensUsed } = await runFixStep(tmpDir, pr.base.ref, pr.title, reviewResult.commentBody, step.instructions ?? '', config));
251
- fileLog({ level: 'info', event: 'fix_retry_succeeded', repo: `${owner}/${repoName}`, pr: prNumber });
252
- fixErr = undefined;
253
250
  }
254
- catch (retryErr) {
255
- logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 2 }, retryErr);
256
- fixErr = retryErr;
251
+ catch (err) {
252
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1 }, err);
253
+ fixErr = err;
257
254
  }
258
- }
259
- if (fixErr !== undefined) {
260
- skipFix('fix_error');
261
- // Only notify for transient failures auth errors are operator issues, not PR author issues
262
- if (isRetryableFixError(fixErr)) {
255
+ if (fixErr !== undefined && isRetryableFixError(fixErr)) {
256
+ log(chalk.yellow(`⚠ fix step failed — retrying in 2 min...`));
257
+ onPhaseChange('fix retry in 2 min...', { phase: 'fixing' });
258
+ fileLog({ level: 'info', event: 'fix_retry_scheduled', repo: `${owner}/${repoName}`, pr: prNumber });
259
+ await new Promise(resolve => setTimeout(resolve, FIX_RETRY_DELAY_MS));
260
+ onPhaseChange(`${vendor} fixing (retry)...`, { phase: 'fixing' });
263
261
  try {
264
- const octokit = createGithubClient(token);
265
- await octokit.rest.issues.createComment({
266
- owner, repo: repoName, issue_number: prNumber,
267
- body: `⚠️ **Auto-fix failed**\n\nThe fix step timed out after retrying. Push a new commit or run \`crosscheck run ${pr.html_url}\` to retry manually.\n\n<!-- crosscheck: fix_failed -->`,
268
- });
269
- fileLog({ level: 'info', event: 'fix_failed_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber });
262
+ ;
263
+ ({ appliedCount, tokensUsed: fixTokensUsed } = await runFixStep(tmpDir, pr.base.ref, pr.title, reviewResult.commentBody, step.instructions ?? '', config));
264
+ fileLog({ level: 'info', event: 'fix_retry_succeeded', repo: `${owner}/${repoName}`, pr: prNumber });
265
+ fixErr = undefined;
266
+ }
267
+ catch (retryErr) {
268
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 2 }, retryErr);
269
+ fixErr = retryErr;
270
270
  }
271
- catch { /* best-effort notification */ }
272
271
  }
273
- continue;
274
- }
275
- if (appliedCount === 0) {
276
- onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: fixTokensUsed });
277
- results[step.name] = { applied_count: 0 };
278
- continue;
279
- }
280
- const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
281
- if (isFork) {
282
- skipFix('fork_pr');
283
- continue;
284
- }
285
- const deliveryMode = config.post_review.auto_fix.delivery.mode;
286
- if (deliveryMode === 'commit') {
287
- execSync('git add -A', { cwd: tmpDir });
288
- execSync(`git commit -m "[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by Claude Code"`, { cwd: tmpDir });
289
- const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
290
- execSync(`git push origin HEAD:${pr.head.ref}`, {
291
- cwd: tmpDir,
292
- env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
293
- });
294
- ctx.crosscheckShas.add(newSha);
295
- onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
296
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed });
297
- results[step.name] = { applied_count: appliedCount };
298
- }
299
- else if (deliveryMode === 'pull_request') {
300
- // Create a fix branch and open a PR targeting the original branch
301
- const fixBranch = `fix/cr-${prNumber}-review-issues`;
302
- execSync(`git checkout -b ${fixBranch}`, { cwd: tmpDir });
303
- execSync('git add -A', { cwd: tmpDir });
304
- execSync(`git commit -m "[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by Claude Code"`, { cwd: tmpDir });
305
- const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
306
- execSync(`git push origin HEAD:${fixBranch}`, {
307
- cwd: tmpDir,
308
- env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
309
- });
310
- ctx.crosscheckShas.add(newSha);
311
- const octokit = createGithubClient(token);
312
- const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
313
- const { data: fixPr } = await octokit.rest.pulls.create({
314
- owner,
315
- repo: repoName,
316
- head: fixBranch,
317
- base: pr.head.ref,
318
- title: fixPrTitle,
319
- body: `Auto-fix by crosscheck for CR issues found in #${prNumber}.\n\nReview: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
320
- });
321
- if (config.post_review.auto_fix.delivery.label) {
272
+ if (fixErr !== undefined) {
273
+ skipFix('fix_error');
274
+ // Only notify for transient failures — auth errors are operator issues, not PR author issues
275
+ if (isRetryableFixError(fixErr)) {
276
+ try {
277
+ const octokit = createGithubClient(token);
278
+ await octokit.rest.issues.createComment({
279
+ owner, repo: repoName, issue_number: prNumber,
280
+ body: `⚠️ **Auto-fix failed**\n\nThe fix step timed out after retrying. Push a new commit or run \`crosscheck run ${pr.html_url}\` to retry manually.\n\n<!-- crosscheck: fix_failed -->`,
281
+ });
282
+ fileLog({ level: 'info', event: 'fix_failed_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber });
283
+ }
284
+ catch { /* best-effort notification */ }
285
+ }
286
+ continue;
287
+ }
288
+ if (appliedCount === 0) {
289
+ onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: fixTokensUsed });
290
+ results[step.name] = { applied_count: 0 };
291
+ continue;
292
+ }
293
+ const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
294
+ if (isFork) {
295
+ skipFix('fork_pr');
296
+ continue;
297
+ }
298
+ const deliveryMode = config.post_review.auto_fix.delivery.mode;
299
+ if (deliveryMode === 'commit') {
300
+ execSync('git add -A', { cwd: tmpDir });
301
+ execSync(`git commit -m "[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by Claude Code"`, { cwd: tmpDir });
302
+ const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
303
+ execSync(`git push origin HEAD:${pr.head.ref}`, {
304
+ cwd: tmpDir,
305
+ env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
306
+ });
307
+ ctx.crosscheckShas.add(newSha);
308
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
309
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed });
310
+ results[step.name] = { applied_count: appliedCount };
311
+ }
312
+ else if (deliveryMode === 'pull_request') {
313
+ // Create a fix branch and open a PR targeting the original branch
314
+ const fixBranch = `fix/cr-${prNumber}-review-issues`;
315
+ execSync(`git checkout -b ${fixBranch}`, { cwd: tmpDir });
316
+ execSync('git add -A', { cwd: tmpDir });
317
+ execSync(`git commit -m "[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by Claude Code"`, { cwd: tmpDir });
318
+ const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
319
+ execSync(`git push origin HEAD:${fixBranch}`, {
320
+ cwd: tmpDir,
321
+ env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
322
+ });
323
+ ctx.crosscheckShas.add(newSha);
324
+ const octokit = createGithubClient(token);
325
+ const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
326
+ const { data: fixPr } = await octokit.rest.pulls.create({
327
+ owner,
328
+ repo: repoName,
329
+ head: fixBranch,
330
+ base: pr.head.ref,
331
+ title: fixPrTitle,
332
+ body: `Auto-fix by crosscheck for CR issues found in #${prNumber}.\n\nReview: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
333
+ });
334
+ if (config.post_review.auto_fix.delivery.label) {
335
+ try {
336
+ await octokit.rest.issues.addLabels({
337
+ owner, repo: repoName, issue_number: fixPr.number, labels: [config.post_review.auto_fix.delivery.label],
338
+ });
339
+ }
340
+ catch { /* label may not exist in this repo — skip */ }
341
+ }
342
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
343
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed });
344
+ results[step.name] = { applied_count: appliedCount };
345
+ }
346
+ else {
347
+ // comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
348
+ let patch = '';
322
349
  try {
323
- await octokit.rest.issues.addLabels({
324
- owner, repo: repoName, issue_number: fixPr.number, labels: [config.post_review.auto_fix.delivery.label],
325
- });
350
+ patch = execSync('git diff', { cwd: tmpDir, encoding: 'utf8' });
351
+ }
352
+ catch { /* ignore */ }
353
+ if (patch) {
354
+ const octokit = createGithubClient(token);
355
+ const body = `### Suggested fixes (crosscheck auto-fix)\n\n\`\`\`diff\n${patch.slice(0, 16000)}\n\`\`\``;
356
+ await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
326
357
  }
327
- catch { /* label may not exist in this repo — skip */ }
358
+ onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
359
+ fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed });
360
+ results[step.name] = { applied_count: appliedCount };
328
361
  }
329
- onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
330
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed });
331
- results[step.name] = { applied_count: appliedCount };
332
362
  }
333
- else {
334
- // comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
335
- let patch = '';
336
- try {
337
- patch = execSync('git diff', { cwd: tmpDir, encoding: 'utf8' });
363
+ else if (effectiveType === 'conflict-resolve') {
364
+ const skipConflictResolve = (reason) => {
365
+ onPhaseChange('', { phase: 'fixed', fixCount: 0 });
366
+ results[step.name] = { skipped: true };
367
+ fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
368
+ };
369
+ if (ctx.dryRun) {
370
+ skipConflictResolve('dry_run');
371
+ continue;
338
372
  }
339
- catch { /* ignore */ }
340
- if (patch) {
373
+ // Fast pre-check: GitHub's mergeable field tells us if the PR has conflicts without
374
+ // cloning. true = no conflicts (skip immediately); false = conflicts confirmed (proceed);
375
+ // null = GitHub is still computing — fall through to the git merge probe.
376
+ {
341
377
  const octokit = createGithubClient(token);
342
- const body = `### Suggested fixes (crosscheck auto-fix)\n\n\`\`\`diff\n${patch.slice(0, 16000)}\n\`\`\``;
343
- await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
378
+ const { data: prInfo } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
379
+ if (prInfo.mergeable === true) {
380
+ skipConflictResolve('no_conflicts');
381
+ continue;
382
+ }
344
383
  }
345
- onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
346
- fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed });
347
- results[step.name] = { applied_count: appliedCount };
348
- }
349
- }
350
- else if (effectiveType === 'conflict-resolve') {
351
- const skipConflictResolve = (reason) => {
352
- onPhaseChange('', { phase: 'fixed', fixCount: 0 });
353
- results[step.name] = { skipped: true };
354
- fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
355
- };
356
- if (ctx.dryRun) {
357
- skipConflictResolve('dry_run');
358
- continue;
359
- }
360
- // Fast pre-check: GitHub's mergeable field tells us if the PR has conflicts without
361
- // cloning. true = no conflicts (skip immediately); false = conflicts confirmed (proceed);
362
- // null = GitHub is still computing — fall through to the git merge probe.
363
- {
364
- const octokit = createGithubClient(token);
365
- const { data: prInfo } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
366
- if (prInfo.mergeable === true) {
384
+ // P1: The clone only has the PR head checked out — no unmerged index entries exist
385
+ // until we actually attempt the merge. Attempt the merge first; if it succeeds
386
+ // cleanly (no conflicts) abort it and skip. If it fails, the working tree now has
387
+ // real conflict markers and UU entries that findConflictedFiles can detect.
388
+ let hasMergeConflicts = false;
389
+ try {
390
+ execSync(`git merge --no-commit origin/${pr.base.ref}`, { cwd: tmpDir, stdio: 'pipe' });
391
+ // Clean merge undo the staged merge state and skip this step
392
+ try {
393
+ execSync('git merge --abort', { cwd: tmpDir });
394
+ }
395
+ catch { /* ignore */ }
396
+ }
397
+ catch {
398
+ hasMergeConflicts = true;
399
+ }
400
+ if (!hasMergeConflicts) {
367
401
  skipConflictResolve('no_conflicts');
368
402
  continue;
369
403
  }
370
- }
371
- // P1: The clone only has the PR head checked out — no unmerged index entries exist
372
- // until we actually attempt the merge. Attempt the merge first; if it succeeds
373
- // cleanly (no conflicts) abort it and skip. If it fails, the working tree now has
374
- // real conflict markers and UU entries that findConflictedFiles can detect.
375
- let hasMergeConflicts = false;
376
- try {
377
- execSync(`git merge --no-commit origin/${pr.base.ref}`, { cwd: tmpDir, stdio: 'pipe' });
378
- // Clean merge — undo the staged merge state and skip this step
379
- try {
380
- execSync('git merge --abort', { cwd: tmpDir });
404
+ const conflictedFiles = findConflictedFiles(tmpDir);
405
+ if (conflictedFiles.length === 0) {
406
+ try {
407
+ execSync('git merge --abort', { cwd: tmpDir });
408
+ }
409
+ catch { /* ignore */ }
410
+ skipConflictResolve('no_conflicts');
411
+ continue;
381
412
  }
382
- catch { /* ignore */ }
383
- }
384
- catch {
385
- hasMergeConflicts = true;
386
- }
387
- if (!hasMergeConflicts) {
388
- skipConflictResolve('no_conflicts');
389
- continue;
390
- }
391
- const conflictedFiles = findConflictedFiles(tmpDir);
392
- if (conflictedFiles.length === 0) {
393
- try {
394
- execSync('git merge --abort', { cwd: tmpDir });
413
+ const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
414
+ if (!vendor) {
415
+ try {
416
+ execSync('git merge --abort', { cwd: tmpDir });
417
+ }
418
+ catch { /* ignore */ }
419
+ ;
420
+ skipConflictResolve('no_vendor');
421
+ continue;
395
422
  }
396
- catch { /* ignore */ }
397
- skipConflictResolve('no_conflicts');
398
- continue;
399
- }
400
- const vendor = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
401
- if (!vendor) {
402
- try {
403
- execSync('git merge --abort', { cwd: tmpDir });
423
+ if (vendor === 'codex') {
424
+ try {
425
+ execSync('git merge --abort', { cwd: tmpDir });
426
+ }
427
+ catch { /* ignore */ }
428
+ ;
429
+ skipConflictResolve('codex_conflict_resolve_unsupported');
430
+ continue;
404
431
  }
405
- catch { /* ignore */ }
406
- ;
407
- skipConflictResolve('no_vendor');
408
- continue;
409
- }
410
- if (vendor === 'codex') {
411
- try {
412
- execSync('git merge --abort', { cwd: tmpDir });
432
+ const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
433
+ if (isFork) {
434
+ try {
435
+ execSync('git merge --abort', { cwd: tmpDir });
436
+ }
437
+ catch { /* ignore */ }
438
+ ;
439
+ skipConflictResolve('fork_pr');
440
+ continue;
413
441
  }
414
- catch { /* ignore */ }
415
- ;
416
- skipConflictResolve('codex_conflict_resolve_unsupported');
417
- continue;
418
- }
419
- const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
420
- if (isFork) {
421
- try {
422
- execSync('git merge --abort', { cwd: tmpDir });
442
+ const existingCount = countCrosscheckCommitsForPR(tmpDir, pr.base.ref);
443
+ if (existingCount >= MAX_CROSSCHECK_COMMITS) {
444
+ try {
445
+ execSync('git merge --abort', { cwd: tmpDir });
446
+ }
447
+ catch { /* ignore */ }
448
+ log(chalk.yellow(`⚠ PR #${prNumber}: ${MAX_CROSSCHECK_COMMITS} [crosscheck] commits already — stopping conflict-resolve`));
449
+ skipConflictResolve('commit_limit_reached');
450
+ continue;
423
451
  }
424
- catch { /* ignore */ }
425
- ;
426
- skipConflictResolve('fork_pr');
427
- continue;
428
- }
429
- const existingCount = countCrosscheckCommitsForPR(tmpDir, pr.base.ref);
430
- if (existingCount >= MAX_CROSSCHECK_COMMITS) {
452
+ onPhaseChange(`${vendor} resolving conflicts...`, { phase: 'fixing' });
453
+ let appliedCount = 0;
454
+ let resolvedPaths = [];
455
+ let resolveTokensUsed;
431
456
  try {
432
- execSync('git merge --abort', { cwd: tmpDir });
457
+ ;
458
+ ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? ''));
433
459
  }
434
- catch { /* ignore */ }
435
- log(chalk.yellow(`⚠ PR #${prNumber}: ${MAX_CROSSCHECK_COMMITS} [crosscheck] commits already — stopping conflict-resolve`));
436
- skipConflictResolve('commit_limit_reached');
437
- continue;
438
- }
439
- onPhaseChange(`${vendor} resolving conflicts...`, { phase: 'fixing' });
440
- let appliedCount = 0;
441
- let resolvedPaths = [];
442
- let resolveTokensUsed;
443
- try {
444
- ;
445
- ({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? ''));
446
- }
447
- catch (err) {
448
- logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
449
- try {
450
- execSync('git merge --abort', { cwd: tmpDir });
460
+ catch (err) {
461
+ logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
462
+ try {
463
+ execSync('git merge --abort', { cwd: tmpDir });
464
+ }
465
+ catch { /* ignore */ }
466
+ skipConflictResolve('resolve_error');
467
+ continue;
451
468
  }
452
- catch { /* ignore */ }
453
- skipConflictResolve('resolve_error');
454
- continue;
455
- }
456
- if (appliedCount === 0) {
457
- try {
458
- execSync('git merge --abort', { cwd: tmpDir });
469
+ if (appliedCount === 0) {
470
+ try {
471
+ execSync('git merge --abort', { cwd: tmpDir });
472
+ }
473
+ catch { /* ignore */ }
474
+ onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: resolveTokensUsed });
475
+ results[step.name] = { applied_count: 0 };
476
+ continue;
459
477
  }
460
- catch { /* ignore */ }
461
- onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: resolveTokensUsed });
462
- results[step.name] = { applied_count: 0 };
463
- continue;
464
- }
465
- // P2: Verify every conflict region was resolved before committing. Scope the
466
- // check to the union of (originally-conflicted files) (files the resolver
467
- // actually rewrote) — a repo-wide grep would false-positive on legitimate
468
- // "=======" lines in docs (e.g. Markdown setext headings) and abort valid
469
- // resolutions, but we still need to cover any path the resolver touched in
470
- // case it ever edits outside the original conflict set. Read working-tree
471
- // content directly so untrusted PR-controlled paths never reach a shell.
472
- const MARKER_RE = /^(<<<<<<<|=======|>>>>>>>)( |$)/m;
473
- const pathsToScan = Array.from(new Set([...conflictedFiles, ...resolvedPaths]));
474
- const filesWithMarkers = [];
475
- for (const f of pathsToScan) {
476
- try {
477
- const content = readFileSync(join(tmpDir, f), 'utf8');
478
- if (MARKER_RE.test(content))
479
- filesWithMarkers.push(f);
478
+ // P2: Verify every conflict region was resolved before committing. Scope the
479
+ // check to the union of (originally-conflicted files) ∪ (files the resolver
480
+ // actually rewrote) a repo-wide grep would false-positive on legitimate
481
+ // "=======" lines in docs (e.g. Markdown setext headings) and abort valid
482
+ // resolutions, but we still need to cover any path the resolver touched in
483
+ // case it ever edits outside the original conflict set. Read working-tree
484
+ // content directly so untrusted PR-controlled paths never reach a shell.
485
+ const MARKER_RE = /^(<<<<<<<|=======|>>>>>>>)( |$)/m;
486
+ const pathsToScan = Array.from(new Set([...conflictedFiles, ...resolvedPaths]));
487
+ const filesWithMarkers = [];
488
+ for (const f of pathsToScan) {
489
+ try {
490
+ const content = readFileSync(join(tmpDir, f), 'utf8');
491
+ if (MARKER_RE.test(content))
492
+ filesWithMarkers.push(f);
493
+ }
494
+ catch { /* unreadable (deleted side of modify/delete) — caught by U-filter below */ }
480
495
  }
481
- catch { /* unreadable (deleted side of modify/delete) — caught by U-filter below */ }
482
- }
483
- if (filesWithMarkers.length > 0) {
496
+ if (filesWithMarkers.length > 0) {
497
+ try {
498
+ execSync('git merge --abort', { cwd: tmpDir });
499
+ }
500
+ catch { /* ignore */ }
501
+ log(chalk.yellow(`⚠ PR #${prNumber}: ${filesWithMarkers.length} file(s) still contain conflict markers — skipping commit`));
502
+ fileLog({ level: 'warn', event: 'conflict_resolve_incomplete', repo: `${owner}/${repoName}`, pr: prNumber, paths: filesWithMarkers });
503
+ skipConflictResolve('incomplete_resolution');
504
+ continue;
505
+ }
506
+ // Stage only files the resolver actually rewrote — `git add -A` would
507
+ // otherwise silently stage non-text conflicts (binary, modify/delete) using
508
+ // the worktree side as an un-reviewed resolution. Staging also has to come
509
+ // BEFORE the unmerged-path check below: git keeps a path in the unmerged
510
+ // index until it is explicitly added, so checking earlier would always fail
511
+ // on the resolved files themselves. Use execFileSync (no shell) because
512
+ // resolvedPaths is derived from model output and PR-controlled filenames.
513
+ for (const p of resolvedPaths) {
514
+ try {
515
+ execFileSync('git', ['add', '--', p], { cwd: tmpDir, stdio: 'pipe' });
516
+ }
517
+ catch { /* skip */ }
518
+ }
519
+ // After staging the resolved files, anything still in U state is a conflict
520
+ // the resolver did not handle (binary, modify/delete, or a failed edit).
521
+ // Abort rather than commit a partial merge.
522
+ let unmergedPaths = [];
484
523
  try {
485
- execSync('git merge --abort', { cwd: tmpDir });
524
+ const out = execSync('git diff --name-only --diff-filter=U', { cwd: tmpDir, encoding: 'utf8' });
525
+ unmergedPaths = out.trim().split('\n').filter(Boolean);
486
526
  }
487
527
  catch { /* ignore */ }
488
- log(chalk.yellow(`⚠ PR #${prNumber}: ${filesWithMarkers.length} file(s) still contain conflict markers — skipping commit`));
489
- fileLog({ level: 'warn', event: 'conflict_resolve_incomplete', repo: `${owner}/${repoName}`, pr: prNumber, paths: filesWithMarkers });
490
- skipConflictResolve('incomplete_resolution');
491
- continue;
492
- }
493
- // Stage only files the resolver actually rewrote`git add -A` would
494
- // otherwise silently stage non-text conflicts (binary, modify/delete) using
495
- // the worktree side as an un-reviewed resolution. Staging also has to come
496
- // BEFORE the unmerged-path check below: git keeps a path in the unmerged
497
- // index until it is explicitly added, so checking earlier would always fail
498
- // on the resolved files themselves. Use execFileSync (no shell) because
499
- // resolvedPaths is derived from model output and PR-controlled filenames.
500
- for (const p of resolvedPaths) {
528
+ if (unmergedPaths.length > 0) {
529
+ try {
530
+ execSync('git merge --abort', { cwd: tmpDir });
531
+ }
532
+ catch { /* ignore */ }
533
+ log(chalk.yellow(`⚠ PR #${prNumber}: ${unmergedPaths.length} unmerged path(s) remain after resolveskipping commit`));
534
+ fileLog({ level: 'warn', event: 'conflict_resolve_unmerged_paths', repo: `${owner}/${repoName}`, pr: prNumber, paths: unmergedPaths });
535
+ skipConflictResolve('unmerged_paths');
536
+ continue;
537
+ }
538
+ execSync(`git commit -m "[crosscheck] resolve: resolve ${conflictedFiles.length} conflict${conflictedFiles.length !== 1 ? 's' : ''} — by Claude Code"`, { cwd: tmpDir });
539
+ const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
540
+ execSync(`git push origin HEAD:${pr.head.ref}`, {
541
+ cwd: tmpDir,
542
+ env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
543
+ });
544
+ ctx.crosscheckShas.add(newSha);
545
+ // Move the in-flight pending status to newSha so watchers on other
546
+ // machines (which don't share crosscheckShas) see the PR as locked when
547
+ // they receive the synchronize event and skip duplicate review.
548
+ // Track the sha so the finally below releases the pending status —
549
+ // without that release the status would stay pending forever on GitHub.
501
550
  try {
502
- execFileSync('git', ['add', '--', p], { cwd: tmpDir, stdio: 'pipe' });
551
+ const lockOctokit = createGithubClient(token);
552
+ await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
553
+ pushedShasNeedingRelease.push(newSha);
503
554
  }
504
- catch { /* skip */ }
505
- }
506
- // After staging the resolved files, anything still in U state is a conflict
507
- // the resolver did not handle (binary, modify/delete, or a failed edit).
508
- // Abort rather than commit a partial merge.
509
- let unmergedPaths = [];
510
- try {
511
- const out = execSync('git diff --name-only --diff-filter=U', { cwd: tmpDir, encoding: 'utf8' });
512
- unmergedPaths = out.trim().split('\n').filter(Boolean);
555
+ catch (err) {
556
+ fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
557
+ }
558
+ onPhaseChange('conflicts resolved ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: resolveTokensUsed });
559
+ fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed });
560
+ results[step.name] = { applied_count: appliedCount };
513
561
  }
514
- catch { /* ignore */ }
515
- if (unmergedPaths.length > 0) {
562
+ }
563
+ const verdict = Object.values(results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
564
+ return { verdict: verdict ?? null };
565
+ }
566
+ catch (err) {
567
+ workflowFailed = true;
568
+ throw err;
569
+ }
570
+ finally {
571
+ if (pushedShasNeedingRelease.length > 0) {
572
+ const lockOctokit = createGithubClient(token);
573
+ const outcome = workflowFailed ? 'failure' : 'success';
574
+ // Drain via shift() so each released sha is synchronously removed from
575
+ // the shared array. The command-layer SIGINT/SIGTERM handler iterates
576
+ // the same array — if a late signal arrives after this finally has
577
+ // already released a sha, the handler won't see it and won't overwrite
578
+ // the released status with 'failure'. Atomic shift gives clean per-sha
579
+ // ownership transfer even when both loops are draining concurrently.
580
+ while (pushedShasNeedingRelease.length > 0) {
581
+ const s = pushedShasNeedingRelease.shift();
516
582
  try {
517
- execSync('git merge --abort', { cwd: tmpDir });
583
+ await releaseRemoteLock(lockOctokit, owner, repoName, s, outcome);
584
+ }
585
+ catch (err) {
586
+ fileLog({ level: 'warn', event: 'pushed_sha_release_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: s, error: err instanceof Error ? err.message : String(err) });
518
587
  }
519
- catch { /* ignore */ }
520
- log(chalk.yellow(`⚠ PR #${prNumber}: ${unmergedPaths.length} unmerged path(s) remain after resolve — skipping commit`));
521
- fileLog({ level: 'warn', event: 'conflict_resolve_unmerged_paths', repo: `${owner}/${repoName}`, pr: prNumber, paths: unmergedPaths });
522
- skipConflictResolve('unmerged_paths');
523
- continue;
524
588
  }
525
- execSync(`git commit -m "[crosscheck] resolve: resolve ${conflictedFiles.length} conflict${conflictedFiles.length !== 1 ? 's' : ''} — by Claude Code"`, { cwd: tmpDir });
526
- const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
527
- execSync(`git push origin HEAD:${pr.head.ref}`, {
528
- cwd: tmpDir,
529
- env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
530
- });
531
- ctx.crosscheckShas.add(newSha);
532
- onPhaseChange('conflicts resolved ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: resolveTokensUsed });
533
- fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed });
534
- results[step.name] = { applied_count: appliedCount };
535
589
  }
536
590
  }
537
- const verdict = Object.values(results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
538
- return { verdict: verdict ?? null };
539
591
  }
540
592
  //# sourceMappingURL=runner.js.map