@humanbased/crosscheck 0.14.0 → 0.15.0-beta.145
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/ISSUE.md +1 -1
- package/LICENSE +1 -1
- package/README.md +191 -8
- package/README.zh.md +1 -1
- package/crosscheck.config.example.yml +4 -2
- package/dist/__tests__/backtrace.test.js +1 -1
- package/dist/__tests__/backtrace.test.js.map +1 -1
- package/dist/__tests__/diagnose.test.js +36 -0
- package/dist/__tests__/diagnose.test.js.map +1 -1
- package/dist/__tests__/durations.test.js +5 -1
- package/dist/__tests__/durations.test.js.map +1 -1
- package/dist/__tests__/error-classification.test.d.ts +2 -0
- package/dist/__tests__/error-classification.test.d.ts.map +1 -0
- package/dist/__tests__/error-classification.test.js +36 -0
- package/dist/__tests__/error-classification.test.js.map +1 -0
- package/dist/__tests__/fix.test.js +48 -1
- package/dist/__tests__/fix.test.js.map +1 -1
- package/dist/__tests__/issue.test.js +2 -2
- package/dist/__tests__/issue.test.js.map +1 -1
- package/dist/__tests__/kickass.test.js +362 -69
- package/dist/__tests__/kickass.test.js.map +1 -1
- package/dist/__tests__/optimize.test.js +3 -3
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/__tests__/pr-picker.test.js +8 -7
- package/dist/__tests__/pr-picker.test.js.map +1 -1
- package/dist/__tests__/pr-status.test.js +41 -20
- package/dist/__tests__/pr-status.test.js.map +1 -1
- package/dist/__tests__/pr-workflow-state.test.d.ts +2 -0
- package/dist/__tests__/pr-workflow-state.test.d.ts.map +1 -0
- package/dist/__tests__/pr-workflow-state.test.js +184 -0
- package/dist/__tests__/pr-workflow-state.test.js.map +1 -0
- package/dist/__tests__/review-models.test.js +18 -0
- package/dist/__tests__/review-models.test.js.map +1 -1
- package/dist/__tests__/run.test.d.ts +2 -0
- package/dist/__tests__/run.test.d.ts.map +1 -0
- package/dist/__tests__/run.test.js +81 -0
- package/dist/__tests__/run.test.js.map +1 -0
- package/dist/__tests__/runner.test.js +117 -1
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/scopes.test.js +11 -11
- package/dist/__tests__/scopes.test.js.map +1 -1
- package/dist/__tests__/smart-switch.test.js +1 -1
- package/dist/__tests__/smart-switch.test.js.map +1 -1
- package/dist/__tests__/tier-timeouts.test.d.ts +2 -0
- package/dist/__tests__/tier-timeouts.test.d.ts.map +1 -0
- package/dist/__tests__/tier-timeouts.test.js +23 -0
- package/dist/__tests__/tier-timeouts.test.js.map +1 -0
- package/dist/__tests__/webhook.test.d.ts +2 -0
- package/dist/__tests__/webhook.test.d.ts.map +1 -0
- package/dist/__tests__/webhook.test.js +197 -0
- package/dist/__tests__/webhook.test.js.map +1 -0
- package/dist/cli.js +38 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/detect-step.d.ts +5 -0
- package/dist/commands/detect-step.d.ts.map +1 -0
- package/dist/commands/detect-step.js +124 -0
- package/dist/commands/detect-step.js.map +1 -0
- package/dist/commands/diagnose.d.ts +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +30 -1
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/kickass.d.ts +28 -10
- package/dist/commands/kickass.d.ts.map +1 -1
- package/dist/commands/kickass.js +295 -68
- package/dist/commands/kickass.js.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +14 -5
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts +16 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +347 -44
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +41 -3
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +10 -2
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +200 -6
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/schema.d.ts +52 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +24 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/github/client.d.ts +40 -1
- package/dist/github/client.d.ts.map +1 -1
- package/dist/github/client.js +69 -9
- package/dist/github/client.js.map +1 -1
- package/dist/github/review-status.d.ts.map +1 -1
- package/dist/github/review-status.js +7 -4
- package/dist/github/review-status.js.map +1 -1
- package/dist/github/webhook.d.ts +25 -1
- package/dist/github/webhook.d.ts.map +1 -1
- package/dist/github/webhook.js +37 -1
- package/dist/github/webhook.js.map +1 -1
- package/dist/lib/annotation.d.ts +4 -0
- package/dist/lib/annotation.d.ts.map +1 -1
- package/dist/lib/annotation.js +5 -1
- package/dist/lib/annotation.js.map +1 -1
- package/dist/lib/board.d.ts.map +1 -1
- package/dist/lib/board.js +7 -5
- package/dist/lib/board.js.map +1 -1
- package/dist/lib/comment-bodies.d.ts.map +1 -1
- package/dist/lib/comment-bodies.js +3 -2
- package/dist/lib/comment-bodies.js.map +1 -1
- package/dist/lib/durations.d.ts.map +1 -1
- package/dist/lib/durations.js +5 -3
- package/dist/lib/durations.js.map +1 -1
- package/dist/lib/logger.d.ts +4 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +41 -5
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/pr-picker.d.ts.map +1 -1
- package/dist/lib/pr-picker.js +5 -1
- package/dist/lib/pr-picker.js.map +1 -1
- package/dist/lib/pr-status.d.ts +4 -3
- package/dist/lib/pr-status.d.ts.map +1 -1
- package/dist/lib/pr-status.js +19 -13
- package/dist/lib/pr-status.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +68 -0
- package/dist/lib/pr-workflow-state.d.ts.map +1 -0
- package/dist/lib/pr-workflow-state.js +328 -0
- package/dist/lib/pr-workflow-state.js.map +1 -0
- package/dist/lib/product.d.ts +3 -0
- package/dist/lib/product.d.ts.map +1 -0
- package/dist/lib/product.js +5 -0
- package/dist/lib/product.js.map +1 -0
- package/dist/lib/repo-picker.d.ts +1 -0
- package/dist/lib/repo-picker.d.ts.map +1 -1
- package/dist/lib/repo-picker.js +50 -33
- package/dist/lib/repo-picker.js.map +1 -1
- package/dist/lib/review-models.d.ts +2 -2
- package/dist/lib/review-models.d.ts.map +1 -1
- package/dist/lib/review-models.js +6 -1
- package/dist/lib/review-models.js.map +1 -1
- package/dist/lib/runner.d.ts +19 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +338 -55
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/scopes.js +1 -1
- package/dist/lib/scopes.js.map +1 -1
- package/dist/lib/smart-switch.js +1 -1
- package/dist/lib/smart-switch.js.map +1 -1
- package/dist/lib/vendor.d.ts +4 -0
- package/dist/lib/vendor.d.ts.map +1 -0
- package/dist/lib/vendor.js +14 -0
- package/dist/lib/vendor.js.map +1 -0
- package/dist/lib/workflow.d.ts +5 -0
- package/dist/lib/workflow.d.ts.map +1 -1
- package/dist/lib/workflow.js.map +1 -1
- package/dist/reviewers/claude.d.ts +3 -1
- package/dist/reviewers/claude.d.ts.map +1 -1
- package/dist/reviewers/claude.js +15 -10
- package/dist/reviewers/claude.js.map +1 -1
- package/dist/reviewers/codex.d.ts +1 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +7 -10
- package/dist/reviewers/codex.js.map +1 -1
- package/dist/reviewers/conflict-resolve.d.ts +1 -1
- package/dist/reviewers/conflict-resolve.d.ts.map +1 -1
- package/dist/reviewers/conflict-resolve.js +3 -2
- package/dist/reviewers/conflict-resolve.js.map +1 -1
- package/dist/reviewers/fix.d.ts +5 -1
- package/dist/reviewers/fix.d.ts.map +1 -1
- package/dist/reviewers/fix.js +68 -2
- package/dist/reviewers/fix.js.map +1 -1
- package/dist/reviewers/tier-timeouts.d.ts +5 -0
- package/dist/reviewers/tier-timeouts.d.ts.map +1 -0
- package/dist/reviewers/tier-timeouts.js +14 -0
- package/dist/reviewers/tier-timeouts.js.map +1 -0
- package/docs/fixture-pr.md +112 -0
- package/docs/proof-demo.md +102 -0
- package/get-started.md +128 -31
- package/get-started.zh.md +7 -1
- package/package.json +4 -2
package/dist/lib/runner.js
CHANGED
|
@@ -5,23 +5,32 @@ import { join } from 'path';
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { runCodexReview } from '../reviewers/codex.js';
|
|
7
7
|
import { runClaudeReview } from '../reviewers/claude.js';
|
|
8
|
-
import { runFixStep } from '../reviewers/fix.js';
|
|
8
|
+
import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
|
|
9
9
|
import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
|
|
10
10
|
import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING } from '../lib/verdict.js';
|
|
11
11
|
import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
|
|
12
12
|
import { acquireRemoteLock, releaseRemoteLock } from '../github/review-status.js';
|
|
13
13
|
import { log as fileLog, logError } from '../lib/logger.js';
|
|
14
14
|
import { buildCommitTrailers } from '../lib/annotation.js';
|
|
15
|
-
import { resolveClaudeModel } from '../lib/review-models.js';
|
|
15
|
+
import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
|
|
16
16
|
import { buildStepIdentityFields } from '../lib/event-fields.js';
|
|
17
17
|
import { buildFixAppliedCommentBody, buildConflictResolvedCommentBody } from '../lib/comment-bodies.js';
|
|
18
18
|
import { loadWorkflow, evaluateWhen } from '../lib/workflow.js';
|
|
19
|
+
import { isSubscriptionLimitError } from '../lib/smart-switch.js';
|
|
19
20
|
const MAX_CROSSCHECK_COMMITS = 5;
|
|
20
21
|
const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
|
|
21
|
-
//
|
|
22
|
+
// Per-vendor configured timeout (seconds) → execa milliseconds, or undefined when
|
|
23
|
+
// unset so the reviewer falls back to its built-in default. A per-run override
|
|
24
|
+
// (ctx.overrideTimeoutMs, set by --timeout / --crazy / --halfcrazy) always wins
|
|
25
|
+
// over this; 0 from that override means "no cap" and is preserved by `??`.
|
|
26
|
+
function vendorTimeoutMs(timeoutSec) {
|
|
27
|
+
return timeoutSec == null ? undefined : timeoutSec * 1000;
|
|
28
|
+
}
|
|
29
|
+
// Auth and quota/credit failures are operator/vendor-capacity issues that won't
|
|
30
|
+
// self-heal through an immediate retry. Transient subprocess failures can retry.
|
|
22
31
|
export function isRetryableFixError(err) {
|
|
23
32
|
const msg = err instanceof Error ? err.message : String(err);
|
|
24
|
-
return !/auth failure|not logged in|claude auth/i.test(msg);
|
|
33
|
+
return !/auth failure|not logged in|claude auth/i.test(msg) && !isSubscriptionLimitError(err);
|
|
25
34
|
}
|
|
26
35
|
// When a PR has already been reviewed, subsequent webhook runs treat every
|
|
27
36
|
// 'review' step as a 'recheck' so the first review's CR result is preserved.
|
|
@@ -56,10 +65,18 @@ export function countCrosscheckCommitsForPR(tmpDir, baseRef) {
|
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
export function buildWorkflowCompleteEvent(inputs) {
|
|
59
|
-
const
|
|
68
|
+
const stepValues = Object.values(inputs.results);
|
|
69
|
+
const lastVerdict = stepValues.reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
|
|
60
70
|
const lastStep = inputs.stepsRun.length > 0 ? inputs.stepsRun[inputs.stepsRun.length - 1] : null;
|
|
61
71
|
const endedReason = inputs.workflowFailed ? 'error' : 'completed';
|
|
62
72
|
const now = inputs.now ?? Date.now();
|
|
73
|
+
const totalTokens = stepValues.reduce((s, r) => s + (r.tokens_used ?? 0), 0);
|
|
74
|
+
// Only emit split fields when at least one step actually recorded them — avoid
|
|
75
|
+
// emitting 0/0 for codex-only or fix-only workflows where splits are unavailable.
|
|
76
|
+
const hasSplits = stepValues.some(r => r.input_tokens !== undefined || r.output_tokens !== undefined);
|
|
77
|
+
const totalInputTokens = hasSplits ? stepValues.reduce((s, r) => s + (r.input_tokens ?? 0), 0) : undefined;
|
|
78
|
+
const totalOutputTokens = hasSplits ? stepValues.reduce((s, r) => s + (r.output_tokens ?? 0), 0) : undefined;
|
|
79
|
+
const vendorsUsed = [...new Set(stepValues.map(r => r.vendor).filter(Boolean))];
|
|
63
80
|
return {
|
|
64
81
|
level: inputs.workflowFailed ? 'warn' : 'info',
|
|
65
82
|
event: 'workflow_complete',
|
|
@@ -71,7 +88,11 @@ export function buildWorkflowCompleteEvent(inputs) {
|
|
|
71
88
|
last_verdict: lastVerdict,
|
|
72
89
|
ended_reason: endedReason,
|
|
73
90
|
total_duration_ms: now - inputs.workflowStart,
|
|
91
|
+
...(totalTokens > 0 && { total_tokens: totalTokens, ...(hasSplits && { total_input_tokens: totalInputTokens, total_output_tokens: totalOutputTokens }) }),
|
|
92
|
+
...(vendorsUsed.length > 0 && { vendors_used: vendorsUsed }),
|
|
93
|
+
...(inputs.qualityTier !== undefined && { quality_tier: inputs.qualityTier }),
|
|
74
94
|
...(inputs.round !== undefined && { round: inputs.round }),
|
|
95
|
+
...(inputs.trigger !== undefined && { trigger: inputs.trigger }),
|
|
75
96
|
};
|
|
76
97
|
}
|
|
77
98
|
// Returns true when fix/recheck steps should be skipped because the configured
|
|
@@ -119,8 +140,122 @@ function resolveReviewer(reviewer, origin, config, fallback) {
|
|
|
119
140
|
return config.vendors.codex.enabled ? 'codex' : (fallback && config.vendors[fallback].enabled ? fallback : null);
|
|
120
141
|
return null;
|
|
121
142
|
}
|
|
143
|
+
function supportsStep(vendor, stepType) {
|
|
144
|
+
if (stepType === 'review' || stepType === 'recheck' || stepType === 'fix')
|
|
145
|
+
return true;
|
|
146
|
+
// Conflict resolution is Claude-only until Codex conflict resolution exists.
|
|
147
|
+
return vendor === 'claude';
|
|
148
|
+
}
|
|
149
|
+
function resolveLimitFallbackVendor(failedVendor, stepType, config) {
|
|
150
|
+
const fallback = failedVendor === 'claude' ? 'codex' : 'claude';
|
|
151
|
+
return config.vendors[fallback].enabled && supportsStep(fallback, stepType) ? fallback : null;
|
|
152
|
+
}
|
|
153
|
+
// Extends resolveReviewer with a human-origin fallback for the fix step.
|
|
154
|
+
// Scoped to reviewer: 'origin' only — other reviewer types (claude, codex, auto)
|
|
155
|
+
// already encode explicit vendor intent and need no fallback.
|
|
156
|
+
// When origin is 'human' and no vendor resolved, honours routing.fallback_reviewer
|
|
157
|
+
// so the fix step respects the same routing intent as the review step.
|
|
158
|
+
// 'auto' mirrors resolveReviewer's auto path (config-enabled check, codex-first)
|
|
159
|
+
// without async auth calls. null disables the fallback entirely.
|
|
160
|
+
// Exported so callers can detect when the fallback was applied (e.g. for logging).
|
|
161
|
+
export function resolveFixVendor(stepReviewer, origin, config, fallback) {
|
|
162
|
+
const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
|
|
163
|
+
if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
|
|
164
|
+
return { vendor, usedHumanFallback: false };
|
|
165
|
+
}
|
|
166
|
+
const fb = config.routing.fallback_reviewer;
|
|
167
|
+
let humanFallback = null;
|
|
168
|
+
if (fb === 'claude')
|
|
169
|
+
humanFallback = config.vendors.claude.enabled ? 'claude' : null;
|
|
170
|
+
else if (fb === 'codex')
|
|
171
|
+
humanFallback = config.vendors.codex.enabled ? 'codex' : null;
|
|
172
|
+
else if (fb !== null) {
|
|
173
|
+
// 'auto': prefer codex then claude, same as resolveReviewer's auto path
|
|
174
|
+
humanFallback = config.vendors.codex.enabled ? 'codex' : config.vendors.claude.enabled ? 'claude' : null;
|
|
175
|
+
}
|
|
176
|
+
if (!humanFallback)
|
|
177
|
+
return { vendor: null, usedHumanFallback: false };
|
|
178
|
+
return { vendor: humanFallback, usedHumanFallback: true };
|
|
179
|
+
}
|
|
180
|
+
// ─── pr_complexity helpers ────────────────────────────────────────────────────
|
|
181
|
+
const EXT_LANG = {
|
|
182
|
+
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
183
|
+
mjs: 'javascript', cjs: 'javascript', py: 'python', go: 'go', java: 'java',
|
|
184
|
+
rb: 'ruby', rs: 'rust', cs: 'csharp', cpp: 'cpp', cc: 'cpp', php: 'php',
|
|
185
|
+
kt: 'kotlin', swift: 'swift',
|
|
186
|
+
};
|
|
187
|
+
function classifyFile(filePath) {
|
|
188
|
+
const lower = filePath.toLowerCase();
|
|
189
|
+
const ext = lower.split('.').pop() ?? '';
|
|
190
|
+
if (/\.(test|spec)\.[jt]sx?$/.test(lower) || /(\/__tests__\/|\/test\/|\/spec\/)/.test(lower))
|
|
191
|
+
return 'test';
|
|
192
|
+
if (/\.(md|mdx|rst|txt)$/.test(lower))
|
|
193
|
+
return 'docs';
|
|
194
|
+
if (/^(dockerfile|tf|hcl)$/.test(ext) || lower === 'dockerfile' || /\/(\.github|infra|k8s|docker|ci\/)/.test(lower))
|
|
195
|
+
return 'infra';
|
|
196
|
+
if (/\.(css|scss|sass|less|html|svelte|vue)$/.test(lower) || /\.(tsx|jsx)$/.test(lower))
|
|
197
|
+
return 'frontend';
|
|
198
|
+
if (/\/(components|pages|views|ui|client|browser|public|assets|styles)/.test(lower))
|
|
199
|
+
return 'frontend';
|
|
200
|
+
if (/^(json|yml|yaml|toml|ini|cfg|env|lock)$/.test(ext))
|
|
201
|
+
return 'config';
|
|
202
|
+
if (['ts', 'js', 'mjs', 'cjs', 'py', 'go', 'java', 'rb', 'rs', 'cs', 'cpp', 'cc', 'php', 'kt', 'swift'].includes(ext))
|
|
203
|
+
return 'backend';
|
|
204
|
+
return 'other';
|
|
205
|
+
}
|
|
206
|
+
function diffBucket(totalLines) {
|
|
207
|
+
if (totalLines < 50)
|
|
208
|
+
return 'tiny';
|
|
209
|
+
if (totalLines < 200)
|
|
210
|
+
return 'small';
|
|
211
|
+
if (totalLines < 500)
|
|
212
|
+
return 'medium';
|
|
213
|
+
if (totalLines < 2000)
|
|
214
|
+
return 'large';
|
|
215
|
+
return 'xlarge';
|
|
216
|
+
}
|
|
217
|
+
function emitPRComplexity(ctx, triggerField) {
|
|
218
|
+
const { owner, repoName, prNumber, tmpDir, pr, config } = ctx;
|
|
219
|
+
try {
|
|
220
|
+
const raw = execSync(`git diff --stat origin/${pr.base.ref}...HEAD`, { cwd: tmpDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
221
|
+
if (!raw)
|
|
222
|
+
return;
|
|
223
|
+
const lines = raw.split('\n');
|
|
224
|
+
const summary = lines[lines.length - 1];
|
|
225
|
+
const filesChanged = parseInt(summary.match(/(\d+) files? changed/)?.[1] ?? '0');
|
|
226
|
+
const insertions = parseInt(summary.match(/(\d+) insertion/)?.[1] ?? '0');
|
|
227
|
+
const deletions = parseInt(summary.match(/(\d+) deletion/)?.[1] ?? '0');
|
|
228
|
+
const filePaths = lines.slice(0, -1).map(l => l.trim().split('|')[0]?.trim()).filter(Boolean);
|
|
229
|
+
const mix = { backend: 0, frontend: 0, test: 0, infra: 0, docs: 0, config: 0, other: 0 };
|
|
230
|
+
const langSet = new Set();
|
|
231
|
+
for (const fp of filePaths) {
|
|
232
|
+
const ext = fp.split('.').pop()?.toLowerCase() ?? '';
|
|
233
|
+
if (EXT_LANG[ext])
|
|
234
|
+
langSet.add(EXT_LANG[ext]);
|
|
235
|
+
const cat = classifyFile(fp);
|
|
236
|
+
mix[cat] = (mix[cat] ?? 0) + 1;
|
|
237
|
+
}
|
|
238
|
+
fileLog({
|
|
239
|
+
level: 'info',
|
|
240
|
+
event: 'pr_complexity',
|
|
241
|
+
repo: `${owner}/${repoName}`,
|
|
242
|
+
pr: prNumber,
|
|
243
|
+
files_changed: filesChanged,
|
|
244
|
+
insertions,
|
|
245
|
+
deletions,
|
|
246
|
+
diff_bucket: diffBucket(insertions + deletions),
|
|
247
|
+
file_mix: mix,
|
|
248
|
+
languages: [...langSet],
|
|
249
|
+
quality_tier: config.quality.tier,
|
|
250
|
+
...triggerField,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch { /* best-effort — never fail the workflow for a logging event */ }
|
|
254
|
+
}
|
|
255
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
256
|
export async function runWorkflow(ctx) {
|
|
123
|
-
const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange } = ctx;
|
|
257
|
+
const { owner, repoName, prNumber, pr, tmpDir, token, config, origin, log, onPhaseChange, trigger } = ctx;
|
|
258
|
+
const triggerField = trigger !== undefined ? { trigger } : {};
|
|
124
259
|
const steps = ctx.steps ?? loadWorkflow(process.cwd());
|
|
125
260
|
const results = {};
|
|
126
261
|
// SHAs the workflow pushed AND set a `crosscheck/review` pending status on.
|
|
@@ -134,6 +269,11 @@ export async function runWorkflow(ctx) {
|
|
|
134
269
|
// mid-workflow (process.exit there bypasses our finally below).
|
|
135
270
|
const pushedShasNeedingRelease = ctx.pushedShas ?? [];
|
|
136
271
|
let workflowFailed = false;
|
|
272
|
+
// When a fix commits and a structural review/recheck step follows, we acquire
|
|
273
|
+
// the remote lock on the pushed SHA. Track it here so the finally can detect
|
|
274
|
+
// whether the recheck was actually skipped (by `when`, no_reviewer, etc.) and
|
|
275
|
+
// release the SHA as `failure` rather than `success` in that case.
|
|
276
|
+
let fixPushedShaRequiresRecheck = null;
|
|
137
277
|
// workflow_complete event accumulators. Each step the runner dispatches is
|
|
138
278
|
// recorded in stepsRun (including ones that get logged as step_skipped —
|
|
139
279
|
// the event records the workflow's declared shape, the per-step skip
|
|
@@ -141,11 +281,12 @@ export async function runWorkflow(ctx) {
|
|
|
141
281
|
const workflowId = randomUUID();
|
|
142
282
|
const workflowStart = Date.now();
|
|
143
283
|
const stepsRun = [];
|
|
284
|
+
emitPRComplexity(ctx, triggerField);
|
|
144
285
|
try {
|
|
145
286
|
for (const step of steps) {
|
|
146
287
|
stepsRun.push(step.name);
|
|
147
288
|
const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
|
|
148
|
-
if (exceedsMaxRounds(effectiveType, step.type, step.max_rounds, ctx.round)) {
|
|
289
|
+
if (exceedsMaxRounds(effectiveType, step.type, ctx.overrideMaxRounds ?? step.max_rounds, ctx.round)) {
|
|
149
290
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
|
|
150
291
|
results[step.name] = { skipped: true };
|
|
151
292
|
if (effectiveType === 'fix')
|
|
@@ -170,14 +311,17 @@ export async function runWorkflow(ctx) {
|
|
|
170
311
|
}
|
|
171
312
|
if (effectiveType === 'review' || effectiveType === 'recheck') {
|
|
172
313
|
const isRecheck = effectiveType === 'recheck';
|
|
173
|
-
|
|
314
|
+
let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
174
315
|
if (!reviewer) {
|
|
175
316
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
|
|
176
317
|
results[step.name] = { skipped: true };
|
|
177
318
|
continue;
|
|
178
319
|
}
|
|
320
|
+
// The recheck step is confirmed to run — clear the pending-recheck guard
|
|
321
|
+
// so the finally doesn't release the fix-pushed SHA as failure.
|
|
322
|
+
fixPushedShaRequiresRecheck = null;
|
|
179
323
|
const stepIdentity = buildStepIdentityFields(effectiveType, step.name);
|
|
180
|
-
fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, ...(ctx.round !== undefined && { round: ctx.round }) });
|
|
324
|
+
fileLog({ level: 'info', event: 'review_started', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }) });
|
|
181
325
|
const startPhase = isRecheck ? 'rechecking' : 'reviewing';
|
|
182
326
|
const donePhase = isRecheck ? 'rechecked' : 'reviewed';
|
|
183
327
|
onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
|
|
@@ -185,16 +329,50 @@ export async function runWorkflow(ctx) {
|
|
|
185
329
|
// workflow start and never reset, so a recheck's duration_ms would
|
|
186
330
|
// otherwise include the prior review and fix wall time.
|
|
187
331
|
const stepStart = Date.now();
|
|
188
|
-
let rawReview;
|
|
332
|
+
let rawReview = '';
|
|
189
333
|
let tokensUsed;
|
|
334
|
+
let inputTokens;
|
|
335
|
+
let outputTokens;
|
|
190
336
|
let model = 'default';
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
337
|
+
const runReviewWithVendor = async (candidate) => {
|
|
338
|
+
if (candidate === 'codex') {
|
|
339
|
+
;
|
|
340
|
+
({ review: rawReview, tokensUsed, model } = await runCodexReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.codex, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec)));
|
|
341
|
+
inputTokens = undefined;
|
|
342
|
+
outputTokens = undefined;
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
;
|
|
346
|
+
({ review: rawReview, tokensUsed, inputTokens, outputTokens, model } = await runClaudeReview(tmpDir, pr.base.ref, pr.title, config.quality, config.vendors.claude, config.budget.per_review_usd, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec), !!ctx.roundMode));
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
await runReviewWithVendor(reviewer);
|
|
194
351
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
352
|
+
catch (err) {
|
|
353
|
+
if (!isSubscriptionLimitError(err))
|
|
354
|
+
throw err;
|
|
355
|
+
const failedVendor = reviewer;
|
|
356
|
+
const fallbackVendor = resolveLimitFallbackVendor(failedVendor, effectiveType, config);
|
|
357
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
358
|
+
ctx.onVendorLimit?.(failedVendor, fallbackVendor, reason, step.name);
|
|
359
|
+
if (!fallbackVendor)
|
|
360
|
+
throw err;
|
|
361
|
+
fileLog({
|
|
362
|
+
level: 'warn',
|
|
363
|
+
event: 'vendor_fallback',
|
|
364
|
+
repo: `${owner}/${repoName}`,
|
|
365
|
+
pr: prNumber,
|
|
366
|
+
step: step.name,
|
|
367
|
+
step_type: effectiveType,
|
|
368
|
+
failed_vendor: failedVendor,
|
|
369
|
+
fallback_vendor: fallbackVendor,
|
|
370
|
+
reason: reason.slice(0, 300),
|
|
371
|
+
});
|
|
372
|
+
log(chalk.yellow(`⚠ ${failedVendor} hit a usage limit — switching ${effectiveType} step to ${fallbackVendor}`));
|
|
373
|
+
reviewer = fallbackVendor;
|
|
374
|
+
onPhaseChange(`${reviewer} ${isRecheck ? 'rechecking' : 'reviewing'}...`, { phase: startPhase });
|
|
375
|
+
await runReviewWithVendor(reviewer);
|
|
198
376
|
}
|
|
199
377
|
const { verdict, clean } = parseVerdict(rawReview);
|
|
200
378
|
if (verdict === null) {
|
|
@@ -204,7 +382,7 @@ export async function runWorkflow(ctx) {
|
|
|
204
382
|
? `${NULL_VERDICT_WARNING}\n\n${clean}`
|
|
205
383
|
: prependVerdictToComment(clean, verdict);
|
|
206
384
|
const commentCount = countComments(rawReview);
|
|
207
|
-
fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, ...(ctx.round !== undefined && { round: ctx.round }) });
|
|
385
|
+
fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, tokens_used: tokensUsed, ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
|
|
208
386
|
// Recheck verdict is stored separately to preserve the original review's commentCount on the board
|
|
209
387
|
const phaseUpdate = isRecheck
|
|
210
388
|
? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: config.quality.tier }
|
|
@@ -212,7 +390,7 @@ export async function runWorkflow(ctx) {
|
|
|
212
390
|
if (ctx.dryRun) {
|
|
213
391
|
onPhaseChange('dry-run — comment not posted', phaseUpdate);
|
|
214
392
|
log(chalk.dim(`\n--- dry-run: comment that would be posted ---\n${commentBody}\n--- end ---`));
|
|
215
|
-
results[step.name] = { verdict, commentBody };
|
|
393
|
+
results[step.name] = { verdict, commentBody, tokens_used: tokensUsed, input_tokens: inputTokens, output_tokens: outputTokens, vendor: reviewer, model };
|
|
216
394
|
}
|
|
217
395
|
else {
|
|
218
396
|
onPhaseChange(isRecheck ? 'posting recheck...' : 'posting comment...', phaseUpdate);
|
|
@@ -227,10 +405,26 @@ export async function runWorkflow(ctx) {
|
|
|
227
405
|
priorReviewId = await getLastCrossCheckCommentId(owner, repoName, prNumber, token);
|
|
228
406
|
}
|
|
229
407
|
}
|
|
230
|
-
|
|
408
|
+
// Pre-compute next_step for the annotation so readers can skip full
|
|
409
|
+
// comment scans: find the first remaining step whose when-condition holds.
|
|
410
|
+
const currentStepIdx = steps.indexOf(step);
|
|
411
|
+
const syntheticResultsForNext = {
|
|
412
|
+
review: { verdict }, [step.name]: { verdict },
|
|
413
|
+
};
|
|
414
|
+
const nextWorkflowStep = steps.slice(currentStepIdx + 1).find(s => !s.when || evaluateWhen(s.when, syntheticResultsForNext));
|
|
415
|
+
const nextStepAnnotation = nextWorkflowStep?.type ?? 'none';
|
|
416
|
+
// Read the actual HEAD from the checkout: after an in-run fix step pushes a
|
|
417
|
+
// new commit, pr.head.sha is still the pre-fix SHA and would make the recheck
|
|
418
|
+
// annotation look stale to the step detector on the next run.
|
|
419
|
+
let annotationSha = pr.head.sha;
|
|
420
|
+
try {
|
|
421
|
+
annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
422
|
+
}
|
|
423
|
+
catch { /* fall back to pr.head.sha if git is unavailable */ }
|
|
424
|
+
const commentId = await postReviewComment(octokit, owner, repoName, prNumber, commentBody, reviewer, config.brand, origin, verdict ?? undefined, priorReviewId, isRecheck, model, effectiveType, ctx.round ?? 1, annotationSha, nextStepAnnotation, ctx.trigger === 'kickass' ? 'kickass' : undefined);
|
|
231
425
|
const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
|
|
232
426
|
fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
|
|
233
|
-
results[step.name] = { verdict, commentBody, commentUrl, commentId };
|
|
427
|
+
results[step.name] = { verdict, commentBody, commentUrl, commentId, tokens_used: tokensUsed, input_tokens: inputTokens, output_tokens: outputTokens, vendor: reviewer, model };
|
|
234
428
|
}
|
|
235
429
|
}
|
|
236
430
|
else if (effectiveType === 'fix') {
|
|
@@ -262,9 +456,15 @@ export async function runWorkflow(ctx) {
|
|
|
262
456
|
reviewCommentId = ctx.initialReviewComment?.id;
|
|
263
457
|
}
|
|
264
458
|
if (!reviewCommentBody) {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
459
|
+
try {
|
|
460
|
+
const latestReviewComment = await getLastCrossCheckReviewComment(owner, repoName, prNumber, token);
|
|
461
|
+
reviewCommentBody = latestReviewComment?.body;
|
|
462
|
+
reviewCommentId = latestReviewComment?.id;
|
|
463
|
+
}
|
|
464
|
+
catch (fetchErr) {
|
|
465
|
+
fileLog({ level: 'warn', event: 'review_comment_fetch_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: fetchErr instanceof Error ? fetchErr.message : String(fetchErr) });
|
|
466
|
+
throw fetchErr;
|
|
467
|
+
}
|
|
268
468
|
}
|
|
269
469
|
if (!reviewCommentBody) {
|
|
270
470
|
skipFix('no_review_comment');
|
|
@@ -272,17 +472,18 @@ export async function runWorkflow(ctx) {
|
|
|
272
472
|
}
|
|
273
473
|
// Vendor is resolved from the workflow step's reviewer field, same as review/recheck steps.
|
|
274
474
|
// Use 'origin' to fix with the same vendor that authored the PR (recommended default).
|
|
275
|
-
|
|
475
|
+
// resolveFixVendor extends resolveReviewer with a human-origin fallback so human-authored
|
|
476
|
+
// PRs don't silently skip when no explicit vendor is configured.
|
|
477
|
+
const { vendor, usedHumanFallback } = resolveFixVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
478
|
+
if (usedHumanFallback && vendor) {
|
|
479
|
+
fileLog({ level: 'info', event: 'fix_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: 'none', to: vendor, reason: 'human_origin' });
|
|
480
|
+
}
|
|
276
481
|
if (!vendor) {
|
|
277
482
|
skipFix('no_vendor');
|
|
278
483
|
continue;
|
|
279
484
|
}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
skipFix('codex_fix_unsupported');
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
const fixModel = resolveClaudeModel(config.quality);
|
|
485
|
+
const claudeFixModel = resolveClaudeModel(config.quality, config.vendors.claude);
|
|
486
|
+
const codexFixModel = resolveCodexModel(config.quality, config.vendors.codex);
|
|
286
487
|
// Guard: don't push more than MAX_CROSSCHECK_COMMITS per PR.
|
|
287
488
|
// Scope to commits ahead of base so long-lived branches (e.g. staging)
|
|
288
489
|
// don't count [crosscheck] commits from previously merged PRs.
|
|
@@ -293,29 +494,54 @@ export async function runWorkflow(ctx) {
|
|
|
293
494
|
continue;
|
|
294
495
|
}
|
|
295
496
|
onPhaseChange(`${vendor} fixing...`, { phase: 'fixing' });
|
|
296
|
-
// Per-step start timestamp covers attempt + retry + retry-delay wall time
|
|
297
|
-
// (the user perceives the whole interval as "the fix step").
|
|
298
497
|
const fixStepStart = Date.now();
|
|
299
498
|
let appliedCount = 0;
|
|
300
499
|
let fixTokensUsed;
|
|
301
500
|
let fixErr = undefined;
|
|
501
|
+
let activeVendor = vendor;
|
|
502
|
+
const runFix = async (v) => {
|
|
503
|
+
if (v === 'codex') {
|
|
504
|
+
return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec));
|
|
505
|
+
}
|
|
506
|
+
return runFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', config, 'default', ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec));
|
|
507
|
+
};
|
|
302
508
|
try {
|
|
303
509
|
;
|
|
304
|
-
({ appliedCount, tokensUsed: fixTokensUsed } = await
|
|
510
|
+
({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(vendor));
|
|
305
511
|
}
|
|
306
512
|
catch (err) {
|
|
307
|
-
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1 }, err);
|
|
308
|
-
|
|
513
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor }, err);
|
|
514
|
+
const fallbackVendor = resolveLimitFallbackVendor(vendor, effectiveType, config);
|
|
515
|
+
if (isSubscriptionLimitError(err)) {
|
|
516
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
517
|
+
ctx.onVendorLimit?.(vendor, fallbackVendor, reason, step.name);
|
|
518
|
+
}
|
|
519
|
+
if (fallbackVendor !== null && (isRetryableFixError(err) || isSubscriptionLimitError(err))) {
|
|
520
|
+
log(chalk.yellow(`⚠ ${vendor} fix failed — falling back to ${fallbackVendor}...`));
|
|
521
|
+
fileLog({ level: 'warn', event: 'fix_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: vendor, to: fallbackVendor, ...(isSubscriptionLimitError(err) && { reason: 'vendor_limit' }) });
|
|
522
|
+
try {
|
|
523
|
+
;
|
|
524
|
+
({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(fallbackVendor));
|
|
525
|
+
activeVendor = fallbackVendor;
|
|
526
|
+
}
|
|
527
|
+
catch (fallbackErr) {
|
|
528
|
+
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'fix', attempt: 1, vendor: fallbackVendor }, fallbackErr);
|
|
529
|
+
fixErr = fallbackErr;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
fixErr = err;
|
|
534
|
+
}
|
|
309
535
|
}
|
|
310
536
|
if (fixErr !== undefined && isRetryableFixError(fixErr)) {
|
|
311
537
|
log(chalk.yellow(`⚠ fix step failed — retrying in 2 min...`));
|
|
312
538
|
onPhaseChange('fix retry in 2 min...', { phase: 'fixing' });
|
|
313
539
|
fileLog({ level: 'info', event: 'fix_retry_scheduled', repo: `${owner}/${repoName}`, pr: prNumber });
|
|
314
540
|
await new Promise(resolve => setTimeout(resolve, FIX_RETRY_DELAY_MS));
|
|
315
|
-
onPhaseChange(`${
|
|
541
|
+
onPhaseChange(`${activeVendor} fixing (retry)...`, { phase: 'fixing' });
|
|
316
542
|
try {
|
|
317
543
|
;
|
|
318
|
-
({ appliedCount, tokensUsed: fixTokensUsed } = await
|
|
544
|
+
({ appliedCount, tokensUsed: fixTokensUsed } = await runFix(activeVendor));
|
|
319
545
|
fileLog({ level: 'info', event: 'fix_retry_succeeded', repo: `${owner}/${repoName}`, pr: prNumber });
|
|
320
546
|
fixErr = undefined;
|
|
321
547
|
}
|
|
@@ -325,7 +551,7 @@ export async function runWorkflow(ctx) {
|
|
|
325
551
|
}
|
|
326
552
|
}
|
|
327
553
|
if (fixErr !== undefined) {
|
|
328
|
-
skipFix('fix_error');
|
|
554
|
+
skipFix(isSubscriptionLimitError(fixErr) ? 'vendor_limit' : 'fix_error');
|
|
329
555
|
// Only notify for transient failures — auth errors are operator issues, not PR author issues
|
|
330
556
|
if (isRetryableFixError(fixErr)) {
|
|
331
557
|
try {
|
|
@@ -342,7 +568,7 @@ export async function runWorkflow(ctx) {
|
|
|
342
568
|
}
|
|
343
569
|
if (appliedCount === 0) {
|
|
344
570
|
onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: fixTokensUsed });
|
|
345
|
-
results[step.name] = { applied_count: 0 };
|
|
571
|
+
results[step.name] = { applied_count: 0, ...(fixTokensUsed !== undefined && { tokens_used: fixTokensUsed }), vendor };
|
|
346
572
|
continue;
|
|
347
573
|
}
|
|
348
574
|
const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
|
|
@@ -352,13 +578,14 @@ export async function runWorkflow(ctx) {
|
|
|
352
578
|
}
|
|
353
579
|
const deliveryMode = config.post_review.auto_fix.delivery.mode;
|
|
354
580
|
if (deliveryMode === 'commit') {
|
|
581
|
+
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
355
582
|
execSync('git add -A', { cwd: tmpDir });
|
|
356
583
|
execFileSync('git', [
|
|
357
584
|
'commit',
|
|
358
585
|
'-m',
|
|
359
586
|
`[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by Claude Code`,
|
|
360
587
|
'-m',
|
|
361
|
-
buildCommitTrailers({ reviewer:
|
|
588
|
+
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
362
589
|
], { cwd: tmpDir });
|
|
363
590
|
const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
364
591
|
execSync(`git push origin HEAD:${pr.head.ref}`, {
|
|
@@ -366,8 +593,27 @@ export async function runWorkflow(ctx) {
|
|
|
366
593
|
env: { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token },
|
|
367
594
|
});
|
|
368
595
|
ctx.crosscheckShas.add(newSha);
|
|
596
|
+
// Set a pending status on the pushed commit only when a review/recheck
|
|
597
|
+
// step follows in THIS workflow invocation — that step will release it.
|
|
598
|
+
// Fix-only runs (kickass `--steps fix`) must NOT acquire the lock here:
|
|
599
|
+
// doing so leaves a PENDING status that the separately-dispatched recheck
|
|
600
|
+
// (`--steps recheck`) sees as "in-progress" via checkRemoteLock and skips,
|
|
601
|
+
// permanently orphaning the PENDING status.
|
|
602
|
+
const currentStepIdx = steps.indexOf(step);
|
|
603
|
+
const hasRecheckAfterFix = steps.slice(currentStepIdx + 1).some(s => s.type === 'review' || s.type === 'recheck');
|
|
604
|
+
if (hasRecheckAfterFix) {
|
|
605
|
+
try {
|
|
606
|
+
const lockOctokit = createGithubClient(token);
|
|
607
|
+
await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
|
|
608
|
+
pushedShasNeedingRelease.push(newSha);
|
|
609
|
+
fixPushedShaRequiresRecheck = newSha;
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
369
615
|
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
370
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
|
|
616
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
371
617
|
// Post a summary comment so the silent commit push is visible on the timeline
|
|
372
618
|
// as a comment card. Best-effort — a failure here must not fail the run.
|
|
373
619
|
try {
|
|
@@ -382,9 +628,10 @@ export async function runWorkflow(ctx) {
|
|
|
382
628
|
catch (err) {
|
|
383
629
|
fileLog({ level: 'warn', event: 'fix_applied_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
|
|
384
630
|
}
|
|
385
|
-
results[step.name] = { applied_count: appliedCount };
|
|
631
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
386
632
|
}
|
|
387
633
|
else if (deliveryMode === 'pull_request') {
|
|
634
|
+
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
388
635
|
// Create a fix branch and open a PR targeting the original branch
|
|
389
636
|
const fixBranch = `fix/cr-${prNumber}-review-issues`;
|
|
390
637
|
execSync(`git checkout -b ${fixBranch}`, { cwd: tmpDir });
|
|
@@ -394,7 +641,7 @@ export async function runWorkflow(ctx) {
|
|
|
394
641
|
'-m',
|
|
395
642
|
`[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by Claude Code`,
|
|
396
643
|
'-m',
|
|
397
|
-
buildCommitTrailers({ reviewer:
|
|
644
|
+
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
398
645
|
], { cwd: tmpDir });
|
|
399
646
|
const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
400
647
|
execSync(`git push origin HEAD:${fixBranch}`, {
|
|
@@ -421,8 +668,8 @@ export async function runWorkflow(ctx) {
|
|
|
421
668
|
catch { /* label may not exist in this repo — skip */ }
|
|
422
669
|
}
|
|
423
670
|
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
424
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
|
|
425
|
-
results[step.name] = { applied_count: appliedCount };
|
|
671
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPr.number, tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
672
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
426
673
|
}
|
|
427
674
|
else {
|
|
428
675
|
// comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
|
|
@@ -437,8 +684,8 @@ export async function runWorkflow(ctx) {
|
|
|
437
684
|
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
438
685
|
}
|
|
439
686
|
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
440
|
-
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart });
|
|
441
|
-
results[step.name] = { applied_count: appliedCount };
|
|
687
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, delivery: 'comment', tokens_used: fixTokensUsed, duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
688
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
442
689
|
}
|
|
443
690
|
}
|
|
444
691
|
else if (effectiveType === 'conflict-resolve') {
|
|
@@ -510,7 +757,7 @@ export async function runWorkflow(ctx) {
|
|
|
510
757
|
skipConflictResolve('codex_conflict_resolve_unsupported');
|
|
511
758
|
continue;
|
|
512
759
|
}
|
|
513
|
-
const conflictResolveModel = resolveClaudeModel(config.quality);
|
|
760
|
+
const conflictResolveModel = resolveClaudeModel(config.quality, config.vendors.claude);
|
|
514
761
|
const isFork = pr.head.repo?.full_name !== pr.base.repo.full_name;
|
|
515
762
|
if (isFork) {
|
|
516
763
|
try {
|
|
@@ -539,7 +786,7 @@ export async function runWorkflow(ctx) {
|
|
|
539
786
|
let resolveTokensUsed;
|
|
540
787
|
try {
|
|
541
788
|
;
|
|
542
|
-
({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel));
|
|
789
|
+
({ appliedCount, resolvedPaths, tokensUsed: resolveTokensUsed } = await runConflictResolveStep(tmpDir, pr.title, step.instructions ?? '', conflictResolveModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.claude.timeout_sec)));
|
|
543
790
|
}
|
|
544
791
|
catch (err) {
|
|
545
792
|
logError({ repo: `${owner}/${repoName}`, pr: prNumber, phase: 'conflict-resolve', attempt: 1 }, err);
|
|
@@ -547,7 +794,7 @@ export async function runWorkflow(ctx) {
|
|
|
547
794
|
execSync('git merge --abort', { cwd: tmpDir });
|
|
548
795
|
}
|
|
549
796
|
catch { /* ignore */ }
|
|
550
|
-
skipConflictResolve('resolve_error');
|
|
797
|
+
skipConflictResolve(isSubscriptionLimitError(err) ? 'vendor_limit' : 'resolve_error');
|
|
551
798
|
continue;
|
|
552
799
|
}
|
|
553
800
|
if (appliedCount === 0) {
|
|
@@ -556,7 +803,7 @@ export async function runWorkflow(ctx) {
|
|
|
556
803
|
}
|
|
557
804
|
catch { /* ignore */ }
|
|
558
805
|
onPhaseChange('', { phase: 'fixed', fixCount: 0, fixTokens: resolveTokensUsed });
|
|
559
|
-
results[step.name] = { applied_count: 0 };
|
|
806
|
+
results[step.name] = { applied_count: 0, ...(resolveTokensUsed !== undefined && { tokens_used: resolveTokensUsed }), vendor };
|
|
560
807
|
continue;
|
|
561
808
|
}
|
|
562
809
|
// P2: Verify every conflict region was resolved before committing. Scope the
|
|
@@ -646,7 +893,7 @@ export async function runWorkflow(ctx) {
|
|
|
646
893
|
fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
|
|
647
894
|
}
|
|
648
895
|
onPhaseChange('conflicts resolved ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: resolveTokensUsed });
|
|
649
|
-
fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, duration_ms: Date.now() - conflictResolveStepStart });
|
|
896
|
+
fileLog({ level: 'info', event: 'conflict_resolve_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor, conflicts_resolved: conflictedFiles.length, sha: newSha, tokens_used: resolveTokensUsed, duration_ms: Date.now() - conflictResolveStepStart, ...triggerField });
|
|
650
897
|
// Post a summary comment so the silent merge-commit push is visible on the
|
|
651
898
|
// timeline as a comment card. Best-effort — a failure here must not fail the run.
|
|
652
899
|
// Prefer the resolver's actual rewrite set; fall back to the originally-conflicted
|
|
@@ -664,20 +911,54 @@ export async function runWorkflow(ctx) {
|
|
|
664
911
|
catch (err) {
|
|
665
912
|
fileLog({ level: 'warn', event: 'conflict_resolved_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
|
|
666
913
|
}
|
|
667
|
-
results[step.name] = { applied_count: appliedCount };
|
|
914
|
+
results[step.name] = { applied_count: appliedCount, ...(resolveTokensUsed !== undefined && { tokens_used: resolveTokensUsed }), vendor };
|
|
668
915
|
}
|
|
669
916
|
}
|
|
670
917
|
const verdict = Object.values(results).reverse().find(r => r.verdict !== undefined)?.verdict ?? null;
|
|
671
|
-
|
|
918
|
+
const fixAppliedCount = Object.values(results).reduce((acc, r) => {
|
|
919
|
+
if (r.applied_count === undefined)
|
|
920
|
+
return acc;
|
|
921
|
+
return (acc ?? 0) + r.applied_count;
|
|
922
|
+
}, undefined);
|
|
923
|
+
const latestReviewResult = Object.values(results).reverse().find(r => r.commentBody !== undefined);
|
|
924
|
+
return {
|
|
925
|
+
verdict: verdict ?? null,
|
|
926
|
+
fixAppliedCount,
|
|
927
|
+
...(latestReviewResult?.commentBody && {
|
|
928
|
+
latestReviewComment: {
|
|
929
|
+
body: latestReviewResult.commentBody,
|
|
930
|
+
...(latestReviewResult.commentId !== undefined && { id: latestReviewResult.commentId }),
|
|
931
|
+
},
|
|
932
|
+
}),
|
|
933
|
+
};
|
|
672
934
|
}
|
|
673
935
|
catch (err) {
|
|
674
936
|
workflowFailed = true;
|
|
675
937
|
throw err;
|
|
676
938
|
}
|
|
677
939
|
finally {
|
|
678
|
-
if (pushedShasNeedingRelease.length > 0) {
|
|
940
|
+
if (pushedShasNeedingRelease.length > 0 || fixPushedShaRequiresRecheck !== null) {
|
|
679
941
|
const lockOctokit = createGithubClient(token);
|
|
680
942
|
const outcome = workflowFailed ? 'failure' : 'success';
|
|
943
|
+
// A recheck step was expected after the fix but was skipped (by `when`,
|
|
944
|
+
// no_reviewer, max_rounds, etc.). The fix-pushed SHA is in
|
|
945
|
+
// pushedShasNeedingRelease but no recheck ran to confirm the commit, so
|
|
946
|
+
// releasing it as `success` would mislead branch protection. Release it
|
|
947
|
+
// as `failure` instead so the commit remains unreviewed until the recheck
|
|
948
|
+
// runs in a subsequent invocation (e.g. via `crosscheck run --steps recheck`).
|
|
949
|
+
if (fixPushedShaRequiresRecheck !== null) {
|
|
950
|
+
const unrecheckedSha = fixPushedShaRequiresRecheck;
|
|
951
|
+
fixPushedShaRequiresRecheck = null;
|
|
952
|
+
const idx = pushedShasNeedingRelease.indexOf(unrecheckedSha);
|
|
953
|
+
if (idx !== -1)
|
|
954
|
+
pushedShasNeedingRelease.splice(idx, 1);
|
|
955
|
+
try {
|
|
956
|
+
await releaseRemoteLock(lockOctokit, owner, repoName, unrecheckedSha, 'failure');
|
|
957
|
+
}
|
|
958
|
+
catch (err) {
|
|
959
|
+
fileLog({ level: 'warn', event: 'pushed_sha_release_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: unrecheckedSha, error: err instanceof Error ? err.message : String(err) });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
681
962
|
// Drain via shift() so each released sha is synchronously removed from
|
|
682
963
|
// the shared array. The command-layer SIGINT/SIGTERM handler iterates
|
|
683
964
|
// the same array — if a late signal arrives after this finally has
|
|
@@ -704,6 +985,8 @@ export async function runWorkflow(ctx) {
|
|
|
704
985
|
owner, repoName, prNumber,
|
|
705
986
|
workflowId, workflowStart, stepsRun, results, workflowFailed,
|
|
706
987
|
round: ctx.round,
|
|
988
|
+
trigger: ctx.trigger,
|
|
989
|
+
qualityTier: config.quality.tier,
|
|
707
990
|
}));
|
|
708
991
|
}
|
|
709
992
|
}
|