@motivation-labs/crosscheck 0.13.0-beta.46 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/conflict-resolve.test.js +1 -18
- package/dist/__tests__/conflict-resolve.test.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +0 -20
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/runner.d.ts +0 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +392 -444
- package/dist/lib/runner.js.map +1 -1
- package/dist/reviewers/conflict-resolve.d.ts +0 -1
- package/dist/reviewers/conflict-resolve.d.ts.map +1 -1
- package/dist/reviewers/conflict-resolve.js +2 -7
- package/dist/reviewers/conflict-resolve.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/runner.js
CHANGED
|
@@ -8,7 +8,6 @@ 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';
|
|
12
11
|
import { log as fileLog, logError } from '../lib/logger.js';
|
|
13
12
|
import { loadWorkflow, evaluateWhen } from '../lib/workflow.js';
|
|
14
13
|
const MAX_CROSSCHECK_COMMITS = 5;
|
|
@@ -99,494 +98,443 @@ export async function runWorkflow(ctx) {
|
|
|
99
98
|
const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange } = ctx;
|
|
100
99
|
const steps = ctx.steps ?? loadWorkflow(process.cwd());
|
|
101
100
|
const results = {};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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' });
|
|
118
131
|
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 });
|
|
125
132
|
continue;
|
|
126
133
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
137
|
-
continue;
|
|
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));
|
|
138
143
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
}
|
|
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);
|
|
191
177
|
}
|
|
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
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 };
|
|
197
183
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
184
|
+
}
|
|
185
|
+
else if (effectiveType === 'fix') {
|
|
186
|
+
const skipFix = (reason) => {
|
|
187
|
+
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
188
|
+
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');
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
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;
|
|
241
|
+
}
|
|
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' });
|
|
247
248
|
try {
|
|
248
249
|
;
|
|
249
250
|
({ 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;
|
|
250
253
|
}
|
|
251
|
-
catch (
|
|
252
|
-
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt:
|
|
253
|
-
fixErr =
|
|
254
|
+
catch (retryErr) {
|
|
255
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 2 }, retryErr);
|
|
256
|
+
fixErr = retryErr;
|
|
254
257
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
onPhaseChange(`${vendor} fixing (retry)...`, { phase: 'fixing' });
|
|
261
|
-
try {
|
|
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
|
-
}
|
|
271
|
-
}
|
|
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 = '';
|
|
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)) {
|
|
349
263
|
try {
|
|
350
|
-
patch = execSync('git diff', { cwd: tmpDir, encoding: 'utf8' });
|
|
351
|
-
}
|
|
352
|
-
catch { /* ignore */ }
|
|
353
|
-
if (patch) {
|
|
354
264
|
const octokit = createGithubClient(token);
|
|
355
|
-
|
|
356
|
-
|
|
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 });
|
|
357
270
|
}
|
|
358
|
-
|
|
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 };
|
|
271
|
+
catch { /* best-effort notification */ }
|
|
361
272
|
}
|
|
273
|
+
continue;
|
|
362
274
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
{
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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) {
|
|
392
322
|
try {
|
|
393
|
-
|
|
323
|
+
await octokit.rest.issues.addLabels({
|
|
324
|
+
owner, repo: repoName, issue_number: fixPr.number, labels: [config.post_review.auto_fix.delivery.label],
|
|
325
|
+
});
|
|
394
326
|
}
|
|
395
|
-
catch { /*
|
|
327
|
+
catch { /* label may not exist in this repo — skip */ }
|
|
396
328
|
}
|
|
397
|
-
|
|
398
|
-
|
|
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
|
+
}
|
|
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' });
|
|
399
338
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
339
|
+
catch { /* ignore */ }
|
|
340
|
+
if (patch) {
|
|
341
|
+
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 });
|
|
403
344
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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) {
|
|
410
367
|
skipConflictResolve('no_conflicts');
|
|
411
368
|
continue;
|
|
412
369
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
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;
|
|
431
|
-
}
|
|
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;
|
|
441
|
-
}
|
|
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;
|
|
451
|
-
}
|
|
452
|
-
onPhaseChange(`${vendor} resolving conflicts...`, { phase: 'fixing' });
|
|
453
|
-
let appliedCount = 0;
|
|
454
|
-
let resolvedPaths = [];
|
|
455
|
-
let resolveTokensUsed;
|
|
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
|
|
456
379
|
try {
|
|
457
|
-
;
|
|
458
|
-
({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? ''));
|
|
380
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
459
381
|
}
|
|
460
|
-
catch
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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 });
|
|
468
395
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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 });
|
|
477
404
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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 */ }
|
|
405
|
+
catch { /* ignore */ }
|
|
406
|
+
;
|
|
407
|
+
skipConflictResolve('no_vendor');
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (vendor === 'codex') {
|
|
411
|
+
try {
|
|
412
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
495
413
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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 });
|
|
505
423
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
execFileSync('git', ['add', '--', p], { cwd: tmpDir, stdio: 'pipe' });
|
|
516
|
-
}
|
|
517
|
-
catch { /* skip */ }
|
|
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) {
|
|
431
|
+
try {
|
|
432
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
518
433
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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);
|
|
523
449
|
try {
|
|
524
|
-
|
|
525
|
-
unmergedPaths = out.trim().split('\n').filter(Boolean);
|
|
450
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
526
451
|
}
|
|
527
452
|
catch { /* ignore */ }
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
fileLog({ level: 'warn', event: 'conflict_resolve_unmerged_paths', repo: `${owner}/${repoName}`, pr: prNumber, paths: unmergedPaths });
|
|
535
|
-
skipConflictResolve('unmerged_paths');
|
|
536
|
-
continue;
|
|
453
|
+
skipConflictResolve('resolve_error');
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (appliedCount === 0) {
|
|
457
|
+
try {
|
|
458
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
537
459
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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) {
|
|
550
476
|
try {
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
477
|
+
const content = readFileSync(join(tmpDir, f), 'utf8');
|
|
478
|
+
if (MARKER_RE.test(content))
|
|
479
|
+
filesWithMarkers.push(f);
|
|
554
480
|
}
|
|
555
|
-
catch (
|
|
556
|
-
|
|
481
|
+
catch { /* unreadable (deleted side of modify/delete) — caught by U-filter below */ }
|
|
482
|
+
}
|
|
483
|
+
if (filesWithMarkers.length > 0) {
|
|
484
|
+
try {
|
|
485
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
557
486
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
487
|
+
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;
|
|
561
492
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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();
|
|
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) {
|
|
582
501
|
try {
|
|
583
|
-
|
|
502
|
+
execFileSync('git', ['add', '--', p], { cwd: tmpDir, stdio: 'pipe' });
|
|
584
503
|
}
|
|
585
|
-
catch
|
|
586
|
-
|
|
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);
|
|
513
|
+
}
|
|
514
|
+
catch { /* ignore */ }
|
|
515
|
+
if (unmergedPaths.length > 0) {
|
|
516
|
+
try {
|
|
517
|
+
execSync('git merge --abort', { cwd: tmpDir });
|
|
587
518
|
}
|
|
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;
|
|
588
524
|
}
|
|
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 };
|
|
589
535
|
}
|
|
590
536
|
}
|
|
537
|
+
const verdict = Object.values(results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
|
|
538
|
+
return { verdict: verdict ?? null };
|
|
591
539
|
}
|
|
592
540
|
//# sourceMappingURL=runner.js.map
|