@humanbased/crosscheck 1.2.0-beta.81 → 1.3.0-beta.100
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/README.md +2 -1
- package/crosscheck.config.example.yml +23 -2
- package/dist/__tests__/adoption.test.d.ts +2 -0
- package/dist/__tests__/adoption.test.d.ts.map +1 -0
- package/dist/__tests__/adoption.test.js +175 -0
- package/dist/__tests__/adoption.test.js.map +1 -0
- package/dist/__tests__/auto-fix-branch.test.d.ts +2 -0
- package/dist/__tests__/auto-fix-branch.test.d.ts.map +1 -0
- package/dist/__tests__/auto-fix-branch.test.js +143 -0
- package/dist/__tests__/auto-fix-branch.test.js.map +1 -0
- package/dist/__tests__/board.test.js +2 -0
- package/dist/__tests__/board.test.js.map +1 -1
- package/dist/__tests__/codex-env.test.d.ts +2 -0
- package/dist/__tests__/codex-env.test.d.ts.map +1 -0
- package/dist/__tests__/codex-env.test.js +70 -0
- package/dist/__tests__/codex-env.test.js.map +1 -0
- package/dist/__tests__/codex.test.js +89 -1
- package/dist/__tests__/codex.test.js.map +1 -1
- package/dist/__tests__/commit-subject.test.d.ts +2 -0
- package/dist/__tests__/commit-subject.test.d.ts.map +1 -0
- package/dist/__tests__/commit-subject.test.js +72 -0
- package/dist/__tests__/commit-subject.test.js.map +1 -0
- package/dist/__tests__/credential-free-origin.test.d.ts +2 -0
- package/dist/__tests__/credential-free-origin.test.d.ts.map +1 -0
- package/dist/__tests__/credential-free-origin.test.js +59 -0
- package/dist/__tests__/credential-free-origin.test.js.map +1 -0
- package/dist/__tests__/detector.test.js +87 -1
- package/dist/__tests__/detector.test.js.map +1 -1
- package/dist/__tests__/error-classification.test.js +34 -0
- package/dist/__tests__/error-classification.test.js.map +1 -1
- package/dist/__tests__/fix.test.js +37 -1
- package/dist/__tests__/fix.test.js.map +1 -1
- package/dist/__tests__/harness-section.test.d.ts +2 -0
- package/dist/__tests__/harness-section.test.d.ts.map +1 -0
- package/dist/__tests__/harness-section.test.js +46 -0
- package/dist/__tests__/harness-section.test.js.map +1 -0
- package/dist/__tests__/kickass.test.js +31 -0
- package/dist/__tests__/kickass.test.js.map +1 -1
- package/dist/__tests__/linear-worker.test.js.map +1 -1
- package/dist/__tests__/loader.test.js +2 -2
- package/dist/__tests__/loader.test.js.map +1 -1
- package/dist/__tests__/onboard-preservation.test.js +0 -1
- package/dist/__tests__/onboard-preservation.test.js.map +1 -1
- package/dist/__tests__/optimize.test.js +1 -1
- package/dist/__tests__/optimize.test.js.map +1 -1
- package/dist/__tests__/pr-status.test.js +213 -3
- package/dist/__tests__/pr-status.test.js.map +1 -1
- package/dist/__tests__/pr-workflow-state.test.js +247 -7
- package/dist/__tests__/pr-workflow-state.test.js.map +1 -1
- package/dist/__tests__/repository-guidance.test.js +21 -21
- package/dist/__tests__/repository-guidance.test.js.map +1 -1
- package/dist/__tests__/reviewed-sha.test.d.ts +2 -0
- package/dist/__tests__/reviewed-sha.test.d.ts.map +1 -0
- package/dist/__tests__/reviewed-sha.test.js +116 -0
- package/dist/__tests__/reviewed-sha.test.js.map +1 -0
- package/dist/__tests__/runner.test.js +178 -2
- package/dist/__tests__/runner.test.js.map +1 -1
- package/dist/__tests__/skill-broker.test.js +44 -2
- package/dist/__tests__/skill-broker.test.js.map +1 -1
- package/dist/__tests__/superseded-fix-pr.test.d.ts +2 -0
- package/dist/__tests__/superseded-fix-pr.test.d.ts.map +1 -0
- package/dist/__tests__/superseded-fix-pr.test.js +225 -0
- package/dist/__tests__/superseded-fix-pr.test.js.map +1 -0
- package/dist/__tests__/webhook.test.js +60 -0
- package/dist/__tests__/webhook.test.js.map +1 -1
- package/dist/cli.js +8 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/adoption.d.ts +6 -0
- package/dist/commands/adoption.d.ts.map +1 -0
- package/dist/commands/adoption.js +132 -0
- package/dist/commands/adoption.js.map +1 -0
- package/dist/commands/detect-step.d.ts.map +1 -1
- package/dist/commands/detect-step.js +5 -1
- package/dist/commands/detect-step.js.map +1 -1
- package/dist/commands/diagnose.d.ts.map +1 -1
- package/dist/commands/diagnose.js +2 -2
- package/dist/commands/diagnose.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/issue.js +1 -1
- package/dist/commands/issue.js.map +1 -1
- package/dist/commands/kickass.d.ts +1 -1
- package/dist/commands/kickass.d.ts.map +1 -1
- package/dist/commands/kickass.js +36 -22
- package/dist/commands/kickass.js.map +1 -1
- package/dist/commands/linear-test.js.map +1 -1
- package/dist/commands/onboard.d.ts.map +1 -1
- package/dist/commands/onboard.js +30 -3
- package/dist/commands/onboard.js.map +1 -1
- package/dist/commands/optimize.d.ts.map +1 -1
- package/dist/commands/optimize.js +1 -0
- package/dist/commands/optimize.js.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands/review.js +1 -1
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +40 -4
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +6 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/watch.d.ts.map +1 -1
- package/dist/commands/watch.js +132 -61
- package/dist/commands/watch.js.map +1 -1
- package/dist/config/loader.js +6 -6
- package/dist/config/loader.js.map +1 -1
- package/dist/config/schema.d.ts +8 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +19 -2
- package/dist/config/schema.js.map +1 -1
- package/dist/github/detector.d.ts.map +1 -1
- package/dist/github/detector.js +28 -1
- package/dist/github/detector.js.map +1 -1
- package/dist/github/merge.d.ts +0 -5
- package/dist/github/merge.d.ts.map +1 -1
- package/dist/github/merge.js +0 -9
- package/dist/github/merge.js.map +1 -1
- package/dist/github/reviewed-sha.d.ts +13 -0
- package/dist/github/reviewed-sha.d.ts.map +1 -0
- package/dist/github/reviewed-sha.js +62 -0
- package/dist/github/reviewed-sha.js.map +1 -0
- package/dist/github/superseded-fix-pr.d.ts +19 -0
- package/dist/github/superseded-fix-pr.d.ts.map +1 -0
- package/dist/github/superseded-fix-pr.js +97 -0
- package/dist/github/superseded-fix-pr.js.map +1 -0
- package/dist/github/webhook.d.ts +4 -1
- package/dist/github/webhook.d.ts.map +1 -1
- package/dist/github/webhook.js +8 -1
- package/dist/github/webhook.js.map +1 -1
- package/dist/lib/adoption.d.ts +64 -0
- package/dist/lib/adoption.d.ts.map +1 -0
- package/dist/lib/adoption.js +165 -0
- package/dist/lib/adoption.js.map +1 -0
- package/dist/lib/annotation.js +0 -7
- package/dist/lib/annotation.js.map +1 -1
- package/dist/lib/auto-fix-branch.d.ts +35 -0
- package/dist/lib/auto-fix-branch.d.ts.map +1 -0
- package/dist/lib/auto-fix-branch.js +88 -0
- package/dist/lib/auto-fix-branch.js.map +1 -0
- package/dist/lib/clone.d.ts +1 -0
- package/dist/lib/clone.d.ts.map +1 -1
- package/dist/lib/clone.js +38 -1
- package/dist/lib/clone.js.map +1 -1
- package/dist/lib/comment-bodies.d.ts.map +1 -1
- package/dist/lib/comment-bodies.js +6 -3
- package/dist/lib/comment-bodies.js.map +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +17 -1
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/pr-picker.d.ts +1 -1
- package/dist/lib/pr-picker.d.ts.map +1 -1
- package/dist/lib/pr-picker.js +10 -5
- package/dist/lib/pr-picker.js.map +1 -1
- package/dist/lib/pr-status.d.ts +2 -1
- package/dist/lib/pr-status.d.ts.map +1 -1
- package/dist/lib/pr-status.js +70 -6
- package/dist/lib/pr-status.js.map +1 -1
- package/dist/lib/pr-workflow-state.d.ts +17 -1
- package/dist/lib/pr-workflow-state.d.ts.map +1 -1
- package/dist/lib/pr-workflow-state.js +110 -5
- package/dist/lib/pr-workflow-state.js.map +1 -1
- package/dist/lib/repo-picker.d.ts.map +1 -1
- package/dist/lib/repo-picker.js +0 -1
- package/dist/lib/repo-picker.js.map +1 -1
- package/dist/lib/runner.d.ts +31 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/lib/runner.js +460 -58
- package/dist/lib/runner.js.map +1 -1
- package/dist/lib/vendor.d.ts +1 -0
- package/dist/lib/vendor.d.ts.map +1 -1
- package/dist/lib/vendor.js +6 -0
- package/dist/lib/vendor.js.map +1 -1
- package/dist/lib/workflow.d.ts +4 -0
- package/dist/lib/workflow.d.ts.map +1 -1
- package/dist/lib/workflow.js +4 -1
- package/dist/lib/workflow.js.map +1 -1
- package/dist/linear/comment.d.ts.map +1 -1
- package/dist/linear/comment.js.map +1 -1
- package/dist/reviewers/address.d.ts +1 -1
- package/dist/reviewers/address.d.ts.map +1 -1
- package/dist/reviewers/address.js +1 -1
- package/dist/reviewers/address.js.map +1 -1
- package/dist/reviewers/codex-env.d.ts +12 -0
- package/dist/reviewers/codex-env.d.ts.map +1 -0
- package/dist/reviewers/codex-env.js +54 -0
- package/dist/reviewers/codex-env.js.map +1 -0
- package/dist/reviewers/codex.d.ts +16 -1
- package/dist/reviewers/codex.d.ts.map +1 -1
- package/dist/reviewers/codex.js +110 -33
- package/dist/reviewers/codex.js.map +1 -1
- package/dist/reviewers/fix.d.ts +1 -1
- package/dist/reviewers/fix.d.ts.map +1 -1
- package/dist/reviewers/fix.js +16 -6
- package/dist/reviewers/fix.js.map +1 -1
- package/dist/skills/broker.d.ts +2 -1
- package/dist/skills/broker.d.ts.map +1 -1
- package/dist/skills/broker.js +28 -2
- package/dist/skills/broker.js.map +1 -1
- package/docs/metrics.md +115 -0
- package/docs/trust.md +159 -0
- package/get-started.md +115 -5
- package/package.json +3 -1
package/dist/lib/runner.js
CHANGED
|
@@ -4,12 +4,15 @@ import { readFileSync } from 'fs';
|
|
|
4
4
|
import { join } from 'path';
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { filterStepsByTypes } from './repo-workflow.js';
|
|
7
|
+
import { vendorDisplayName } from '../lib/vendor.js';
|
|
7
8
|
import { runCodexReview } from '../reviewers/codex.js';
|
|
8
9
|
import { runClaudeReview } from '../reviewers/claude.js';
|
|
9
10
|
import { runFixStep, runCodexFixStep } from '../reviewers/fix.js';
|
|
10
11
|
import { runConflictResolveStep, findConflictedFiles } from '../reviewers/conflict-resolve.js';
|
|
11
12
|
import { parseVerdict, prependVerdictToComment, NULL_VERDICT_WARNING, applySeverityGate, SEVERITY_GATE_NOTE } from '../lib/verdict.js';
|
|
12
13
|
import { createGithubClient, postReviewComment, getLastCrossCheckCommentId, getLastCrossCheckReviewComment } from '../github/client.js';
|
|
14
|
+
import { autoFixBranchName, autoFixPRIntro, sourcePRHasMerged } from '../github/superseded-fix-pr.js';
|
|
15
|
+
import { verifyReviewedSha, isVerifiedReviewedSha, reviewedShaRejection } from '../github/reviewed-sha.js';
|
|
13
16
|
import { resolveLinearAuth, withWorker } from '../linear/identity.js';
|
|
14
17
|
import { notifyLinear } from '../linear/notify.js';
|
|
15
18
|
import { shouldPostToLinear } from '../linear/comment.js';
|
|
@@ -21,6 +24,8 @@ import { resolveClaudeModel, resolveCodexModel } from '../lib/review-models.js';
|
|
|
21
24
|
import { resolveReviewStrategy, escalate, clampToLevels } from './review-strategy.js';
|
|
22
25
|
import { CLAUDE_EFFORT_LEVELS, CODEX_EFFORT_LEVELS } from '../config/schema.js';
|
|
23
26
|
import { buildStepIdentityFields } from '../lib/event-fields.js';
|
|
27
|
+
import { planAutoFixDelivery, forceWithLeaseArgs, parseLsRemoteOid, isLeaseRejection, assessFixBranchOwnership, isInvalidBaseError } from '../lib/auto-fix-branch.js';
|
|
28
|
+
import { prOpenToVerdictMs } from '../lib/adoption.js';
|
|
24
29
|
import { buildAttributionFooter, buildFixAppliedCommentBody, buildFixFailedCommentBody, buildConflictResolvedCommentBody, buildRetriedReviewBanner } from '../lib/comment-bodies.js';
|
|
25
30
|
import { linearWritePossible, loadWorkflow, loadHarnessSection, evaluateWhen } from '../lib/workflow.js';
|
|
26
31
|
import { isSubscriptionLimitError, isVendorUnavailableError } from '../lib/smart-switch.js';
|
|
@@ -31,7 +36,6 @@ import { formatSkillAttribution } from '../skills/attribution.js';
|
|
|
31
36
|
const MAX_CROSSCHECK_COMMITS = 5;
|
|
32
37
|
const FIX_RETRY_DELAY_MS = 2 * 60 * 1000;
|
|
33
38
|
const REVIEW_RETRY_DELAY_MS = 2 * 60 * 1000;
|
|
34
|
-
const GIT_PUSH_RETRY_DELAY_MS = 3000;
|
|
35
39
|
// Per-vendor configured timeout (seconds) → execa milliseconds, or undefined when
|
|
36
40
|
// unset so the reviewer falls back to its built-in default. A per-run override
|
|
37
41
|
// (ctx.overrideTimeoutMs, set by --timeout / --crazy / --halfcrazy) always wins
|
|
@@ -138,6 +142,52 @@ export function exceedsMaxRounds(effectiveType, originalStepType, maxRounds, rou
|
|
|
138
142
|
export function anyFixApplied(results) {
|
|
139
143
|
return Object.values(results).some(r => (r.applied_count ?? 0) > 0);
|
|
140
144
|
}
|
|
145
|
+
export function resolveFixLanding(deliveryMode) {
|
|
146
|
+
switch (deliveryMode) {
|
|
147
|
+
case 'comment': return 'comment';
|
|
148
|
+
case 'commit': return 'branch';
|
|
149
|
+
case 'pull_request': return 'branch-then-separate-pr';
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// stepsRun holds every step the runner dispatched, skips included; results holds
|
|
153
|
+
// what each one did. Split them so callers can report a run where nothing
|
|
154
|
+
// happened without re-deriving the reasons from the log file.
|
|
155
|
+
export function summariseStepOutcomes(stepsRun, results) {
|
|
156
|
+
const outcomes = { ran: [], skipped: [] };
|
|
157
|
+
for (const name of new Set(stepsRun)) {
|
|
158
|
+
const result = results[name];
|
|
159
|
+
if (result?.skipped)
|
|
160
|
+
outcomes.skipped.push({ step: name, reason: result.skipReason ?? 'unknown' });
|
|
161
|
+
else
|
|
162
|
+
outcomes.ran.push(name);
|
|
163
|
+
}
|
|
164
|
+
return outcomes;
|
|
165
|
+
}
|
|
166
|
+
// Accumulates outcomes across the fix→recheck rounds of one invocation, so the
|
|
167
|
+
// completion line reports whether the run as a whole did work: a first round
|
|
168
|
+
// that skips everything followed by a round that applies a fix is not a
|
|
169
|
+
// "no step ran" run.
|
|
170
|
+
//
|
|
171
|
+
// Running once wins over skipping any number of times — the step demonstrably
|
|
172
|
+
// happened. A step skipped in several rounds is reported once, carrying its
|
|
173
|
+
// latest reason: that is the state the run ended in, and listing the same step
|
|
174
|
+
// several times reads as several distinct problems.
|
|
175
|
+
export function mergeStepOutcomes(base, next) {
|
|
176
|
+
if (!base)
|
|
177
|
+
return next;
|
|
178
|
+
if (!next)
|
|
179
|
+
return base;
|
|
180
|
+
const ran = [...new Set([...base.ran, ...next.ran])];
|
|
181
|
+
const ranSet = new Set(ran);
|
|
182
|
+
// Map.set on an existing key overwrites the reason but keeps the original
|
|
183
|
+
// position, so order stays first-seen while the reason stays last-seen.
|
|
184
|
+
const skipped = new Map();
|
|
185
|
+
for (const entry of [...base.skipped, ...next.skipped]) {
|
|
186
|
+
if (!ranSet.has(entry.step))
|
|
187
|
+
skipped.set(entry.step, entry.reason);
|
|
188
|
+
}
|
|
189
|
+
return { ran, skipped: [...skipped].map(([step, reason]) => ({ step, reason })) };
|
|
190
|
+
}
|
|
141
191
|
function countComments(reviewText) {
|
|
142
192
|
const bullets = (reviewText.match(/^[-*•]\s/gm) ?? []).length;
|
|
143
193
|
const numbered = (reviewText.match(/^\d+\.\s/gm) ?? []).length;
|
|
@@ -178,16 +228,54 @@ function resolveLimitFallbackVendor(failedVendor, stepType, config) {
|
|
|
178
228
|
const fallback = failedVendor === 'claude' ? 'codex' : 'claude';
|
|
179
229
|
return config.vendors[fallback].enabled && supportsStep(fallback, stepType) ? fallback : null;
|
|
180
230
|
}
|
|
181
|
-
//
|
|
231
|
+
// ─── commit subjects ──────────────────────────────────────────────────────────
|
|
232
|
+
// The vendor is a parameter, not a constant: the vendor that ran the step is
|
|
233
|
+
// known at every call site, and a subject crediting a different one contradicts
|
|
234
|
+
// the Crosscheck-Reviewer trailer on its own commit.
|
|
235
|
+
// The `[crosscheck]` prefix and the step word after it are parsed —
|
|
236
|
+
// countCrosscheckCommitsForPR greps the prefix to enforce the commit cap — so
|
|
237
|
+
// only the trailing attribution varies.
|
|
238
|
+
export function fixCommitSubject(appliedCount, vendor) {
|
|
239
|
+
return `[crosscheck] fix: apply ${appliedCount} fix${appliedCount !== 1 ? 'es' : ''} from code review — by ${vendorDisplayName(vendor)}`;
|
|
240
|
+
}
|
|
241
|
+
export function fixPRCommitSubject(prNumber, vendor) {
|
|
242
|
+
return `[crosscheck] fix: apply CR fixes from review of PR #${prNumber} — by ${vendorDisplayName(vendor)}`;
|
|
243
|
+
}
|
|
244
|
+
export function conflictResolveCommitSubject(conflictCount, vendor) {
|
|
245
|
+
return `[crosscheck] resolve: resolve ${conflictCount} conflict${conflictCount !== 1 ? 's' : ''} — by ${vendorDisplayName(vendor)}`;
|
|
246
|
+
}
|
|
247
|
+
// Extends resolveReviewer with a human-origin fallback for the steps that write
|
|
248
|
+
// code (fix, conflict-resolve).
|
|
182
249
|
// Scoped to reviewer: 'origin' only — other reviewer types (claude, codex, auto)
|
|
183
250
|
// already encode explicit vendor intent and need no fallback.
|
|
184
251
|
// When origin is 'human' and no vendor resolved, honours routing.fallback_reviewer
|
|
185
|
-
// so the
|
|
186
|
-
// 'auto'
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
252
|
+
// so the step respects the same routing intent as the review step.
|
|
253
|
+
// 'auto' resolves against the vendors that can actually run stepType, so a step
|
|
254
|
+
// only one vendor supports doesn't fall back to the other and skip a line later.
|
|
255
|
+
// An explicit 'claude'/'codex' is an operator decision and is honoured as written
|
|
256
|
+
// even when that vendor cannot run the step — the caller then reports a precise
|
|
257
|
+
// unsupported-step skip instead of silently substituting a different vendor.
|
|
258
|
+
// null disables the fallback entirely.
|
|
259
|
+
function resolveStepVendor(stepType, stepReviewer, origin, config, fallback) {
|
|
190
260
|
const vendor = resolveReviewer(stepReviewer, origin, config, fallback);
|
|
261
|
+
// Origin detection can assign a vendor that cannot run the step: conflict
|
|
262
|
+
// resolution is Claude-only, so a Codex-origin PR (reviewer: 'origin', origin:
|
|
263
|
+
// 'codex') resolves to 'codex' and the dispatch skips it as unsupported — the
|
|
264
|
+
// conflicts never get resolved. (#284's `Crosscheck-Reviewer: codex` detection
|
|
265
|
+
// introduced this: these crosscheck-authored fix PRs used to detect as 'human'
|
|
266
|
+
// and the auto fallback picked Claude.) Substitute a capable, enabled vendor.
|
|
267
|
+
// Scoped to origin-derived assignment only — an explicit reviewer: claude|codex,
|
|
268
|
+
// reviewer: auto, or routing.fallback_reviewer is an operator decision, left as
|
|
269
|
+
// written so the caller can report the precise unsupported-step skip.
|
|
270
|
+
if (stepReviewer === 'origin' &&
|
|
271
|
+
(origin === 'claude' || origin === 'codex') &&
|
|
272
|
+
vendor !== null &&
|
|
273
|
+
!supportsStep(vendor, stepType)) {
|
|
274
|
+
const capable = vendor === 'claude' ? 'codex' : 'claude';
|
|
275
|
+
if (config.vendors[capable].enabled && supportsStep(capable, stepType)) {
|
|
276
|
+
return { vendor: capable, usedHumanFallback: false, substitutedOriginVendor: vendor };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
191
279
|
if (vendor !== null || origin !== 'human' || stepReviewer !== 'origin') {
|
|
192
280
|
return { vendor, usedHumanFallback: false };
|
|
193
281
|
}
|
|
@@ -198,13 +286,25 @@ export function resolveFixVendor(stepReviewer, origin, config, fallback) {
|
|
|
198
286
|
else if (fb === 'codex')
|
|
199
287
|
humanFallback = config.vendors.codex.enabled ? 'codex' : null;
|
|
200
288
|
else if (fb !== null) {
|
|
201
|
-
// 'auto': prefer codex then claude, same as resolveReviewer's auto path
|
|
202
|
-
|
|
289
|
+
// 'auto': prefer codex then claude, same as resolveReviewer's auto path,
|
|
290
|
+
// narrowed to vendors that support this step type.
|
|
291
|
+
const usable = (v) => config.vendors[v].enabled && supportsStep(v, stepType);
|
|
292
|
+
humanFallback = usable('codex') ? 'codex' : usable('claude') ? 'claude' : null;
|
|
203
293
|
}
|
|
204
294
|
if (!humanFallback)
|
|
205
295
|
return { vendor: null, usedHumanFallback: false };
|
|
206
296
|
return { vendor: humanFallback, usedHumanFallback: true };
|
|
207
297
|
}
|
|
298
|
+
// Exported so callers can detect when the fallback was applied (e.g. for logging).
|
|
299
|
+
export function resolveFixVendor(stepReviewer, origin, config, fallback) {
|
|
300
|
+
return resolveStepVendor('fix', stepReviewer, origin, config, fallback);
|
|
301
|
+
}
|
|
302
|
+
// The default workflow gives conflict-resolve `reviewer: origin`, so every PR
|
|
303
|
+
// crosscheck cannot attribute resolved to null here and skipped with 'no_vendor'
|
|
304
|
+
// — the fix step honoured routing.fallback_reviewer, this one did not.
|
|
305
|
+
export function resolveConflictResolveVendor(stepReviewer, origin, config, fallback) {
|
|
306
|
+
return resolveStepVendor('conflict-resolve', stepReviewer, origin, config, fallback);
|
|
307
|
+
}
|
|
208
308
|
// ─── pr_complexity helpers ────────────────────────────────────────────────────
|
|
209
309
|
const EXT_LANG = {
|
|
210
310
|
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
|
@@ -456,7 +556,7 @@ async function pushWithNonFastForwardHandling(params) {
|
|
|
456
556
|
const { tmpDir, branch, token, log, fileLog, owner, repoName, prNumber } = params;
|
|
457
557
|
const env = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
|
|
458
558
|
try {
|
|
459
|
-
|
|
559
|
+
execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
|
|
460
560
|
}
|
|
461
561
|
catch (pushErr) {
|
|
462
562
|
const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
@@ -472,11 +572,11 @@ async function pushWithNonFastForwardHandling(params) {
|
|
|
472
572
|
log(chalk.yellow(`⚠ push rejected (non-fast-forward) — fetching latest and rebasing...`));
|
|
473
573
|
try {
|
|
474
574
|
// Fetch the latest state of the branch
|
|
475
|
-
|
|
575
|
+
execFileSync('git', ['fetch', 'origin', branch], { cwd: tmpDir, env, stdio: 'pipe' });
|
|
476
576
|
// Rebase our changes onto the latest branch state
|
|
477
|
-
|
|
577
|
+
execFileSync('git', ['rebase', `origin/${branch}`], { cwd: tmpDir, env, stdio: 'pipe' });
|
|
478
578
|
// Retry the push
|
|
479
|
-
|
|
579
|
+
execFileSync('git', ['push', 'origin', `HEAD:${branch}`], { cwd: tmpDir, env });
|
|
480
580
|
fileLog({
|
|
481
581
|
level: 'info',
|
|
482
582
|
event: 'push_rebase_succeeded',
|
|
@@ -692,7 +792,7 @@ export async function runWorkflow(ctx) {
|
|
|
692
792
|
const effectiveType = getEffectiveStepType(step.type, ctx.isRecheckRun === true);
|
|
693
793
|
if (exceedsMaxRounds(effectiveType, step.type, ctx.overrideMaxRounds ?? step.max_rounds, ctx.round)) {
|
|
694
794
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'max_rounds' });
|
|
695
|
-
results[step.name] = { skipped: true };
|
|
795
|
+
results[step.name] = { skipped: true, skipReason: 'max_rounds' };
|
|
696
796
|
if (effectiveType === 'fix')
|
|
697
797
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
698
798
|
else if (effectiveType === 'recheck')
|
|
@@ -704,7 +804,7 @@ export async function runWorkflow(ctx) {
|
|
|
704
804
|
// Evaluate when condition — skip step if false
|
|
705
805
|
if (step.when && !evaluateWhen(step.when, results)) {
|
|
706
806
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'when_condition' });
|
|
707
|
-
results[step.name] = { skipped: true };
|
|
807
|
+
results[step.name] = { skipped: true, skipReason: 'when_condition' };
|
|
708
808
|
if (effectiveType === 'fix')
|
|
709
809
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
710
810
|
else if (effectiveType === 'recheck')
|
|
@@ -718,7 +818,7 @@ export async function runWorkflow(ctx) {
|
|
|
718
818
|
// review coerced to a recheck (isRecheckRun) is never blocked here.
|
|
719
819
|
if (step.type === 'recheck' && reviewRanThisSession && !anyFixApplied(results)) {
|
|
720
820
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_change_since_review' });
|
|
721
|
-
results[step.name] = { skipped: true };
|
|
821
|
+
results[step.name] = { skipped: true, skipReason: 'no_change_since_review' };
|
|
722
822
|
onPhaseChange('', { phase: 'rechecked' });
|
|
723
823
|
continue;
|
|
724
824
|
}
|
|
@@ -727,7 +827,7 @@ export async function runWorkflow(ctx) {
|
|
|
727
827
|
let reviewer = resolveReviewer(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
728
828
|
if (!reviewer) {
|
|
729
829
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason: 'no_reviewer' });
|
|
730
|
-
results[step.name] = { skipped: true };
|
|
830
|
+
results[step.name] = { skipped: true, skipReason: 'no_reviewer' };
|
|
731
831
|
continue;
|
|
732
832
|
}
|
|
733
833
|
// The recheck step is confirmed to run — clear the pending-recheck guard
|
|
@@ -756,7 +856,7 @@ export async function runWorkflow(ctx) {
|
|
|
756
856
|
const runReviewWithVendor = async (candidate) => {
|
|
757
857
|
if (candidate === 'codex') {
|
|
758
858
|
;
|
|
759
|
-
({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession));
|
|
859
|
+
({ review: rawReview, tokensUsed, model, effort, retried } = await runCodexReview(tmpDir, pr.base.ref, pr.title, quality, codexVendor, step.instructions, undefined, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec), log, ctx.issueContext, skillSession, config.skills.codex_full_access));
|
|
760
860
|
inputTokens = undefined;
|
|
761
861
|
outputTokens = undefined;
|
|
762
862
|
}
|
|
@@ -851,7 +951,12 @@ export async function runWorkflow(ctx) {
|
|
|
851
951
|
? `${buildRetriedReviewBanner(retried.timeoutMs, retried.delayMs)}\n\n${baseBody}`
|
|
852
952
|
: baseBody;
|
|
853
953
|
const commentCount = countComments(rawReview);
|
|
854
|
-
|
|
954
|
+
// How long the PR waited for a verdict, measured from when its author opened
|
|
955
|
+
// it — the number a team feels, as distinct from duration_ms (how long the
|
|
956
|
+
// reviewer ran). Omitted rather than guessed when the PR event carried no
|
|
957
|
+
// created_at, so the metric never mixes real latencies with invented ones.
|
|
958
|
+
const openToVerdictMs = prOpenToVerdictMs(pr.created_at, verdict);
|
|
959
|
+
fileLog({ level: 'info', event: 'review_complete', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, duration_ms: Date.now() - stepStart, ...(openToVerdictMs !== undefined && { open_to_verdict_ms: openToVerdictMs }), tokens_used: tokensUsed, skills_activated: activatedSkills.map(skill => skill.name), ...(inputTokens !== undefined && { input_tokens: inputTokens }), ...(outputTokens !== undefined && { output_tokens: outputTokens }), ...(ctx.round !== undefined && { round: ctx.round }), ...(ctx.roundMode && { mode: ctx.roundMode }), ...triggerField });
|
|
855
960
|
// Recheck verdict is stored separately to preserve the original review's commentCount on the board
|
|
856
961
|
const phaseUpdate = isRecheck
|
|
857
962
|
? { recheckVerdict: verdict, phase: donePhase, recheckTokens: tokensUsed, recheckReviewer: reviewer, qualityTier: quality.tier }
|
|
@@ -890,6 +995,28 @@ export async function runWorkflow(ctx) {
|
|
|
890
995
|
annotationSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
891
996
|
}
|
|
892
997
|
catch { /* fall back to pr.head.sha if git is unavailable */ }
|
|
998
|
+
// ...but the clone's HEAD is only trustworthy once it is in the repo. A fix
|
|
999
|
+
// commit whose push failed leaves HEAD one commit ahead of the remote, and
|
|
1000
|
+
// stamping it attributes this verdict to code that does not exist — an APPROVE
|
|
1001
|
+
// on a phantom sha clears the previous BLOCK and reads as routine on the PR
|
|
1002
|
+
// page. Refuse to post rather than post an unattributable verdict: a missing
|
|
1003
|
+
// review is recoverable (the run fails, the pending status releases as
|
|
1004
|
+
// failure, the PR stays blocked), a phantom approval is not.
|
|
1005
|
+
const shaVerification = await verifyReviewedSha(octokit, owner, repoName, prNumber, annotationSha);
|
|
1006
|
+
if (!isVerifiedReviewedSha(shaVerification.status)) {
|
|
1007
|
+
const rejection = reviewedShaRejection(shaVerification, annotationSha, verdict);
|
|
1008
|
+
fileLog({ level: 'error', event: 'verdict_sha_unverified', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, verification: shaVerification.status, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1009
|
+
log(chalk.red(`✗ ${rejection}`));
|
|
1010
|
+
// The review itself cost vendor tokens — surface it rather than lose it.
|
|
1011
|
+
log(chalk.dim(`\n--- unposted review ---\n${commentBody}\n--- end ---`));
|
|
1012
|
+
throw new Error(rejection);
|
|
1013
|
+
}
|
|
1014
|
+
if (shaVerification.status === 'descendant') {
|
|
1015
|
+
// The reviewed commit is real but ahead of the PR head — the fix landed on a
|
|
1016
|
+
// separate auto-fix branch. Recorded so a verdict that does not cover HEAD is
|
|
1017
|
+
// explicable from the log rather than looking like the head-matching case.
|
|
1018
|
+
fileLog({ level: 'warn', event: 'verdict_sha_off_head', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, ...stepIdentity, verdict, sha: annotationSha, head_sha: shaVerification.headSha, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1019
|
+
}
|
|
893
1020
|
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, activatedSkills, effort,
|
|
894
1021
|
// Withheld when an explicit vendors.*.model overrode the tier map:
|
|
895
1022
|
// citing a tier the run did not use would assert a routing decision
|
|
@@ -899,6 +1026,16 @@ export async function runWorkflow(ctx) {
|
|
|
899
1026
|
: undefined);
|
|
900
1027
|
const commentUrl = `github.com/${owner}/${repoName}/pull/${prNumber}`;
|
|
901
1028
|
fileLog({ level: 'info', event: 'comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, url: `https://${commentUrl}` });
|
|
1029
|
+
// A posted verdict that blocks the merge is the product's whole reason to
|
|
1030
|
+
// exist, so it gets its own event rather than being re-derived downstream.
|
|
1031
|
+
// BLOCK blocks by definition; a NEEDS WORK that reached here survived the
|
|
1032
|
+
// severity gate, which only lets it through when a Critical/High/Medium
|
|
1033
|
+
// finding backs it — a nit-only review was already downgraded to APPROVE.
|
|
1034
|
+
// Logged only here, after the comment actually posted, so dry runs and
|
|
1035
|
+
// failed postReviewComment calls are never counted as posted findings.
|
|
1036
|
+
if (verdict === 'BLOCK' || verdict === 'NEEDS WORK') {
|
|
1037
|
+
fileLog({ level: 'info', event: 'blocking_finding_posted', repo: `${owner}/${repoName}`, pr: prNumber, reviewer, model, ...stepIdentity, verdict, ...(ctx.round !== undefined && { round: ctx.round }), ...triggerField });
|
|
1038
|
+
}
|
|
902
1039
|
// Mirror the verdict onto the PR's Linear issue. `run` and `watch` both
|
|
903
1040
|
// land here, so this is the path that matters — reviews posted from
|
|
904
1041
|
// commands/review.ts are the exception, not the rule.
|
|
@@ -938,7 +1075,7 @@ export async function runWorkflow(ctx) {
|
|
|
938
1075
|
const skipFix = (reason) => {
|
|
939
1076
|
lastFixSkipReason = reason;
|
|
940
1077
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
941
|
-
results[step.name] = { skipped: true };
|
|
1078
|
+
results[step.name] = { skipped: true, skipReason: reason };
|
|
942
1079
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
|
|
943
1080
|
};
|
|
944
1081
|
if (ctx.dryRun) {
|
|
@@ -1028,7 +1165,7 @@ export async function runWorkflow(ctx) {
|
|
|
1028
1165
|
const skillSession = skillSessionFor(step.name, effectiveType);
|
|
1029
1166
|
const runFix = async (v) => {
|
|
1030
1167
|
if (v === 'codex') {
|
|
1031
|
-
return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort);
|
|
1168
|
+
return runCodexFixStep(tmpDir, pr.base.ref, pr.title, reviewCommentBody, step.instructions ?? '', codexFixModel, ctx.overrideTimeoutMs ?? vendorTimeoutMs(config.vendors.codex.timeout_sec) ?? tierMs, skillSession, codexVendor.effort, config.skills.codex_full_access);
|
|
1032
1169
|
}
|
|
1033
1170
|
// roundConfig, not config: runFixStep reads vendors.claude.effort and
|
|
1034
1171
|
// quality.tier out of it, and both must be this round's values.
|
|
@@ -1137,13 +1274,14 @@ export async function runWorkflow(ctx) {
|
|
|
1137
1274
|
continue;
|
|
1138
1275
|
}
|
|
1139
1276
|
const deliveryMode = config.post_review.auto_fix.delivery.mode;
|
|
1140
|
-
|
|
1277
|
+
const landing = resolveFixLanding(deliveryMode);
|
|
1278
|
+
if (landing === 'branch') {
|
|
1141
1279
|
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
1142
1280
|
execSync('git add -A', { cwd: tmpDir });
|
|
1143
1281
|
execFileSync('git', [
|
|
1144
1282
|
'commit',
|
|
1145
1283
|
'-m',
|
|
1146
|
-
|
|
1284
|
+
fixCommitSubject(appliedCount, activeVendor),
|
|
1147
1285
|
'-m',
|
|
1148
1286
|
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
1149
1287
|
], { cwd: tmpDir });
|
|
@@ -1202,52 +1340,306 @@ export async function runWorkflow(ctx) {
|
|
|
1202
1340
|
}
|
|
1203
1341
|
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1204
1342
|
}
|
|
1205
|
-
else if (
|
|
1343
|
+
else if (landing === 'branch-then-separate-pr') {
|
|
1206
1344
|
const fixModel = activeVendor === 'codex' ? codexFixModel : claudeFixModel;
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
|
|
1345
|
+
// Commit the fix on the PR's own branch (already checked out in tmpDir) and
|
|
1346
|
+
// try to push it there, so the fix, recheck, and approval all stay on the
|
|
1347
|
+
// original PR. Only when that push can't land — e.g. the PR was merged and
|
|
1348
|
+
// its branch deleted, or the branch is protected — do we fall back to opening
|
|
1349
|
+
// a separate follow-up PR that carries the very same commit.
|
|
1210
1350
|
execSync('git add -A', { cwd: tmpDir });
|
|
1211
1351
|
execFileSync('git', [
|
|
1212
1352
|
'commit',
|
|
1213
1353
|
'-m',
|
|
1214
|
-
|
|
1354
|
+
fixPRCommitSubject(prNumber, activeVendor),
|
|
1215
1355
|
'-m',
|
|
1216
1356
|
buildCommitTrailers({ reviewer: activeVendor, model: fixModel, step: 'fix', service: 'crosscheck' }),
|
|
1217
1357
|
], { cwd: tmpDir });
|
|
1218
1358
|
const newSha = execSync('git rev-parse HEAD', { cwd: tmpDir, encoding: 'utf8' }).trim();
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1359
|
+
let landedOnBranch = false;
|
|
1360
|
+
try {
|
|
1361
|
+
await pushWithNonFastForwardHandling({
|
|
1362
|
+
tmpDir,
|
|
1363
|
+
branch: pr.head.ref,
|
|
1364
|
+
token,
|
|
1365
|
+
log,
|
|
1366
|
+
fileLog: (entry) => fileLog({ ...entry, phase: 'fix' }),
|
|
1367
|
+
owner,
|
|
1368
|
+
repoName,
|
|
1369
|
+
prNumber,
|
|
1370
|
+
});
|
|
1371
|
+
landedOnBranch = true;
|
|
1372
|
+
}
|
|
1373
|
+
catch (pushErr) {
|
|
1374
|
+
fileLog({ level: 'warn', event: 'fix_branch_push_fell_back', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, branch: pr.head.ref, error: pushErr instanceof Error ? pushErr.message.slice(0, 500) : String(pushErr), fallback: 'pull_request' });
|
|
1375
|
+
}
|
|
1223
1376
|
ctx.crosscheckShas.add(newSha);
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1377
|
+
if (landedOnBranch) {
|
|
1378
|
+
// Fix landed on the PR branch — mirror `commit` delivery exactly: set the
|
|
1379
|
+
// pending status the following recheck releases, and post the fix-applied
|
|
1380
|
+
// comment so the push is visible on the PR timeline.
|
|
1381
|
+
const currentStepIdx = steps.indexOf(step);
|
|
1382
|
+
const hasRecheckAfterFix = steps.slice(currentStepIdx + 1).some(s => s.type === 'review' || s.type === 'recheck');
|
|
1383
|
+
if (hasRecheckAfterFix) {
|
|
1384
|
+
try {
|
|
1385
|
+
const lockOctokit = createGithubClient(token);
|
|
1386
|
+
await acquireRemoteLock(lockOctokit, owner, repoName, newSha);
|
|
1387
|
+
pushedShasNeedingRelease.push(newSha);
|
|
1388
|
+
fixPushedShaRequiresRecheck = newSha;
|
|
1389
|
+
}
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
fileLog({ level: 'warn', event: 'remote_lock_refresh_failed', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha, error: err instanceof Error ? err.message : String(err) });
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1395
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'commit', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1241
1396
|
try {
|
|
1242
|
-
|
|
1243
|
-
|
|
1397
|
+
const octokit = createGithubClient(token);
|
|
1398
|
+
const body = buildFixAppliedCommentBody({
|
|
1399
|
+
owner, repo: repoName, sha: newSha, appliedCount,
|
|
1400
|
+
reviewCommentId,
|
|
1401
|
+
changedFiles: fixChangedFiles,
|
|
1402
|
+
vendor: activeVendor,
|
|
1403
|
+
reviewCommentBody,
|
|
1404
|
+
model: activeVendor === 'codex' ? codexFixModel : claudeFixModel,
|
|
1405
|
+
effort: fixEffort,
|
|
1406
|
+
skills: activatedSkills,
|
|
1244
1407
|
});
|
|
1408
|
+
await octokit.rest.issues.createComment({ owner, repo: repoName, issue_number: prNumber, body });
|
|
1409
|
+
fileLog({ level: 'info', event: 'fix_applied_comment_posted', repo: `${owner}/${repoName}`, pr: prNumber, sha: newSha });
|
|
1410
|
+
}
|
|
1411
|
+
catch (err) {
|
|
1412
|
+
fileLog({ level: 'warn', event: 'fix_applied_comment_failed', repo: `${owner}/${repoName}`, pr: prNumber, error: err instanceof Error ? err.message : String(err) });
|
|
1245
1413
|
}
|
|
1246
|
-
|
|
1414
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1415
|
+
}
|
|
1416
|
+
else {
|
|
1417
|
+
// Fallback: the fix could not land on the PR branch. Push the same commit to a
|
|
1418
|
+
// dedicated branch and open a follow-up PR targeting the original branch.
|
|
1419
|
+
const fixBranch = autoFixBranchName(prNumber);
|
|
1420
|
+
const gitEnv = { ...process.env, GITHUB_TOKEN: token, GH_TOKEN: token };
|
|
1421
|
+
const octokit = createGithubClient(token);
|
|
1422
|
+
// The fallback commit exists only in this clone until something pushes it, so
|
|
1423
|
+
// every path that ends without delivering it as a PR has to put HEAD back on the
|
|
1424
|
+
// PR head. Otherwise a later recheck reviews that commit and stamps its verdict
|
|
1425
|
+
// either with a sha the repository cannot resolve — which verifyReviewedSha
|
|
1426
|
+
// refuses to post (#290) — or with one no open PR carries.
|
|
1427
|
+
const restorePRHeadInClone = () => {
|
|
1428
|
+
execSync('git reset --hard HEAD~1', { cwd: tmpDir, stdio: 'pipe' });
|
|
1429
|
+
};
|
|
1430
|
+
// Deliver the fix as a diff on the original PR. Used wherever opening the
|
|
1431
|
+
// follow-up PR would either create a stale artifact or overwrite a branch that
|
|
1432
|
+
// is not ours: the work still reaches a human, just not as a branch.
|
|
1433
|
+
const deliverFixAsComment = async (outcome) => {
|
|
1434
|
+
fileLog({ level: 'warn', event: outcome.event, repo: `${owner}/${repoName}`, pr: prNumber, branch: outcome.branch, reason: outcome.reason });
|
|
1435
|
+
log(chalk.yellow(`⚠ ${outcome.notice}`));
|
|
1436
|
+
let patch = '';
|
|
1437
|
+
try {
|
|
1438
|
+
patch = execSync('git diff HEAD~1', { cwd: tmpDir, encoding: 'utf8' });
|
|
1439
|
+
}
|
|
1440
|
+
catch { /* fall through to the empty check */ }
|
|
1441
|
+
if (patch) {
|
|
1442
|
+
await octokit.rest.issues.createComment({
|
|
1443
|
+
owner, repo: repoName, issue_number: prNumber,
|
|
1444
|
+
body: [
|
|
1445
|
+
'### Suggested fixes (crosscheck auto-fix)',
|
|
1446
|
+
'',
|
|
1447
|
+
outcome.explanation,
|
|
1448
|
+
'',
|
|
1449
|
+
'```diff',
|
|
1450
|
+
patch.slice(0, 16000),
|
|
1451
|
+
'```',
|
|
1452
|
+
'',
|
|
1453
|
+
fixAttributionFooter(),
|
|
1454
|
+
].join('\n'),
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
restorePRHeadInClone();
|
|
1458
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1459
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'comment', tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1460
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1461
|
+
};
|
|
1462
|
+
// Ask whether the intended base still exists BEFORE pushing anything. The most
|
|
1463
|
+
// common reason the PR-branch push failed is that the PR merged and GitHub
|
|
1464
|
+
// deleted its branch — and `pulls.create` rejects a missing base with
|
|
1465
|
+
// `field: base, code: invalid`. That used to throw after the branch was already
|
|
1466
|
+
// pushed, losing the fix and orphaning the branch (19 times in logged runs).
|
|
1467
|
+
let baseBranchExists = true;
|
|
1468
|
+
try {
|
|
1469
|
+
await octokit.rest.repos.getBranch({ owner, repo: repoName, branch: pr.head.ref });
|
|
1470
|
+
}
|
|
1471
|
+
catch (err) {
|
|
1472
|
+
if (err?.status === 404)
|
|
1473
|
+
baseBranchExists = false;
|
|
1474
|
+
else
|
|
1475
|
+
throw err;
|
|
1476
|
+
}
|
|
1477
|
+
const delivery = planAutoFixDelivery(baseBranchExists, pr.head.ref);
|
|
1478
|
+
if (delivery.kind === 'comment') {
|
|
1479
|
+
// A PR cut from this snapshot would be stale the moment it opened — it is
|
|
1480
|
+
// exactly the artifact the superseded-auto-fix cleanup exists to close. Post
|
|
1481
|
+
// the fix as a diff instead, so the work is still delivered and reviewable.
|
|
1482
|
+
await deliverFixAsComment({
|
|
1483
|
+
event: 'fix_pr_skipped',
|
|
1484
|
+
reason: delivery.reason,
|
|
1485
|
+
branch: pr.head.ref,
|
|
1486
|
+
notice: `${pr.head.ref} no longer exists — posting the fix as a diff instead of opening a stale follow-up PR`,
|
|
1487
|
+
explanation: `\`${pr.head.ref}\` no longer exists, so this PR has merged and there is no branch to land on. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
|
|
1488
|
+
});
|
|
1489
|
+
continue;
|
|
1490
|
+
}
|
|
1491
|
+
// A fix branch left behind by an earlier round of this PR made a plain push
|
|
1492
|
+
// non-fast-forward and lost the fix (12 times in logged runs). Replace it under
|
|
1493
|
+
// a lease: this round's fix is built on the newer PR head and supersedes it,
|
|
1494
|
+
// and the lease still refuses if anything other than us moved the branch.
|
|
1495
|
+
let remoteOid = null;
|
|
1496
|
+
try {
|
|
1497
|
+
remoteOid = parseLsRemoteOid(execFileSync('git', ['ls-remote', '--heads', 'origin', fixBranch], { cwd: tmpDir, env: gitEnv, encoding: 'utf8' }));
|
|
1498
|
+
}
|
|
1499
|
+
catch { /* treat an unreadable remote as "branch absent"; the lease catches a wrong guess */ }
|
|
1500
|
+
// `--force-with-lease` only protects against changes made *after* this lookup —
|
|
1501
|
+
// it happily accepts a branch a human just created or moved as the lease value
|
|
1502
|
+
// and overwrites it. So establish that the branch is crosscheck's own artifact
|
|
1503
|
+
// before replacing it, from GitHub's record of who opened the PR from it rather
|
|
1504
|
+
// than from anything the pusher can write into a commit.
|
|
1505
|
+
if (remoteOid !== null) {
|
|
1506
|
+
let crosscheckLogin = null;
|
|
1507
|
+
let candidates = [];
|
|
1508
|
+
try {
|
|
1509
|
+
const [{ data: viewer }, { data: branchPrs }] = await Promise.all([
|
|
1510
|
+
octokit.rest.users.getAuthenticated(),
|
|
1511
|
+
octokit.rest.pulls.list({ owner, repo: repoName, state: 'all', head: `${owner}:${fixBranch}` }),
|
|
1512
|
+
]);
|
|
1513
|
+
crosscheckLogin = viewer.login;
|
|
1514
|
+
candidates = branchPrs;
|
|
1515
|
+
}
|
|
1516
|
+
catch { /* unreadable identity or PR list — treated as not ours, never overwrite */ }
|
|
1517
|
+
const ownership = assessFixBranchOwnership({ sourcePrNumber: prNumber, crosscheckLogin, candidates });
|
|
1518
|
+
if (!ownership.owned) {
|
|
1519
|
+
await deliverFixAsComment({
|
|
1520
|
+
event: 'fix_branch_push_skipped',
|
|
1521
|
+
reason: ownership.reason,
|
|
1522
|
+
branch: fixBranch,
|
|
1523
|
+
notice: `${fixBranch} exists but crosscheck has no PR of its own from it — leaving it in place and posting the fix as a diff instead`,
|
|
1524
|
+
explanation: `\`${fixBranch}\` already exists and crosscheck cannot show it opened a pull request from it, so the branch was left untouched rather than force-replaced. The fix is posted here instead.`,
|
|
1525
|
+
});
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// From #296: the merged-PR handler sweeps for auto-fix PRs once, so a PR opened
|
|
1530
|
+
// after that sweep is never seen by it and stays open and mergeable against a
|
|
1531
|
+
// tree that no longer exists. The base-exists check above already covers a merge
|
|
1532
|
+
// that deleted the branch; this catches a merge in a repo that keeps its
|
|
1533
|
+
// branches. It sits immediately before the push — every lookup above widens the
|
|
1534
|
+
// window it has to close, and nothing has been written to the remote yet, so a
|
|
1535
|
+
// skip here cannot orphan a branch.
|
|
1536
|
+
if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
|
|
1537
|
+
restorePRHeadInClone();
|
|
1538
|
+
skipFix('source_pr_merged');
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
execFileSync('git', forceWithLeaseArgs(fixBranch, remoteOid), { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
1543
|
+
}
|
|
1544
|
+
catch (pushErr) {
|
|
1545
|
+
const pushMsg = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
1546
|
+
fileLog({
|
|
1547
|
+
level: 'error', event: 'fix_branch_push_failed', repo: `${owner}/${repoName}`, pr: prNumber,
|
|
1548
|
+
branch: fixBranch, lease: remoteOid ?? 'absent',
|
|
1549
|
+
reason: isLeaseRejection(pushMsg) ? 'lease_rejected' : 'push_failed',
|
|
1550
|
+
error: pushMsg.slice(0, 500),
|
|
1551
|
+
});
|
|
1552
|
+
throw pushErr;
|
|
1553
|
+
}
|
|
1554
|
+
if (remoteOid !== null) {
|
|
1555
|
+
fileLog({ level: 'info', event: 'fix_branch_replaced', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, previous_sha: remoteOid, sha: newSha });
|
|
1556
|
+
}
|
|
1557
|
+
// Undo this run's push when the follow-up PR turns out not to be creatable after
|
|
1558
|
+
// all. Only a branch this run brought into existence is removed — one that was
|
|
1559
|
+
// already crosscheck's is left at the newer commit rather than deleted out from
|
|
1560
|
+
// under whatever else references it.
|
|
1561
|
+
const rollBackPushedFixBranch = () => {
|
|
1562
|
+
if (remoteOid !== null)
|
|
1563
|
+
return;
|
|
1564
|
+
try {
|
|
1565
|
+
execFileSync('git', ['push', 'origin', '--delete', fixBranch], { cwd: tmpDir, env: gitEnv, stdio: 'pipe' });
|
|
1566
|
+
fileLog({ level: 'info', event: 'fix_branch_rolled_back', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, sha: newSha });
|
|
1567
|
+
}
|
|
1568
|
+
catch (deleteErr) {
|
|
1569
|
+
fileLog({ level: 'warn', event: 'fix_branch_rollback_failed', repo: `${owner}/${repoName}`, pr: prNumber, branch: fixBranch, error: deleteErr instanceof Error ? deleteErr.message.slice(0, 500) : String(deleteErr) });
|
|
1570
|
+
}
|
|
1571
|
+
};
|
|
1572
|
+
// The source PR can still merge in the window between the check above and this
|
|
1573
|
+
// point. Asking again here is what keeps the push from becoming a stale fix PR:
|
|
1574
|
+
// the branch is already on the remote, so the answer decides whether it stays.
|
|
1575
|
+
if (await sourcePRHasMerged(octokit, owner, repoName, prNumber)) {
|
|
1576
|
+
rollBackPushedFixBranch();
|
|
1577
|
+
restorePRHeadInClone();
|
|
1578
|
+
skipFix('source_pr_merged');
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const fixPrTitle = config.post_review.auto_fix.delivery.pr_title.replace('#{original_pr_title}', pr.title);
|
|
1582
|
+
// An earlier round may already have an open PR from this branch; the force-push
|
|
1583
|
+
// above updated it, so opening a second one would be rejected as a duplicate.
|
|
1584
|
+
const { data: existingFixPrs } = await octokit.rest.pulls.list({
|
|
1585
|
+
owner, repo: repoName, state: 'open', head: `${owner}:${fixBranch}`,
|
|
1586
|
+
});
|
|
1587
|
+
let fixPrNumber;
|
|
1588
|
+
if (existingFixPrs.length > 0) {
|
|
1589
|
+
fixPrNumber = existingFixPrs[0].number;
|
|
1590
|
+
fileLog({ level: 'info', event: 'fix_pr_updated', repo: `${owner}/${repoName}`, pr: prNumber, fix_pr: fixPrNumber, sha: newSha });
|
|
1591
|
+
}
|
|
1592
|
+
else {
|
|
1593
|
+
let fixPr;
|
|
1594
|
+
try {
|
|
1595
|
+
const created = await octokit.rest.pulls.create({
|
|
1596
|
+
owner,
|
|
1597
|
+
repo: repoName,
|
|
1598
|
+
head: fixBranch,
|
|
1599
|
+
base: delivery.base,
|
|
1600
|
+
title: fixPrTitle,
|
|
1601
|
+
body: [
|
|
1602
|
+
autoFixPRIntro(prNumber),
|
|
1603
|
+
'',
|
|
1604
|
+
`Review: https://github.com/${owner}/${repoName}/pull/${prNumber}`,
|
|
1605
|
+
'',
|
|
1606
|
+
fixAttributionFooter(),
|
|
1607
|
+
].join('\n'),
|
|
1608
|
+
});
|
|
1609
|
+
fixPr = created.data;
|
|
1610
|
+
}
|
|
1611
|
+
catch (createErr) {
|
|
1612
|
+
// A merge landing in a repo that deletes its branches takes the base away
|
|
1613
|
+
// between the checks above and this call, and `pulls.create` reports that as
|
|
1614
|
+
// `field: base, code: invalid` — the original failure this fallback exists to
|
|
1615
|
+
// stop, just in a narrower window. Take the branch back out and deliver the
|
|
1616
|
+
// fix as a diff rather than leaving it orphaned.
|
|
1617
|
+
if (!isInvalidBaseError(createErr))
|
|
1618
|
+
throw createErr;
|
|
1619
|
+
rollBackPushedFixBranch();
|
|
1620
|
+
await deliverFixAsComment({
|
|
1621
|
+
event: 'fix_pr_create_failed',
|
|
1622
|
+
reason: 'base_branch_gone_after_push',
|
|
1623
|
+
branch: delivery.base,
|
|
1624
|
+
notice: `${delivery.base} disappeared while the fix branch was being pushed — posting the fix as a diff instead of opening a stale follow-up PR`,
|
|
1625
|
+
explanation: `\`${delivery.base}\` existed when this fix started and was gone by the time the follow-up PR was opened, so this PR merged mid-run. The fix is below rather than in a follow-up PR — a PR cut from the pre-merge state would reintroduce whatever the merged version changed.`,
|
|
1626
|
+
});
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
fixPrNumber = fixPr.number;
|
|
1630
|
+
}
|
|
1631
|
+
if (config.post_review.auto_fix.delivery.label) {
|
|
1632
|
+
try {
|
|
1633
|
+
await octokit.rest.issues.addLabels({
|
|
1634
|
+
owner, repo: repoName, issue_number: fixPrNumber, labels: [config.post_review.auto_fix.delivery.label],
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
catch { /* label may not exist in this repo — skip */ }
|
|
1638
|
+
}
|
|
1639
|
+
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1640
|
+
fileLog({ level: 'info', event: 'fix_complete', repo: `${owner}/${repoName}`, pr: prNumber, vendor: activeVendor, applied_count: appliedCount, sha: newSha, delivery: 'pull_request', fix_pr: fixPrNumber, tokens_used: fixTokensUsed, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1641
|
+
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1247
1642
|
}
|
|
1248
|
-
onPhaseChange('fixed ✓', { fixCount: appliedCount, phase: 'fixed', fixTokens: fixTokensUsed });
|
|
1249
|
-
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, skills_activated: activatedSkills.map(skill => skill.name), duration_ms: Date.now() - fixStepStart, ...triggerField });
|
|
1250
|
-
results[step.name] = { applied_count: appliedCount, tokens_used: fixTokensUsed, vendor: activeVendor };
|
|
1251
1643
|
}
|
|
1252
1644
|
else {
|
|
1253
1645
|
// comment: post the diff as a suggested-fix comment, no code push needed (works for fork PRs too)
|
|
@@ -1269,7 +1661,7 @@ export async function runWorkflow(ctx) {
|
|
|
1269
1661
|
else if (effectiveType === 'conflict-resolve') {
|
|
1270
1662
|
const skipConflictResolve = (reason) => {
|
|
1271
1663
|
onPhaseChange('', { phase: 'fixed', fixCount: 0 });
|
|
1272
|
-
results[step.name] = { skipped: true };
|
|
1664
|
+
results[step.name] = { skipped: true, skipReason: reason };
|
|
1273
1665
|
fileLog({ level: 'info', event: 'step_skipped', repo: `${owner}/${repoName}`, pr: prNumber, step: step.name, reason });
|
|
1274
1666
|
};
|
|
1275
1667
|
if (ctx.dryRun) {
|
|
@@ -1316,7 +1708,16 @@ export async function runWorkflow(ctx) {
|
|
|
1316
1708
|
skipConflictResolve('no_conflicts');
|
|
1317
1709
|
continue;
|
|
1318
1710
|
}
|
|
1319
|
-
|
|
1711
|
+
// resolveConflictResolveVendor extends resolveReviewer with the same human-origin
|
|
1712
|
+
// fallback the fix step uses, so a PR crosscheck cannot attribute still gets its
|
|
1713
|
+
// conflicts resolved instead of skipping with 'no_vendor'.
|
|
1714
|
+
const { vendor, usedHumanFallback, substitutedOriginVendor } = resolveConflictResolveVendor(step.reviewer, origin, config, ctx.smartSwitchFallback);
|
|
1715
|
+
if (usedHumanFallback && vendor) {
|
|
1716
|
+
fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: 'none', to: vendor, reason: 'human_origin' });
|
|
1717
|
+
}
|
|
1718
|
+
else if (substitutedOriginVendor && vendor) {
|
|
1719
|
+
fileLog({ level: 'info', event: 'conflict_resolve_vendor_fallback', repo: `${owner}/${repoName}`, pr: prNumber, from: substitutedOriginVendor, to: vendor, reason: 'unsupported_vendor' });
|
|
1720
|
+
}
|
|
1320
1721
|
if (!vendor) {
|
|
1321
1722
|
try {
|
|
1322
1723
|
execSync('git merge --abort', { cwd: tmpDir });
|
|
@@ -1458,7 +1859,7 @@ export async function runWorkflow(ctx) {
|
|
|
1458
1859
|
execFileSync('git', [
|
|
1459
1860
|
'commit',
|
|
1460
1861
|
'-m',
|
|
1461
|
-
|
|
1862
|
+
conflictResolveCommitSubject(conflictedFiles.length, vendor),
|
|
1462
1863
|
'-m',
|
|
1463
1864
|
buildCommitTrailers({ reviewer: vendor, model: conflictResolveModel, step: 'conflict-resolve', service: 'crosscheck' }),
|
|
1464
1865
|
], { cwd: tmpDir });
|
|
@@ -1522,6 +1923,7 @@ export async function runWorkflow(ctx) {
|
|
|
1522
1923
|
return {
|
|
1523
1924
|
verdict: verdict ?? null,
|
|
1524
1925
|
fixAppliedCount,
|
|
1926
|
+
stepOutcomes: summariseStepOutcomes(stepsRun, results),
|
|
1525
1927
|
...(fixAppliedCount === undefined && lastFixSkipReason !== undefined && { fixSkipReason: lastFixSkipReason }),
|
|
1526
1928
|
...(latestReviewResult?.commentBody && {
|
|
1527
1929
|
latestReviewComment: {
|