@codraoss/core 0.9.4
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/LICENSE +625 -0
- package/README.md +15 -0
- package/dist/chunk-FGXA7JNR.js +57 -0
- package/dist/chunk-FGXA7JNR.js.map +1 -0
- package/dist/chunk-ILZUCIZH.js +117 -0
- package/dist/chunk-ILZUCIZH.js.map +1 -0
- package/dist/chunk-JC74XH7Q.js +67 -0
- package/dist/chunk-JC74XH7Q.js.map +1 -0
- package/dist/chunk-MMVQPGK3.js +341 -0
- package/dist/chunk-MMVQPGK3.js.map +1 -0
- package/dist/chunk-NSTV4TRP.js +39 -0
- package/dist/chunk-NSTV4TRP.js.map +1 -0
- package/dist/chunk-OIQRTDYR.js +337 -0
- package/dist/chunk-OIQRTDYR.js.map +1 -0
- package/dist/chunk-PVXFURDR.js +90 -0
- package/dist/chunk-PVXFURDR.js.map +1 -0
- package/dist/chunk-R2HRWFRH.js +108 -0
- package/dist/chunk-R2HRWFRH.js.map +1 -0
- package/dist/chunk-RQ5S67XB.js +82 -0
- package/dist/chunk-RQ5S67XB.js.map +1 -0
- package/dist/chunk-U3VKVDZ7.js +266 -0
- package/dist/chunk-U3VKVDZ7.js.map +1 -0
- package/dist/chunk-V3RF6GQW.js +137 -0
- package/dist/chunk-V3RF6GQW.js.map +1 -0
- package/dist/chunk-WSK45HW4.js +79 -0
- package/dist/chunk-WSK45HW4.js.map +1 -0
- package/dist/chunk-XD6CGYHO.js +893 -0
- package/dist/chunk-XD6CGYHO.js.map +1 -0
- package/dist/claim-checks.d.ts +54 -0
- package/dist/claim-checks.js +20 -0
- package/dist/claim-checks.js.map +1 -0
- package/dist/diff/index.d.ts +22 -0
- package/dist/diff/index.js +25 -0
- package/dist/diff/index.js.map +1 -0
- package/dist/fingerprint.d.ts +9 -0
- package/dist/fingerprint.js +19 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/index-BTDWXDck.d.ts +93 -0
- package/dist/index.d.ts +138 -0
- package/dist/index.js +1755 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +32 -0
- package/dist/logger.js +17 -0
- package/dist/logger.js.map +1 -0
- package/dist/model-gRDTk8yk.d.ts +117 -0
- package/dist/model-output/index.d.ts +4 -0
- package/dist/model-output/index.js +32 -0
- package/dist/model-output/index.js.map +1 -0
- package/dist/ports/index.d.ts +512 -0
- package/dist/ports/index.js +13 -0
- package/dist/ports/index.js.map +1 -0
- package/dist/position-h_jdn6ZH.d.ts +30 -0
- package/dist/prompts/file-review.d.ts +6 -0
- package/dist/prompts/file-review.js +25 -0
- package/dist/prompts/file-review.js.map +1 -0
- package/dist/prompts/languages.d.ts +9 -0
- package/dist/prompts/languages.js +7 -0
- package/dist/prompts/languages.js.map +1 -0
- package/dist/prompts/summary.d.ts +12 -0
- package/dist/prompts/summary.js +50 -0
- package/dist/prompts/summary.js.map +1 -0
- package/dist/prompts/verify.d.ts +71 -0
- package/dist/prompts/verify.js +15 -0
- package/dist/prompts/verify.js.map +1 -0
- package/dist/rules/detect.d.ts +32 -0
- package/dist/rules/detect.js +13 -0
- package/dist/rules/detect.js.map +1 -0
- package/dist/rules/table.d.ts +18 -0
- package/dist/rules/table.js +7 -0
- package/dist/rules/table.js.map +1 -0
- package/dist/timeout.d.ts +6 -0
- package/dist/timeout.js +26 -0
- package/dist/timeout.js.map +1 -0
- package/dist/token-tracker.d.ts +38 -0
- package/dist/token-tracker.js +8 -0
- package/dist/token-tracker.js.map +1 -0
- package/dist/verify.d.ts +3 -0
- package/dist/verify.js +21 -0
- package/dist/verify.js.map +1 -0
- package/package.json +181 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1755 @@
|
|
|
1
|
+
import {
|
|
2
|
+
dedupeFindings
|
|
3
|
+
} from "./chunk-XD6CGYHO.js";
|
|
4
|
+
import {
|
|
5
|
+
InMemoryKV,
|
|
6
|
+
InMemoryOrchestrator,
|
|
7
|
+
InMemoryQueue,
|
|
8
|
+
InMemorySessionStore
|
|
9
|
+
} from "./chunk-JC74XH7Q.js";
|
|
10
|
+
import {
|
|
11
|
+
renderFileDiff
|
|
12
|
+
} from "./chunk-MMVQPGK3.js";
|
|
13
|
+
import "./chunk-RQ5S67XB.js";
|
|
14
|
+
import {
|
|
15
|
+
parseVerifyResponse,
|
|
16
|
+
renderDiffSnippet
|
|
17
|
+
} from "./chunk-V3RF6GQW.js";
|
|
18
|
+
import {
|
|
19
|
+
ruleHitsToComments,
|
|
20
|
+
scanFileForRuleHits
|
|
21
|
+
} from "./chunk-ILZUCIZH.js";
|
|
22
|
+
import {
|
|
23
|
+
ASYNC_BATCH_POLL_DELAY_SECONDS,
|
|
24
|
+
BIN_DIFF_CHAR_BUDGET,
|
|
25
|
+
BIN_MAX_FILES,
|
|
26
|
+
BIN_TARGET_DIFF_LINES,
|
|
27
|
+
BUSY_RETRY_SECONDS,
|
|
28
|
+
DIFF_CACHE_TTL_SECONDS,
|
|
29
|
+
FILE_FIXED_SUBREQUESTS,
|
|
30
|
+
FRESH_INVOCATION_YIELD_SECONDS,
|
|
31
|
+
JOB_LEASE_SECONDS,
|
|
32
|
+
MAX_FINALIZE_CONTINUATIONS,
|
|
33
|
+
MAX_JOB_CONTINUATIONS,
|
|
34
|
+
MAX_MODEL_ATTEMPTS_ESTIMATE,
|
|
35
|
+
MAX_RETRYABLE_FILE_REVIEW_FAILURES,
|
|
36
|
+
MISSING_FILE_ERROR,
|
|
37
|
+
PACKABLE_MAX_DIFF_LINES,
|
|
38
|
+
RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS,
|
|
39
|
+
REVIEW_CHUNK_WALL_CLOCK_MS,
|
|
40
|
+
VERIFY_MIN_ANSWER_RATIO
|
|
41
|
+
} from "./chunk-FGXA7JNR.js";
|
|
42
|
+
import "./chunk-R2HRWFRH.js";
|
|
43
|
+
import "./chunk-U3VKVDZ7.js";
|
|
44
|
+
import "./chunk-NSTV4TRP.js";
|
|
45
|
+
import "./chunk-PVXFURDR.js";
|
|
46
|
+
import {
|
|
47
|
+
logger
|
|
48
|
+
} from "./chunk-WSK45HW4.js";
|
|
49
|
+
import {
|
|
50
|
+
filterReviewableFiles,
|
|
51
|
+
parseUnifiedDiff
|
|
52
|
+
} from "./chunk-OIQRTDYR.js";
|
|
53
|
+
|
|
54
|
+
// src/review/index.ts
|
|
55
|
+
import "@codraoss/schema/webhook";
|
|
56
|
+
import { REVIEW_CONCURRENCY_LIMITS as REVIEW_CONCURRENCY_LIMITS2 } from "@codraoss/schema";
|
|
57
|
+
|
|
58
|
+
// src/review/request.ts
|
|
59
|
+
function shouldTriggerFromChangeRequest(action, config) {
|
|
60
|
+
return config.on.includes(action);
|
|
61
|
+
}
|
|
62
|
+
function extractReviewRequest(input) {
|
|
63
|
+
if (input.eventName === "change_request") {
|
|
64
|
+
const payload = input.payload;
|
|
65
|
+
if (input.config.review.ignore_drafts && payload.changeRequest.draft) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!shouldTriggerFromChangeRequest(payload.action, input.config.review)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
installationId: payload.installationId,
|
|
73
|
+
owner: payload.repository.owner,
|
|
74
|
+
repo: payload.repository.name,
|
|
75
|
+
prNumber: payload.changeRequest.number,
|
|
76
|
+
prTitle: payload.changeRequest.title,
|
|
77
|
+
prAuthor: payload.changeRequest.author,
|
|
78
|
+
commitSha: payload.changeRequest.head.sha,
|
|
79
|
+
baseSha: payload.changeRequest.base.sha,
|
|
80
|
+
headRef: payload.changeRequest.head.ref,
|
|
81
|
+
baseRef: payload.changeRequest.base.ref,
|
|
82
|
+
trigger: "auto"
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (input.eventName === "comment") {
|
|
86
|
+
const payload = input.payload;
|
|
87
|
+
const mentionTrigger = input.config.review.mention_trigger;
|
|
88
|
+
if (!payload.issue.isChangeRequest || payload.action !== "created" || !mentionTrigger) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (!payload.comment.body.includes(mentionTrigger)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
installationId: payload.installationId,
|
|
96
|
+
owner: payload.repository.owner,
|
|
97
|
+
repo: payload.repository.name,
|
|
98
|
+
prNumber: payload.issue.number,
|
|
99
|
+
prTitle: null,
|
|
100
|
+
prAuthor: null,
|
|
101
|
+
commitSha: "",
|
|
102
|
+
baseSha: "",
|
|
103
|
+
headRef: null,
|
|
104
|
+
baseRef: null,
|
|
105
|
+
trigger: "mention"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/review/diff-cache.ts
|
|
112
|
+
import { reviewMaxFilesRange } from "@codraoss/schema";
|
|
113
|
+
function diffCacheKey(jobId) {
|
|
114
|
+
return `diff:${jobId}`;
|
|
115
|
+
}
|
|
116
|
+
async function getDiffFiles(env, job, github, config, maxFiles = reviewMaxFilesRange.default) {
|
|
117
|
+
const cacheKey = diffCacheKey(job.id);
|
|
118
|
+
let rawDiff = await env.kv.get(cacheKey);
|
|
119
|
+
if (!rawDiff) {
|
|
120
|
+
rawDiff = await github.getPullRequestDiff(job.owner, job.repo, job.prNumber);
|
|
121
|
+
try {
|
|
122
|
+
await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
logger.warn(`Failed to cache PR diff for job ${job.id}; it will be re-fetched on the next phase`, error instanceof Error ? error : new Error(String(error)));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return filterReviewableFiles(parseUnifiedDiff(rawDiff, config.review), config.review, maxFiles);
|
|
128
|
+
}
|
|
129
|
+
async function getOrFetchRawDiffForCompletedJob(env, job, github) {
|
|
130
|
+
const cacheKey = diffCacheKey(job.id);
|
|
131
|
+
const cached = await env.kv.get(cacheKey);
|
|
132
|
+
if (cached) return cached;
|
|
133
|
+
const rawDiff = await github.getCompareDiff(job.owner, job.repo, job.baseSha, job.commitSha);
|
|
134
|
+
try {
|
|
135
|
+
await env.kv.put(cacheKey, rawDiff, { expirationTtl: DIFF_CACHE_TTL_SECONDS });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
logger.warn(`Failed to cache reconstructed diff for job ${job.id}`, error instanceof Error ? error : new Error(String(error)));
|
|
138
|
+
}
|
|
139
|
+
return rawDiff;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/review/budget.ts
|
|
143
|
+
function budgetAwareFileLimit(remainingSafeBudget, configuredChunkFileLimit, modelChainLength = 1) {
|
|
144
|
+
const budgetLimit = Math.floor(remainingSafeBudget / estimatedSubrequestsPerFile(modelChainLength));
|
|
145
|
+
return Math.min(configuredChunkFileLimit, budgetLimit);
|
|
146
|
+
}
|
|
147
|
+
function estimatedSubrequestsPerFile(modelChainLength) {
|
|
148
|
+
const modelAttempts = Math.max(1, Math.min(modelChainLength, MAX_MODEL_ATTEMPTS_ESTIMATE));
|
|
149
|
+
return FILE_FIXED_SUBREQUESTS + modelAttempts;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/review/pack.ts
|
|
153
|
+
function unitFiles(unit) {
|
|
154
|
+
return unit.kind === "single" ? [unit.file] : unit.files;
|
|
155
|
+
}
|
|
156
|
+
var measure = (file) => renderFileDiff(file).length;
|
|
157
|
+
var asBin = (files) => files.length === 1 ? { kind: "single", file: files[0] } : {
|
|
158
|
+
kind: "bin",
|
|
159
|
+
files,
|
|
160
|
+
diffLineCount: files.reduce((sum, f) => sum + f.lineCount, 0),
|
|
161
|
+
diffChars: files.reduce((sum, f) => sum + measure(f), 0)
|
|
162
|
+
};
|
|
163
|
+
function planReviewUnits(files, opts) {
|
|
164
|
+
if (!opts.enabled) return files.map((file) => ({ kind: "single", file }));
|
|
165
|
+
const units = [];
|
|
166
|
+
let open = [];
|
|
167
|
+
let lines = 0;
|
|
168
|
+
let chars = 0;
|
|
169
|
+
const close = () => {
|
|
170
|
+
if (open.length > 0) units.push(asBin(open));
|
|
171
|
+
open = [];
|
|
172
|
+
lines = 0;
|
|
173
|
+
chars = 0;
|
|
174
|
+
};
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
const fileChars = measure(file);
|
|
177
|
+
if (file.lineCount > PACKABLE_MAX_DIFF_LINES || fileChars > BIN_DIFF_CHAR_BUDGET) {
|
|
178
|
+
close();
|
|
179
|
+
units.push({ kind: "single", file });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (open.length > 0 && (lines + file.lineCount > BIN_TARGET_DIFF_LINES || chars + fileChars > BIN_DIFF_CHAR_BUDGET || open.length >= BIN_MAX_FILES)) close();
|
|
183
|
+
open.push(file);
|
|
184
|
+
lines += file.lineCount;
|
|
185
|
+
chars += fileChars;
|
|
186
|
+
}
|
|
187
|
+
close();
|
|
188
|
+
return units;
|
|
189
|
+
}
|
|
190
|
+
function narrowUnit(unit, ledger) {
|
|
191
|
+
const outstanding = unitFiles(unit).filter((file) => !ledger.get(file.path)?.handled);
|
|
192
|
+
if (outstanding.length === 0) return [];
|
|
193
|
+
const failedBefore = outstanding.some((file) => (ledger.get(file.path)?.transientErrorCount ?? 0) > 0);
|
|
194
|
+
if (failedBefore) return outstanding.map((file) => ({ kind: "single", file }));
|
|
195
|
+
return [asBin(outstanding)];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/review/phase-control.ts
|
|
199
|
+
async function heartbeatAndCheckSuperseded(env, jobId, leaseOwner) {
|
|
200
|
+
await env.jobs.heartbeatJobLease(jobId, leaseOwner, JOB_LEASE_SECONDS);
|
|
201
|
+
const currentJob = await env.jobs.getJobForProcessing(jobId);
|
|
202
|
+
if (currentJob?.status === "superseded") {
|
|
203
|
+
throw new Error("JOB_SUPERSEDED");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
var NextPhaseError = class extends Error {
|
|
207
|
+
constructor(phase, delaySeconds) {
|
|
208
|
+
super(`NextPhase: ${phase}`);
|
|
209
|
+
this.phase = phase;
|
|
210
|
+
this.delaySeconds = delaySeconds;
|
|
211
|
+
}
|
|
212
|
+
phase;
|
|
213
|
+
delaySeconds;
|
|
214
|
+
};
|
|
215
|
+
async function enqueueJobPhase(env, jobId, phase, delaySeconds = 0) {
|
|
216
|
+
await env.jobs.markJobContinuationQueued(jobId, delaySeconds);
|
|
217
|
+
throw new NextPhaseError(phase, delaySeconds);
|
|
218
|
+
}
|
|
219
|
+
function hasCompletedStep(job, stepName) {
|
|
220
|
+
return job.steps.some((step) => step.name === stepName && step.status === "done");
|
|
221
|
+
}
|
|
222
|
+
async function failJobAndCheckRun(env, job, github, message) {
|
|
223
|
+
try {
|
|
224
|
+
await env.jobs.failJob(job.id, message);
|
|
225
|
+
} catch (dbError) {
|
|
226
|
+
logger.error(`Critical: failed to mark job ${job.id} as failed in the DB; it may remain stuck until lease-expiry recovery reclaims it`, dbError);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const latest = await env.jobs.getJobForProcessing(job.id);
|
|
231
|
+
const checkRunId = latest?.check_run_id ?? job.checkRunId;
|
|
232
|
+
if (checkRunId) {
|
|
233
|
+
await github.updateCheckRun(job.owner, job.repo, checkRunId, {
|
|
234
|
+
status: "completed",
|
|
235
|
+
conclusion: "failure",
|
|
236
|
+
title: "Review failed",
|
|
237
|
+
summary: message
|
|
238
|
+
});
|
|
239
|
+
await env.jobs.markJobCheckRunCompleted(job.id);
|
|
240
|
+
}
|
|
241
|
+
} catch (checkRunError) {
|
|
242
|
+
logger.warn(`Failed to update GitHub check run for failed job ${job.id}; opportunistic maintenance will retry it`, checkRunError);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/review/retry-policy.ts
|
|
247
|
+
import { normalizeModelId } from "@codraoss/schema";
|
|
248
|
+
import { isSubrequestBudgetMessage, isTimeoutMessage, matchesAnyTransientSubstring } from "@codraoss/schema/transient-errors";
|
|
249
|
+
function isRetryableFileReviewErrorMessage(message) {
|
|
250
|
+
if (!message) return false;
|
|
251
|
+
const lower = message.toLowerCase();
|
|
252
|
+
if (lower.includes("retrying later") || lower.includes("all configured review models failed")) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
if (isTimeoutMessage(lower)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
return matchesAnyTransientSubstring(lower) || lower.includes("google request failed with 5") || lower.includes("temporary") || lower.includes("subrequest");
|
|
259
|
+
}
|
|
260
|
+
function isSubrequestBudgetError(error) {
|
|
261
|
+
return isSubrequestBudgetMessage(error);
|
|
262
|
+
}
|
|
263
|
+
function retryableModelFailureDelaySeconds(failureCount) {
|
|
264
|
+
if (!failureCount || failureCount < 1) return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0];
|
|
265
|
+
const index = Math.min(failureCount - 1, RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS.length - 1);
|
|
266
|
+
return RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[index];
|
|
267
|
+
}
|
|
268
|
+
function getRetryableModelFailureDelaySeconds(error) {
|
|
269
|
+
const record = error && typeof error === "object" ? error : null;
|
|
270
|
+
const retryAfterSeconds = typeof record?.retryAfterSeconds === "number" ? record.retryAfterSeconds : null;
|
|
271
|
+
return retryAfterSeconds ?? RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS[0];
|
|
272
|
+
}
|
|
273
|
+
function shouldRetryExistingFileReview(review) {
|
|
274
|
+
return review.file_status === "failed" && isRetryableFileReviewErrorMessage(review.error_msg);
|
|
275
|
+
}
|
|
276
|
+
function countsAsHandledFileReview(review) {
|
|
277
|
+
return !shouldRetryExistingFileReview(review);
|
|
278
|
+
}
|
|
279
|
+
function isAwaitingAsyncReview(review) {
|
|
280
|
+
return review.file_status === "pending" && !!review.async_request_id;
|
|
281
|
+
}
|
|
282
|
+
function bareModelId(model) {
|
|
283
|
+
const normalized = normalizeModelId(model);
|
|
284
|
+
const colon = normalized.indexOf(":");
|
|
285
|
+
return colon === -1 ? normalized : normalized.slice(colon + 1);
|
|
286
|
+
}
|
|
287
|
+
function configuredModelSet(config) {
|
|
288
|
+
const models = /* @__PURE__ */ new Set();
|
|
289
|
+
const addModel = (model) => {
|
|
290
|
+
if (model) models.add(bareModelId(model));
|
|
291
|
+
};
|
|
292
|
+
addModel(config.model?.main);
|
|
293
|
+
for (const fallback of config.model?.fallbacks ?? []) {
|
|
294
|
+
addModel(fallback);
|
|
295
|
+
}
|
|
296
|
+
for (const tier of config.model?.size_overrides ?? []) {
|
|
297
|
+
addModel(tier.model);
|
|
298
|
+
for (const fallback of tier.fallbacks ?? []) {
|
|
299
|
+
addModel(fallback);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return models;
|
|
303
|
+
}
|
|
304
|
+
function canInheritParentFileReview(config, review) {
|
|
305
|
+
return configuredModelSet(config).has(bareModelId(review.model_used));
|
|
306
|
+
}
|
|
307
|
+
async function resolveModelProviderName(env, modelId) {
|
|
308
|
+
if (!modelId || modelId === "unconfigured") return null;
|
|
309
|
+
try {
|
|
310
|
+
const resolved = await env.modelConfigs.getResolvedModelConfig(normalizeModelId(modelId));
|
|
311
|
+
return resolved?.providerName ?? null;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.warn(`Failed to resolve provider for model ${modelId}`, {
|
|
314
|
+
error: error instanceof Error ? error.message : String(error)
|
|
315
|
+
});
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/review/file-runner.ts
|
|
321
|
+
import "@codraoss/schema";
|
|
322
|
+
async function persistCompletedReview(env, job, file, response) {
|
|
323
|
+
await env.fileReviews.upsertFileReview(job.id, {
|
|
324
|
+
filePath: file.path,
|
|
325
|
+
fileStatus: "done",
|
|
326
|
+
modelUsed: response.modelUsed,
|
|
327
|
+
modelProvider: response.provider,
|
|
328
|
+
diffLineCount: file.lineCount,
|
|
329
|
+
diffInput: null,
|
|
330
|
+
rawAiOutput: response.rawText,
|
|
331
|
+
parsedComments: response.parsed.comments,
|
|
332
|
+
inputTokens: response.inputTokens,
|
|
333
|
+
outputTokens: response.outputTokens,
|
|
334
|
+
durationMs: null,
|
|
335
|
+
verdict: response.parsed.verdict,
|
|
336
|
+
fileSummary: response.parsed.fileSummary,
|
|
337
|
+
overallCorrectness: response.parsed.overallCorrectness,
|
|
338
|
+
confidenceScore: response.parsed.confidenceScore,
|
|
339
|
+
errorMessage: null,
|
|
340
|
+
asyncRequestId: null,
|
|
341
|
+
asyncModel: null
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
async function persistFailedFileReview(env, jobId, input) {
|
|
345
|
+
await env.fileReviews.upsertFileReview(jobId, {
|
|
346
|
+
filePath: input.filePath,
|
|
347
|
+
fileStatus: "failed",
|
|
348
|
+
modelUsed: input.modelUsed,
|
|
349
|
+
modelProvider: input.modelProvider ?? null,
|
|
350
|
+
diffLineCount: input.diffLineCount,
|
|
351
|
+
diffInput: null,
|
|
352
|
+
rawAiOutput: null,
|
|
353
|
+
parsedComments: input.parsedComments ?? [],
|
|
354
|
+
inputTokens: null,
|
|
355
|
+
outputTokens: null,
|
|
356
|
+
durationMs: input.durationMs ?? null,
|
|
357
|
+
verdict: null,
|
|
358
|
+
fileSummary: null,
|
|
359
|
+
errorMessage: input.errorMessage,
|
|
360
|
+
...input.clearAsync ? { asyncRequestId: null, asyncModel: null } : {}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function scanRuleChannel(file, config) {
|
|
364
|
+
const rules = config.review.rules;
|
|
365
|
+
if (!rules?.enabled) return { comments: [], stats: null };
|
|
366
|
+
try {
|
|
367
|
+
const result = scanFileForRuleHits(file, {
|
|
368
|
+
disabledRuleIds: rules.disabled_rule_ids,
|
|
369
|
+
shadowRuleIds: rules.shadow_rule_ids,
|
|
370
|
+
deniedClaimTypes: config.review.deny_claim_types
|
|
371
|
+
});
|
|
372
|
+
return { comments: ruleHitsToComments(file, result), stats: result.stats };
|
|
373
|
+
} catch (error) {
|
|
374
|
+
logger.warn(`Rule scan failed for ${file.path}; continuing with LLM findings only`, {
|
|
375
|
+
error: error instanceof Error ? error.message : String(error)
|
|
376
|
+
});
|
|
377
|
+
return { comments: [], stats: null };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async function reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, previousReview, rejectedExemplars = []) {
|
|
381
|
+
const startedAt = env.clock.now();
|
|
382
|
+
const compactPrompt = (previousReview?.transient_error_count ?? 0) > 0;
|
|
383
|
+
const ruleScan = scanRuleChannel(file, config);
|
|
384
|
+
try {
|
|
385
|
+
const response = await model.reviewFile({
|
|
386
|
+
file,
|
|
387
|
+
prTitle: pr.title ?? null,
|
|
388
|
+
prDescription: pr.body ?? null,
|
|
389
|
+
config,
|
|
390
|
+
totalLineCount,
|
|
391
|
+
compactPrompt,
|
|
392
|
+
rejectedExemplars
|
|
393
|
+
});
|
|
394
|
+
await env.fileReviews.upsertFileReview(job.id, {
|
|
395
|
+
filePath: file.path,
|
|
396
|
+
fileStatus: "done",
|
|
397
|
+
modelUsed: response.modelUsed,
|
|
398
|
+
modelProvider: response.provider,
|
|
399
|
+
diffLineCount: file.lineCount,
|
|
400
|
+
diffInput: null,
|
|
401
|
+
rawAiOutput: response.rawText,
|
|
402
|
+
parsedComments: [...response.parsed.comments, ...ruleScan.comments],
|
|
403
|
+
inputTokens: response.inputTokens,
|
|
404
|
+
outputTokens: response.outputTokens,
|
|
405
|
+
durationMs: env.clock.now() - startedAt,
|
|
406
|
+
verdict: response.parsed.verdict,
|
|
407
|
+
fileSummary: response.parsed.fileSummary,
|
|
408
|
+
overallCorrectness: response.parsed.overallCorrectness,
|
|
409
|
+
confidenceScore: response.parsed.confidenceScore,
|
|
410
|
+
errorMessage: null,
|
|
411
|
+
withheldCounts: {
|
|
412
|
+
evidence: (response.parsed.evidenceStats?.unmatched ?? 0) + (response.parsed.evidenceStats?.absent ?? 0) + (response.parsed.evidenceStats?.weak ?? 0),
|
|
413
|
+
claimDenied: Object.values(response.parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0)
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
logger.info(`File review parsed: ${file.path}`, {
|
|
417
|
+
jobId: job.id,
|
|
418
|
+
model: response.modelUsed,
|
|
419
|
+
kept: response.parsed.comments.length,
|
|
420
|
+
evidence: response.parsed.evidenceStats,
|
|
421
|
+
claimTypes: response.parsed.claimTypeCounts,
|
|
422
|
+
deniedClaims: response.parsed.deniedClaimCounts,
|
|
423
|
+
absenceCheck: response.parsed.absenceCheckStats,
|
|
424
|
+
ruleChannel: ruleScan.stats,
|
|
425
|
+
degraded: response.degraded
|
|
426
|
+
});
|
|
427
|
+
if (response.wasPromptTruncated) {
|
|
428
|
+
logger.warn(`Reviewed only part of ${file.path}; findings from the remainder are missing.`, {
|
|
429
|
+
jobId: job.id,
|
|
430
|
+
model: response.modelUsed,
|
|
431
|
+
reviewedLineCount: response.reviewedLineCount,
|
|
432
|
+
diffLineCount: file.lineCount
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown file review error";
|
|
437
|
+
const modelId = config.model?.main ?? "unconfigured";
|
|
438
|
+
const modelProvider = await resolveFailureModelProvider();
|
|
439
|
+
if (isSubrequestBudgetError(error)) {
|
|
440
|
+
logger.warn(`File review deferred for ${file.path}; subrequest budget will retry in a fresh invocation`, {
|
|
441
|
+
error: errorMessage
|
|
442
|
+
});
|
|
443
|
+
Object.defineProperty(error, "retryAfterSeconds", {
|
|
444
|
+
value: FRESH_INVOCATION_YIELD_SECONDS,
|
|
445
|
+
configurable: true
|
|
446
|
+
});
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
if (env.modelErrors.isRetryableModelError(error)) {
|
|
450
|
+
const failureCount = await env.fileReviews.recordRetryableFileReviewFailure(job.id, {
|
|
451
|
+
filePath: file.path,
|
|
452
|
+
modelUsed: modelId,
|
|
453
|
+
modelProvider,
|
|
454
|
+
diffLineCount: file.lineCount,
|
|
455
|
+
diffInput: null,
|
|
456
|
+
durationMs: env.clock.now() - startedAt,
|
|
457
|
+
errorMessage,
|
|
458
|
+
countsAsAttempt: env.modelErrors.nextChainIndexOf(error) === null
|
|
459
|
+
});
|
|
460
|
+
if (failureCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES) {
|
|
461
|
+
const finalError = `Review skipped after ${failureCount} repeated model provider outages.`;
|
|
462
|
+
await persistFailedFileReview(env, job.id, {
|
|
463
|
+
filePath: file.path,
|
|
464
|
+
modelUsed: modelId,
|
|
465
|
+
modelProvider,
|
|
466
|
+
diffLineCount: file.lineCount,
|
|
467
|
+
durationMs: env.clock.now() - startedAt,
|
|
468
|
+
errorMessage: finalError,
|
|
469
|
+
parsedComments: ruleScan.comments
|
|
470
|
+
});
|
|
471
|
+
logger.error(`File review failed permanently for ${file.path} after transient retries`, {
|
|
472
|
+
attempts: failureCount,
|
|
473
|
+
error: errorMessage
|
|
474
|
+
});
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
logger.warn(`File review deferred for ${file.path}; transient model/provider failure will retry later`, {
|
|
478
|
+
error: errorMessage,
|
|
479
|
+
attempts: failureCount
|
|
480
|
+
});
|
|
481
|
+
Object.defineProperty(error, "retryAfterSeconds", {
|
|
482
|
+
value: retryableModelFailureDelaySeconds(failureCount),
|
|
483
|
+
configurable: true
|
|
484
|
+
});
|
|
485
|
+
throw error;
|
|
486
|
+
}
|
|
487
|
+
logger.error(`File review failed for ${file.path}`, { error });
|
|
488
|
+
const isHardLimit = errorMessage.includes("4006") || errorMessage.toLowerCase().includes("allocation");
|
|
489
|
+
if (isHardLimit) {
|
|
490
|
+
logger.warn(`File review hit hard provider allocation limit for ${file.path}, marking as failed to allow partial PR review.`, { error: errorMessage });
|
|
491
|
+
}
|
|
492
|
+
await persistFailedFileReview(env, job.id, {
|
|
493
|
+
filePath: file.path,
|
|
494
|
+
modelUsed: modelId,
|
|
495
|
+
modelProvider,
|
|
496
|
+
diffLineCount: file.lineCount,
|
|
497
|
+
durationMs: env.clock.now() - startedAt,
|
|
498
|
+
errorMessage,
|
|
499
|
+
parsedComments: ruleScan.comments
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/review/bin-runner.ts
|
|
505
|
+
function proportionalSplit(total, weights) {
|
|
506
|
+
if (weights.length === 0) return [];
|
|
507
|
+
const sum = weights.reduce((a, b) => a + b, 0);
|
|
508
|
+
const parts = sum <= 0 ? weights.map(() => Math.floor(total / weights.length)) : weights.map((w) => Math.floor(total * w / sum));
|
|
509
|
+
const assigned = parts.reduce((a, b) => a + b, 0);
|
|
510
|
+
if (assigned < total) {
|
|
511
|
+
const largest = weights.indexOf(Math.max(...weights));
|
|
512
|
+
parts[largest === -1 ? 0 : largest] += total - assigned;
|
|
513
|
+
}
|
|
514
|
+
return parts;
|
|
515
|
+
}
|
|
516
|
+
async function reviewAndPersistBin(env, job, files, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars = []) {
|
|
517
|
+
const startedAt = env.clock.now();
|
|
518
|
+
const ruleScans = new Map(files.map((file) => [file.path, scanRuleChannel(file, config)]));
|
|
519
|
+
const persisted = /* @__PURE__ */ new Set();
|
|
520
|
+
let terminalCount = 0;
|
|
521
|
+
const failedRow = (file, errorMessage, modelProvider) => ({
|
|
522
|
+
filePath: file.path,
|
|
523
|
+
fileStatus: "failed",
|
|
524
|
+
modelUsed: config.model?.main ?? "unconfigured",
|
|
525
|
+
modelProvider: modelProvider ?? null,
|
|
526
|
+
diffLineCount: file.lineCount,
|
|
527
|
+
rawAiOutput: null,
|
|
528
|
+
parsedComments: ruleScans.get(file.path)?.comments ?? [],
|
|
529
|
+
inputTokens: null,
|
|
530
|
+
outputTokens: null,
|
|
531
|
+
durationMs: env.clock.now() - startedAt,
|
|
532
|
+
verdict: null,
|
|
533
|
+
fileSummary: null,
|
|
534
|
+
errorMessage,
|
|
535
|
+
batchSize: files.length
|
|
536
|
+
});
|
|
537
|
+
try {
|
|
538
|
+
const response = await model.reviewFiles({
|
|
539
|
+
files,
|
|
540
|
+
prTitle: pr.title ?? null,
|
|
541
|
+
prDescription: pr.body ?? null,
|
|
542
|
+
config,
|
|
543
|
+
totalLineCount,
|
|
544
|
+
rejectedExemplars
|
|
545
|
+
});
|
|
546
|
+
const reviewed = files.filter((file) => response.batch.reviews.has(file.path));
|
|
547
|
+
const weights = reviewed.map((file) => renderFileDiff(file).length);
|
|
548
|
+
const inputSplit = proportionalSplit(response.inputTokens, weights);
|
|
549
|
+
const outputSplit = proportionalSplit(response.outputTokens, weights);
|
|
550
|
+
const durationMs = env.clock.now() - startedAt;
|
|
551
|
+
const rows = reviewed.map((file, index) => {
|
|
552
|
+
const parsed = response.batch.reviews.get(file.path);
|
|
553
|
+
const rules = ruleScans.get(file.path);
|
|
554
|
+
return {
|
|
555
|
+
filePath: file.path,
|
|
556
|
+
fileStatus: "done",
|
|
557
|
+
modelUsed: response.modelUsed,
|
|
558
|
+
modelProvider: response.provider,
|
|
559
|
+
diffLineCount: file.lineCount,
|
|
560
|
+
rawAiOutput: response.rawText,
|
|
561
|
+
parsedComments: [...parsed.comments, ...rules.comments],
|
|
562
|
+
inputTokens: inputSplit[index],
|
|
563
|
+
outputTokens: outputSplit[index],
|
|
564
|
+
durationMs,
|
|
565
|
+
verdict: parsed.verdict,
|
|
566
|
+
fileSummary: parsed.fileSummary,
|
|
567
|
+
overallCorrectness: parsed.overallCorrectness,
|
|
568
|
+
confidenceScore: parsed.confidenceScore,
|
|
569
|
+
errorMessage: null,
|
|
570
|
+
withheldCounts: {
|
|
571
|
+
evidence: (parsed.evidenceStats?.unmatched ?? 0) + (parsed.evidenceStats?.absent ?? 0) + (parsed.evidenceStats?.weak ?? 0),
|
|
572
|
+
claimDenied: Object.values(parsed.deniedClaimCounts ?? {}).reduce((sum, n) => sum + n, 0)
|
|
573
|
+
},
|
|
574
|
+
batchSize: files.length
|
|
575
|
+
};
|
|
576
|
+
});
|
|
577
|
+
if (rows.length > 0) {
|
|
578
|
+
await env.fileReviews.bulkUpsertFileReviews(job.id, rows);
|
|
579
|
+
for (const row of rows) persisted.add(row.filePath);
|
|
580
|
+
terminalCount += rows.length;
|
|
581
|
+
}
|
|
582
|
+
if (response.batch.missing.length > 0) {
|
|
583
|
+
const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, response.batch.missing.map((path) => ({
|
|
584
|
+
filePath: path,
|
|
585
|
+
modelUsed: response.modelUsed,
|
|
586
|
+
diffLineCount: files.find((f) => f.path === path)?.lineCount ?? 0,
|
|
587
|
+
errorMessage: MISSING_FILE_ERROR
|
|
588
|
+
})));
|
|
589
|
+
for (const count of counts) persisted.add(count.filePath);
|
|
590
|
+
const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES);
|
|
591
|
+
if (exhausted.length > 0) {
|
|
592
|
+
await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow(
|
|
593
|
+
files.find((f) => f.path === c.filePath),
|
|
594
|
+
`Review skipped after the model omitted this file ${c.transientErrorCount} times.`
|
|
595
|
+
)));
|
|
596
|
+
terminalCount += exhausted.length;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
logger.info("Batched file review parsed", {
|
|
600
|
+
jobId: job.id,
|
|
601
|
+
model: response.modelUsed,
|
|
602
|
+
binSize: files.length,
|
|
603
|
+
binPaths: files.map((f) => f.path),
|
|
604
|
+
binDiffLines: files.reduce((sum, f) => sum + f.lineCount, 0),
|
|
605
|
+
durationMs,
|
|
606
|
+
inputTokens: response.inputTokens,
|
|
607
|
+
outputTokens: response.outputTokens,
|
|
608
|
+
keptPerFile: rows.map((r) => ({ path: r.filePath, kept: r.parsedComments.length })),
|
|
609
|
+
entriesReturned: response.batch.stats.entriesReturned,
|
|
610
|
+
missingFiles: response.batch.missing,
|
|
611
|
+
unroutableEntries: response.batch.stats.unroutableEntries,
|
|
612
|
+
pathMismatchFindings: response.batch.stats.pathMismatchFindings,
|
|
613
|
+
ambiguousAcrossBin: response.batch.stats.ambiguousAcrossBin,
|
|
614
|
+
flatFallback: response.batch.stats.flatFallback,
|
|
615
|
+
overCap: response.batch.stats.overCap
|
|
616
|
+
});
|
|
617
|
+
} catch (error) {
|
|
618
|
+
const errorMessage = error instanceof Error ? error.message : "Unknown batched review error";
|
|
619
|
+
const modelId = config.model?.main ?? "unconfigured";
|
|
620
|
+
const modelProvider = await resolveFailureModelProvider();
|
|
621
|
+
if (isSubrequestBudgetError(error)) {
|
|
622
|
+
logger.warn("Batched review deferred; subrequest budget will retry in a fresh invocation", {
|
|
623
|
+
jobId: job.id,
|
|
624
|
+
paths: files.map((f) => f.path),
|
|
625
|
+
error: errorMessage
|
|
626
|
+
});
|
|
627
|
+
Object.defineProperty(error, "retryAfterSeconds", { value: FRESH_INVOCATION_YIELD_SECONDS, configurable: true });
|
|
628
|
+
throw error;
|
|
629
|
+
}
|
|
630
|
+
const outstanding = files.filter((file) => !persisted.has(file.path));
|
|
631
|
+
if (outstanding.length === 0) {
|
|
632
|
+
logger.warn("Batched review hit an error after every file was persisted; keeping the committed rows", {
|
|
633
|
+
jobId: job.id,
|
|
634
|
+
paths: files.map((f) => f.path),
|
|
635
|
+
error: errorMessage
|
|
636
|
+
});
|
|
637
|
+
return terminalCount;
|
|
638
|
+
}
|
|
639
|
+
if (env.modelErrors.isRetryableModelError(error)) {
|
|
640
|
+
const advancedTo = env.modelErrors.nextChainIndexOf(error);
|
|
641
|
+
const counts = await env.fileReviews.bulkRecordRetryableFileReviewFailures(job.id, outstanding.map((file) => ({
|
|
642
|
+
filePath: file.path,
|
|
643
|
+
modelUsed: modelId,
|
|
644
|
+
diffLineCount: file.lineCount,
|
|
645
|
+
errorMessage
|
|
646
|
+
})), { countsAsAttempt: advancedTo === null });
|
|
647
|
+
const exhausted = counts.filter((c) => c.transientErrorCount >= MAX_RETRYABLE_FILE_REVIEW_FAILURES);
|
|
648
|
+
if (exhausted.length > 0) {
|
|
649
|
+
await env.fileReviews.bulkUpsertFileReviews(job.id, exhausted.map((c) => failedRow(
|
|
650
|
+
files.find((f) => f.path === c.filePath),
|
|
651
|
+
`Review skipped after ${c.transientErrorCount} repeated model provider outages.`,
|
|
652
|
+
modelProvider
|
|
653
|
+
)));
|
|
654
|
+
terminalCount += exhausted.length;
|
|
655
|
+
logger.error("Files in a batched review failed permanently after transient retries", {
|
|
656
|
+
jobId: job.id,
|
|
657
|
+
paths: exhausted.map((c) => c.filePath),
|
|
658
|
+
error: errorMessage
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
const stillRetrying = counts.filter((c) => c.transientErrorCount < MAX_RETRYABLE_FILE_REVIEW_FAILURES);
|
|
662
|
+
if (stillRetrying.length === 0) return terminalCount;
|
|
663
|
+
logger.warn("Batched review deferred; transient model/provider failure will retry later", {
|
|
664
|
+
jobId: job.id,
|
|
665
|
+
paths: stillRetrying.map((c) => c.filePath),
|
|
666
|
+
error: errorMessage
|
|
667
|
+
});
|
|
668
|
+
Object.defineProperty(error, "retryAfterSeconds", {
|
|
669
|
+
value: retryableModelFailureDelaySeconds(Math.max(...stillRetrying.map((c) => c.transientErrorCount))),
|
|
670
|
+
configurable: true
|
|
671
|
+
});
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
logger.error("Batched review failed", { jobId: job.id, paths: outstanding.map((f) => f.path), error });
|
|
675
|
+
await env.fileReviews.bulkUpsertFileReviews(job.id, outstanding.map((file) => failedRow(file, errorMessage, modelProvider)));
|
|
676
|
+
terminalCount += outstanding.length;
|
|
677
|
+
}
|
|
678
|
+
return terminalCount;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/finding-gates.ts
|
|
682
|
+
var LOW_YIELD_TITLE = /missing|redundant|repetitive|inconsisten|documentation|\btype\b|\bany\b|potential/i;
|
|
683
|
+
function shadowEvaluate(candidates, posted) {
|
|
684
|
+
const postedSet = new Set(posted);
|
|
685
|
+
const count = (predicate) => ({
|
|
686
|
+
wouldDrop: candidates.filter(predicate).length,
|
|
687
|
+
wouldDropPosted: posted.filter(predicate).length
|
|
688
|
+
});
|
|
689
|
+
return {
|
|
690
|
+
candidates: candidates.length,
|
|
691
|
+
posted: postedSet.size,
|
|
692
|
+
dropP3AndNit: count((c) => c.severity === "P3" || c.severity === "nit"),
|
|
693
|
+
dropLowYieldTitle: count((c) => LOW_YIELD_TITLE.test(c.title)),
|
|
694
|
+
dropUnmatchedEvidence: count((c) => !c.evidence)
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
function verifyCandidateLimit(effectiveMaxComments) {
|
|
698
|
+
return Math.min(40, Math.max(10, effectiveMaxComments * 3));
|
|
699
|
+
}
|
|
700
|
+
async function verifyFindings(params) {
|
|
701
|
+
const { comments, files, model, config, job } = params;
|
|
702
|
+
const keepAll = () => ({ comments, dropped: [], reasons: /* @__PURE__ */ new Map() });
|
|
703
|
+
if (comments.length === 0) return keepAll();
|
|
704
|
+
const limit = verifyCandidateLimit(params.maxCandidates ?? config.review.max_comments);
|
|
705
|
+
const toVerify = comments.slice(0, limit);
|
|
706
|
+
const fileByPath = new Map(files.map((file) => [file.path, file]));
|
|
707
|
+
const prepared = toVerify.map((comment) => ({
|
|
708
|
+
comment,
|
|
709
|
+
snippet: renderDiffSnippet(fileByPath.get(comment.path), comment.line ?? void 0)
|
|
710
|
+
}));
|
|
711
|
+
const verifiable = prepared.filter((entry) => entry.snippet !== "" || entry.comment.evidence);
|
|
712
|
+
if (verifiable.length === 0) return keepAll();
|
|
713
|
+
const candidates = verifiable.map((entry, index) => ({
|
|
714
|
+
index,
|
|
715
|
+
path: entry.comment.path,
|
|
716
|
+
line: entry.comment.line ?? null,
|
|
717
|
+
title: entry.comment.title,
|
|
718
|
+
body: entry.comment.body,
|
|
719
|
+
snippet: entry.snippet,
|
|
720
|
+
evidence: entry.comment.evidence ?? null
|
|
721
|
+
}));
|
|
722
|
+
try {
|
|
723
|
+
const response = await model.verifyFindings({ candidates, config });
|
|
724
|
+
const results = parseVerifyResponse(response.rawText);
|
|
725
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
726
|
+
const conflicting = /* @__PURE__ */ new Set();
|
|
727
|
+
for (const result of results) {
|
|
728
|
+
if (!Number.isInteger(result.index) || result.index < 0 || result.index >= candidates.length) continue;
|
|
729
|
+
const verdict = result.decidable === false ? "drop" : result.verdict;
|
|
730
|
+
const prior = byIndex.get(result.index);
|
|
731
|
+
if (prior && prior.verdict !== verdict) {
|
|
732
|
+
conflicting.add(result.index);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (!prior) byIndex.set(result.index, { verdict, reason: result.reason });
|
|
736
|
+
}
|
|
737
|
+
for (const index of conflicting) byIndex.delete(index);
|
|
738
|
+
const answered = byIndex.size;
|
|
739
|
+
if (answered === 0 || answered / candidates.length < VERIFY_MIN_ANSWER_RATIO) {
|
|
740
|
+
logger.warn("Verification did not answer enough indices; keeping all findings", {
|
|
741
|
+
jobId: job.id,
|
|
742
|
+
candidates: candidates.length,
|
|
743
|
+
answered
|
|
744
|
+
});
|
|
745
|
+
return keepAll();
|
|
746
|
+
}
|
|
747
|
+
const dropped = [];
|
|
748
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
749
|
+
verifiable.forEach((entry, index) => {
|
|
750
|
+
const result = byIndex.get(index);
|
|
751
|
+
if (result?.reason) reasons.set(entry.comment, result.reason);
|
|
752
|
+
if (result?.verdict === "drop") {
|
|
753
|
+
dropped.push({ comment: entry.comment, disposition: "verify", reason: result.reason });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (!result) {
|
|
757
|
+
dropped.push({
|
|
758
|
+
comment: entry.comment,
|
|
759
|
+
disposition: "verify_unanswered",
|
|
760
|
+
reason: "the verifier returned no verdict for this finding"
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
const droppedSet = new Set(dropped.map((drop) => drop.comment));
|
|
765
|
+
logger.info("Verification pass complete", {
|
|
766
|
+
jobId: job.id,
|
|
767
|
+
candidates: candidates.length,
|
|
768
|
+
answered,
|
|
769
|
+
dropped: dropped.length,
|
|
770
|
+
topReasons: dropped.slice(0, 5).map((drop) => drop.reason)
|
|
771
|
+
});
|
|
772
|
+
return { comments: comments.filter((comment) => !droppedSet.has(comment)), dropped, reasons };
|
|
773
|
+
} catch (error) {
|
|
774
|
+
logger.warn("Verification pass failed; posting pre-verification findings", {
|
|
775
|
+
jobId: job.id,
|
|
776
|
+
error: error instanceof Error ? error.message : String(error)
|
|
777
|
+
});
|
|
778
|
+
return keepAll();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/review/prepare.ts
|
|
783
|
+
import { defaultRepoConfig } from "@codraoss/schema";
|
|
784
|
+
async function runPreparePhase(env, job, leaseOwner, github) {
|
|
785
|
+
await env.jobs.updateJobStep(job.id, "Preparation", { status: "running" });
|
|
786
|
+
const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber);
|
|
787
|
+
const config = job.configSnapshot ?? defaultRepoConfig;
|
|
788
|
+
try {
|
|
789
|
+
await env.jobs.setJobPullRequestMeta(job.id, {
|
|
790
|
+
prTitle: pr.title ?? null,
|
|
791
|
+
prAuthor: pr.user?.login ?? null
|
|
792
|
+
});
|
|
793
|
+
} catch (error) {
|
|
794
|
+
logger.warn(`Failed to refresh PR metadata for job ${job.id}`, error instanceof Error ? error : new Error(String(error)));
|
|
795
|
+
}
|
|
796
|
+
let checkRunId = job.checkRunId;
|
|
797
|
+
if (!checkRunId) {
|
|
798
|
+
const checkRun = await github.createCheckRun(job.owner, job.repo, {
|
|
799
|
+
headSha: pr.head.sha,
|
|
800
|
+
title: "Review queued",
|
|
801
|
+
summary: "Codra has started reviewing this pull request."
|
|
802
|
+
});
|
|
803
|
+
checkRunId = checkRun.id;
|
|
804
|
+
await env.jobs.updateJobCheckRun(job.id, checkRun.id);
|
|
805
|
+
}
|
|
806
|
+
const { maxFiles } = await env.settings.getReviewSettings();
|
|
807
|
+
const { files } = await getDiffFiles(env, job, github, config, maxFiles);
|
|
808
|
+
await env.jobs.completePreparationStep(job.id, files.length);
|
|
809
|
+
await env.jobs.heartbeatJobLease(job.id, leaseOwner, JOB_LEASE_SECONDS);
|
|
810
|
+
if (files.length === 0) {
|
|
811
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "done" });
|
|
812
|
+
await enqueueJobPhase(env, job.id, "finalize", FRESH_INVOCATION_YIELD_SECONDS);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (checkRunId) {
|
|
816
|
+
try {
|
|
817
|
+
await github.updateCheckRun(job.owner, job.repo, checkRunId, {
|
|
818
|
+
title: `Reviewing (0/${files.length})`,
|
|
819
|
+
summary: "Codra is analyzing changed files."
|
|
820
|
+
});
|
|
821
|
+
} catch (error) {
|
|
822
|
+
logger.warn(`Failed to update initial progress check run for job ${job.id}; continuing to the review phase anyway`, error instanceof Error ? error : new Error(String(error)));
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
await enqueueJobPhase(env, job.id, "review", FRESH_INVOCATION_YIELD_SECONDS);
|
|
826
|
+
}
|
|
827
|
+
async function loadRejectedExemplars(env, job) {
|
|
828
|
+
try {
|
|
829
|
+
const repositoryId = await env.learning.getRepositoryIdForJob(job.id);
|
|
830
|
+
if (repositoryId === null) return [];
|
|
831
|
+
const rows = await env.learning.getRejectedExemplars({ repositoryId, limit: 5 });
|
|
832
|
+
return rows.map((row) => ({ title: row.title, claimType: row.claim_type }));
|
|
833
|
+
} catch (error) {
|
|
834
|
+
logger.warn("Could not load rejected exemplars; reviewing without them", {
|
|
835
|
+
jobId: job.id,
|
|
836
|
+
error: error instanceof Error ? error.message : String(error)
|
|
837
|
+
});
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// src/review/phase.ts
|
|
843
|
+
import { defaultRepoConfig as defaultRepoConfig2, REVIEW_CONCURRENCY_LIMITS } from "@codraoss/schema";
|
|
844
|
+
async function runReviewPhase(env, job, leaseOwner, github, model, tracker) {
|
|
845
|
+
if (!hasCompletedStep(job, "Preparation")) {
|
|
846
|
+
await runPreparePhase(env, job, leaseOwner, github);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "running" });
|
|
850
|
+
const [rejectedExemplars, pr] = await Promise.all([
|
|
851
|
+
loadRejectedExemplars(env, job),
|
|
852
|
+
github.getPullRequest(job.owner, job.repo, job.prNumber)
|
|
853
|
+
]);
|
|
854
|
+
const config = job.configSnapshot ?? defaultRepoConfig2;
|
|
855
|
+
const failureModelId = config.model?.main ?? "unconfigured";
|
|
856
|
+
let failureModelProviderPromise = null;
|
|
857
|
+
const resolveFailureModelProvider = () => {
|
|
858
|
+
failureModelProviderPromise ??= resolveModelProviderName(env, failureModelId);
|
|
859
|
+
return failureModelProviderPromise;
|
|
860
|
+
};
|
|
861
|
+
const { concurrencyLevel, maxFiles } = await env.settings.getReviewSettings();
|
|
862
|
+
const { files } = await getDiffFiles(env, job, github, config, maxFiles);
|
|
863
|
+
const totalLineCount = files.reduce((sum, file) => sum + file.lineCount, 0);
|
|
864
|
+
const configuredChunkFileLimit = REVIEW_CONCURRENCY_LIMITS[concurrencyLevel];
|
|
865
|
+
const modelChainLength = 1 + (config.model.fallbacks?.length ?? 0);
|
|
866
|
+
const reviewChunkFileLimit = budgetAwareFileLimit(
|
|
867
|
+
tracker.remainingSafeBudget(),
|
|
868
|
+
configuredChunkFileLimit,
|
|
869
|
+
modelChainLength
|
|
870
|
+
);
|
|
871
|
+
if (reviewChunkFileLimit <= 0) {
|
|
872
|
+
throw new Error("Subrequest budget for this invocation was exhausted before starting the next review chunk.");
|
|
873
|
+
}
|
|
874
|
+
const startedAt = env.clock.now();
|
|
875
|
+
let processedThisChunk = 0;
|
|
876
|
+
const jobIdsToQuery = [job.id];
|
|
877
|
+
if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId);
|
|
878
|
+
const allExistingReviews = await env.fileReviews.getFileReviewsForJobs(jobIdsToQuery);
|
|
879
|
+
const currentReviews = /* @__PURE__ */ new Map();
|
|
880
|
+
const parentReviews = /* @__PURE__ */ new Map();
|
|
881
|
+
for (const review of allExistingReviews) {
|
|
882
|
+
if (review.job_id === job.id) currentReviews.set(review.file_path, review);
|
|
883
|
+
else if (review.file_status === "done") parentReviews.set(review.file_path, review);
|
|
884
|
+
}
|
|
885
|
+
const reviewTasks = [];
|
|
886
|
+
let terminalProgress = 0;
|
|
887
|
+
let awaitingAsync = 0;
|
|
888
|
+
if (job.retryOfJobId && parentReviews.size > 0) {
|
|
889
|
+
const inheritablePaths = files.flatMap((file) => {
|
|
890
|
+
if (currentReviews.has(file.path)) return [];
|
|
891
|
+
const parent = parentReviews.get(file.path);
|
|
892
|
+
return parent && canInheritParentFileReview(config, parent) ? [file.path] : [];
|
|
893
|
+
});
|
|
894
|
+
if (inheritablePaths.length > 0) {
|
|
895
|
+
const inheritedPaths = await env.fileReviews.bulkInheritFileReviews({
|
|
896
|
+
jobId: job.id,
|
|
897
|
+
parentJobId: job.retryOfJobId,
|
|
898
|
+
filePaths: inheritablePaths
|
|
899
|
+
});
|
|
900
|
+
for (const path of inheritedPaths) {
|
|
901
|
+
const parent = parentReviews.get(path);
|
|
902
|
+
if (parent) currentReviews.set(path, parent);
|
|
903
|
+
}
|
|
904
|
+
terminalProgress += inheritedPaths.length;
|
|
905
|
+
if (inheritedPaths.length > 0) {
|
|
906
|
+
logger.info(`Bulk-inherited ${inheritedPaths.length} parent file reviews for job ${job.id} in one pass`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const binnedPaths = /* @__PURE__ */ new Set();
|
|
911
|
+
if (config.review.batch_small_files) {
|
|
912
|
+
const ledger = new Map(files.map((file) => {
|
|
913
|
+
const existing = currentReviews.get(file.path);
|
|
914
|
+
const inheritable = parentReviews.get(file.path);
|
|
915
|
+
return [file.path, {
|
|
916
|
+
handled: Boolean(existing && countsAsHandledFileReview(existing) || inheritable && canInheritParentFileReview(config, inheritable)),
|
|
917
|
+
transientErrorCount: existing?.transient_error_count ?? 0
|
|
918
|
+
}];
|
|
919
|
+
}));
|
|
920
|
+
const units = planReviewUnits(files, { enabled: true }).flatMap((unit) => narrowUnit(unit, ledger));
|
|
921
|
+
const plannedBins = units.filter((unit) => unit.kind === "bin");
|
|
922
|
+
let binsDispatched = 0;
|
|
923
|
+
let filesDispatchedInBins = 0;
|
|
924
|
+
for (const unit of plannedBins) {
|
|
925
|
+
if (processedThisChunk >= reviewChunkFileLimit) break;
|
|
926
|
+
if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) break;
|
|
927
|
+
const binFiles = unit.kind === "bin" ? unit.files : [];
|
|
928
|
+
binFiles.forEach((file) => binnedPaths.add(file.path));
|
|
929
|
+
reviewTasks.push((async () => {
|
|
930
|
+
const terminal = await reviewAndPersistBin(env, job, binFiles, pr, config, totalLineCount, model, resolveFailureModelProvider, rejectedExemplars);
|
|
931
|
+
terminalProgress += terminal;
|
|
932
|
+
})());
|
|
933
|
+
processedThisChunk += 1;
|
|
934
|
+
binsDispatched += 1;
|
|
935
|
+
filesDispatchedInBins += binFiles.length;
|
|
936
|
+
}
|
|
937
|
+
if (plannedBins.length > 0) {
|
|
938
|
+
logger.info("Batched review plan", {
|
|
939
|
+
jobId: job.id,
|
|
940
|
+
binsPlanned: plannedBins.length,
|
|
941
|
+
binsDispatched,
|
|
942
|
+
filesInBins: filesDispatchedInBins,
|
|
943
|
+
modelCallsSaved: filesDispatchedInBins - binsDispatched
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
for (const file of files) {
|
|
948
|
+
if (binnedPaths.has(file.path)) continue;
|
|
949
|
+
const existingReview = currentReviews.get(file.path);
|
|
950
|
+
const awaitingReview = existingReview && isAwaitingAsyncReview(existingReview) ? existingReview : null;
|
|
951
|
+
if (existingReview && countsAsHandledFileReview(existingReview) && !awaitingReview) {
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (!awaitingReview && processedThisChunk >= reviewChunkFileLimit) {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
const inherited = parentReviews.get(file.path);
|
|
958
|
+
const reviewTask = async () => {
|
|
959
|
+
if (awaitingReview) {
|
|
960
|
+
const poll = await model.pollReviewBatch({
|
|
961
|
+
model: awaitingReview.async_model ?? awaitingReview.model_used,
|
|
962
|
+
requestId: awaitingReview.async_request_id,
|
|
963
|
+
file,
|
|
964
|
+
config
|
|
965
|
+
});
|
|
966
|
+
if (poll.status === "pending") {
|
|
967
|
+
awaitingAsync += 1;
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (poll.status === "failed") {
|
|
971
|
+
logger.warn(`Async batch poll failed for ${file.path}; falling back to synchronous review`, {
|
|
972
|
+
error: poll.error instanceof Error ? poll.error.message : String(poll.error)
|
|
973
|
+
});
|
|
974
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
975
|
+
terminalProgress += 1;
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
await persistCompletedReview(env, job, file, poll.response);
|
|
979
|
+
terminalProgress += 1;
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (!inherited) {
|
|
983
|
+
const submitted = await model.submitReviewBatch({
|
|
984
|
+
file,
|
|
985
|
+
prTitle: pr.title ?? null,
|
|
986
|
+
prDescription: pr.body ?? null,
|
|
987
|
+
config,
|
|
988
|
+
totalLineCount,
|
|
989
|
+
compactPrompt: (existingReview?.transient_error_count ?? 0) > 0
|
|
990
|
+
});
|
|
991
|
+
if (submitted) {
|
|
992
|
+
await env.fileReviews.upsertFileReview(job.id, {
|
|
993
|
+
filePath: file.path,
|
|
994
|
+
fileStatus: "pending",
|
|
995
|
+
modelUsed: submitted.model,
|
|
996
|
+
modelProvider: null,
|
|
997
|
+
diffLineCount: file.lineCount,
|
|
998
|
+
diffInput: null,
|
|
999
|
+
rawAiOutput: null,
|
|
1000
|
+
parsedComments: [],
|
|
1001
|
+
inputTokens: null,
|
|
1002
|
+
outputTokens: null,
|
|
1003
|
+
durationMs: null,
|
|
1004
|
+
verdict: null,
|
|
1005
|
+
fileSummary: null,
|
|
1006
|
+
overallCorrectness: null,
|
|
1007
|
+
confidenceScore: null,
|
|
1008
|
+
errorMessage: null,
|
|
1009
|
+
asyncRequestId: submitted.requestId,
|
|
1010
|
+
asyncModel: submitted.model
|
|
1011
|
+
});
|
|
1012
|
+
awaitingAsync += 1;
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
1016
|
+
terminalProgress += 1;
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (!canInheritParentFileReview(config, inherited)) {
|
|
1020
|
+
logger.info(`Ignoring inherited review for ${file.path}; parent model ${inherited.model_used} is not in the current model strategy`);
|
|
1021
|
+
await reviewAndPersistFile(env, job, file, pr, config, totalLineCount, model, resolveFailureModelProvider, existingReview, rejectedExemplars);
|
|
1022
|
+
terminalProgress += 1;
|
|
1023
|
+
} else {
|
|
1024
|
+
await env.fileReviews.upsertFileReview(job.id, {
|
|
1025
|
+
filePath: file.path,
|
|
1026
|
+
fileStatus: "done",
|
|
1027
|
+
modelUsed: inherited.model_used,
|
|
1028
|
+
modelProvider: inherited.model_provider,
|
|
1029
|
+
diffLineCount: inherited.diff_line_count,
|
|
1030
|
+
diffInput: inherited.diff_input,
|
|
1031
|
+
rawAiOutput: inherited.raw_ai_output,
|
|
1032
|
+
parsedComments: inherited.parsed_comments,
|
|
1033
|
+
inputTokens: inherited.input_tokens,
|
|
1034
|
+
outputTokens: inherited.output_tokens,
|
|
1035
|
+
durationMs: inherited.duration_ms,
|
|
1036
|
+
verdict: inherited.verdict,
|
|
1037
|
+
fileSummary: inherited.file_summary,
|
|
1038
|
+
overallCorrectness: inherited.overall_correctness,
|
|
1039
|
+
confidenceScore: inherited.confidence_score,
|
|
1040
|
+
errorMessage: null
|
|
1041
|
+
});
|
|
1042
|
+
currentReviews.set(file.path, inherited);
|
|
1043
|
+
terminalProgress += 1;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
1046
|
+
reviewTasks.push(reviewTask());
|
|
1047
|
+
if (!awaitingReview) processedThisChunk += 1;
|
|
1048
|
+
if (env.clock.now() - startedAt >= REVIEW_CHUNK_WALL_CLOCK_MS) {
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const results = await Promise.allSettled(reviewTasks);
|
|
1053
|
+
await heartbeatAndCheckSuperseded(env, job.id, leaseOwner);
|
|
1054
|
+
if (terminalProgress > 0) {
|
|
1055
|
+
await env.jobs.resetJobContinuationCount(job.id);
|
|
1056
|
+
}
|
|
1057
|
+
logger.info("Review chunk model usage", {
|
|
1058
|
+
jobId: job.id,
|
|
1059
|
+
subrequests: tracker.getSubrequestCount(),
|
|
1060
|
+
usage: tracker.getTotalUsage(),
|
|
1061
|
+
wasted: tracker.getWasted()
|
|
1062
|
+
});
|
|
1063
|
+
const rejected = results.filter((result) => result.status === "rejected");
|
|
1064
|
+
if (rejected.length > 0) {
|
|
1065
|
+
rejected.forEach((result, index) => {
|
|
1066
|
+
logger.error(`Review chunk task ${index + 1}/${rejected.length} failed`, result.reason);
|
|
1067
|
+
});
|
|
1068
|
+
const deferrableError = rejected.map((r) => r.reason).find((r) => env.modelErrors.isRetryableModelError(r) || isSubrequestBudgetError(r));
|
|
1069
|
+
if (deferrableError) {
|
|
1070
|
+
throw deferrableError;
|
|
1071
|
+
}
|
|
1072
|
+
throw rejected.length === 1 ? rejected[0].reason : new AggregateError(rejected.map((result) => result.reason), `${rejected.length} review chunk tasks failed`);
|
|
1073
|
+
}
|
|
1074
|
+
const latestReviews = await env.fileReviews.getFileReviewsForJobs([job.id]);
|
|
1075
|
+
const reviewedPaths = new Set(
|
|
1076
|
+
latestReviews.flatMap((review) => countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review) ? [review.file_path] : [])
|
|
1077
|
+
);
|
|
1078
|
+
const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length;
|
|
1079
|
+
if (completedCount >= files.length) {
|
|
1080
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "done" });
|
|
1081
|
+
await enqueueJobPhase(env, job.id, "finalize", FRESH_INVOCATION_YIELD_SECONDS);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
if (awaitingAsync > 0 && terminalProgress === 0) {
|
|
1085
|
+
const pollCount = await env.jobs.markJobContinuationQueued(job.id, ASYNC_BATCH_POLL_DELAY_SECONDS);
|
|
1086
|
+
if (pollCount > MAX_JOB_CONTINUATIONS) {
|
|
1087
|
+
logger.error(`Async batch reviews did not complete after ${pollCount} polls; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`);
|
|
1088
|
+
for (const review of latestReviews.filter(isAwaitingAsyncReview)) {
|
|
1089
|
+
await persistFailedFileReview(env, job.id, {
|
|
1090
|
+
filePath: review.file_path,
|
|
1091
|
+
modelUsed: review.async_model ?? review.model_used,
|
|
1092
|
+
diffLineCount: review.diff_line_count,
|
|
1093
|
+
errorMessage: "Async batch review did not complete in time.",
|
|
1094
|
+
clearAsync: true
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "done" });
|
|
1098
|
+
throw new NextPhaseError("finalize", FRESH_INVOCATION_YIELD_SECONDS);
|
|
1099
|
+
}
|
|
1100
|
+
throw new NextPhaseError("review", ASYNC_BATCH_POLL_DELAY_SECONDS);
|
|
1101
|
+
}
|
|
1102
|
+
if (job.checkRunId) {
|
|
1103
|
+
try {
|
|
1104
|
+
await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
|
|
1105
|
+
title: `Reviewing (${completedCount}/${files.length})`,
|
|
1106
|
+
summary: "Codra is continuing this review in the next queue chunk."
|
|
1107
|
+
});
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
logger.warn(`Failed to update progress check run for job ${job.id}; continuing to the next chunk anyway`, error instanceof Error ? error : new Error(String(error)));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
await enqueueJobPhase(env, job.id, "review", FRESH_INVOCATION_YIELD_SECONDS);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// src/review/finalize.ts
|
|
1116
|
+
import { defaultRepoConfig as defaultRepoConfig3 } from "@codraoss/schema";
|
|
1117
|
+
|
|
1118
|
+
// src/review/telemetry.ts
|
|
1119
|
+
async function sendReviewTelemetry(env, job, files, reviews, overrides, meta) {
|
|
1120
|
+
try {
|
|
1121
|
+
const doneReviews = reviews.filter((r) => r.file_status === "done");
|
|
1122
|
+
const cleanModels = Array.from(
|
|
1123
|
+
new Set(
|
|
1124
|
+
doneReviews.flatMap((r) => {
|
|
1125
|
+
const model = bareModelId(r.model_used);
|
|
1126
|
+
return model && !model.toLowerCase().includes("test") ? [model] : [];
|
|
1127
|
+
})
|
|
1128
|
+
)
|
|
1129
|
+
);
|
|
1130
|
+
const extractExtension = (filePath) => {
|
|
1131
|
+
const name = filePath.split("/").pop() || filePath;
|
|
1132
|
+
const dotIndex = name.lastIndexOf(".");
|
|
1133
|
+
if (dotIndex <= 0) return "";
|
|
1134
|
+
return name.slice(dotIndex + 1).toLowerCase();
|
|
1135
|
+
};
|
|
1136
|
+
await env.telemetry.send({
|
|
1137
|
+
linesReviewed: files.reduce((sum, file) => sum + file.lineCount, 0),
|
|
1138
|
+
inputTokens: doneReviews.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0),
|
|
1139
|
+
outputTokens: doneReviews.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0),
|
|
1140
|
+
modelsUsed: cleanModels,
|
|
1141
|
+
fileExtensions: Array.from(new Set(files.flatMap((f) => {
|
|
1142
|
+
const extension = extractExtension(f.path);
|
|
1143
|
+
return extension ? [extension] : [];
|
|
1144
|
+
}))),
|
|
1145
|
+
triggerType: job.trigger,
|
|
1146
|
+
reviewDurationMs: Math.max(0, env.clock.now() - new Date(job.createdAt).getTime()),
|
|
1147
|
+
filesReviewed: files.length,
|
|
1148
|
+
concurrencyLevel: meta.concurrencyLevel,
|
|
1149
|
+
prTotalLinesChanged: files.reduce((sum, file) => sum + file.lineCount, 0),
|
|
1150
|
+
retryCount: meta.retryCount,
|
|
1151
|
+
...overrides
|
|
1152
|
+
});
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
logger.error("Failed to send telemetry", e instanceof Error ? e : new Error(String(e)));
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
async function loadSuppressedFingerprints(env, jobId) {
|
|
1158
|
+
const posted = /* @__PURE__ */ new Map();
|
|
1159
|
+
const rejected = /* @__PURE__ */ new Set();
|
|
1160
|
+
const postedV2 = /* @__PURE__ */ new Set();
|
|
1161
|
+
const rejectedV2 = /* @__PURE__ */ new Set();
|
|
1162
|
+
try {
|
|
1163
|
+
for (const row of await env.fileReviews.getSuppressedFindings(jobId)) {
|
|
1164
|
+
if (!row.anchored) {
|
|
1165
|
+
if (row.fingerprint) rejected.add(row.fingerprint);
|
|
1166
|
+
if (row.fingerprint_v2) rejectedV2.add(row.fingerprint_v2);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (row.fingerprint_v2) postedV2.add(row.fingerprint_v2);
|
|
1170
|
+
if (!row.fingerprint || !row.anchor_hash) continue;
|
|
1171
|
+
const anchors = posted.get(row.fingerprint) ?? /* @__PURE__ */ new Set();
|
|
1172
|
+
anchors.add(row.anchor_hash);
|
|
1173
|
+
posted.set(row.fingerprint, anchors);
|
|
1174
|
+
}
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
logger.warn("Could not load suppressed findings; posting without cross-run dedupe", {
|
|
1177
|
+
jobId,
|
|
1178
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
return { posted, rejected, postedV2, rejectedV2 };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// src/review/gate-pipeline.ts
|
|
1185
|
+
async function applyFindingGates(params) {
|
|
1186
|
+
const { env, job, config, files, model, effectiveMaxComments, reviewedComments, reviews } = params;
|
|
1187
|
+
const severityRanks = { P0: 0, P1: 1, P2: 2, P3: 3, nit: 4 };
|
|
1188
|
+
const minRank = severityRanks[config.review.min_severity] ?? 4;
|
|
1189
|
+
const minConfidence = config.review.min_confidence ?? 0;
|
|
1190
|
+
const dispositions = /* @__PURE__ */ new Map();
|
|
1191
|
+
const verifyReasons = /* @__PURE__ */ new Map();
|
|
1192
|
+
const recordDisposition = (comments, stage) => {
|
|
1193
|
+
for (const comment of comments) {
|
|
1194
|
+
if (comment.fingerprint && !dispositions.has(comment.fingerprint)) {
|
|
1195
|
+
dispositions.set(comment.fingerprint, stage);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
let finalComments = reviewedComments.filter((c) => {
|
|
1200
|
+
if ((severityRanks[c.severity] ?? 4) > minRank) {
|
|
1201
|
+
recordDisposition([c], "severity");
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|
|
1204
|
+
if (typeof c.confidenceScore === "number" && c.confidenceScore < minConfidence) {
|
|
1205
|
+
recordDisposition([c], "confidence");
|
|
1206
|
+
return false;
|
|
1207
|
+
}
|
|
1208
|
+
return true;
|
|
1209
|
+
});
|
|
1210
|
+
const suppressed = await loadSuppressedFingerprints(env, job.id);
|
|
1211
|
+
const suppressedComments = [];
|
|
1212
|
+
const hasSuppressionData = suppressed.rejected.size > 0 || suppressed.posted.size > 0 || suppressed.rejectedV2.size > 0 || suppressed.postedV2.size > 0;
|
|
1213
|
+
if (hasSuppressionData) {
|
|
1214
|
+
finalComments = finalComments.filter((c) => {
|
|
1215
|
+
const rejected = c.fingerprint && suppressed.rejected.has(c.fingerprint) || c.fingerprintV2 && suppressed.rejectedV2.has(c.fingerprintV2);
|
|
1216
|
+
const anchors = c.fingerprint ? suppressed.posted.get(c.fingerprint) : void 0;
|
|
1217
|
+
const alreadyPosted = anchors && c.anchorHash && anchors.has(c.anchorHash) || c.fingerprintV2 && suppressed.postedV2.has(c.fingerprintV2);
|
|
1218
|
+
if (rejected || alreadyPosted) {
|
|
1219
|
+
suppressedComments.push(c);
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
return true;
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
const droppedBySuppression = suppressedComments.length;
|
|
1226
|
+
recordDisposition(suppressedComments, "suppression");
|
|
1227
|
+
const beforeDedupe = finalComments;
|
|
1228
|
+
finalComments = dedupeFindings(finalComments);
|
|
1229
|
+
const survivedDedupe = new Set(finalComments);
|
|
1230
|
+
recordDisposition(beforeDedupe.filter((c) => !survivedDedupe.has(c)), "dedupe");
|
|
1231
|
+
finalComments.sort((a, b) => {
|
|
1232
|
+
const rankDiff = (severityRanks[a.severity] ?? 4) - (severityRanks[b.severity] ?? 4);
|
|
1233
|
+
if (rankDiff !== 0) return rankDiff;
|
|
1234
|
+
return (b.confidenceScore ?? 0) - (a.confidenceScore ?? 0);
|
|
1235
|
+
});
|
|
1236
|
+
const beforeVerifyList = finalComments;
|
|
1237
|
+
const verify = await verifyFindings({ job, config, files, comments: finalComments, model, maxCandidates: effectiveMaxComments });
|
|
1238
|
+
finalComments = verify.comments;
|
|
1239
|
+
const droppedByVerification = verify.dropped.length;
|
|
1240
|
+
for (const drop of verify.dropped) recordDisposition([drop.comment], drop.disposition);
|
|
1241
|
+
for (const [comment, reason] of verify.reasons) {
|
|
1242
|
+
if (comment.fingerprint) verifyReasons.set(comment.fingerprint, reason);
|
|
1243
|
+
}
|
|
1244
|
+
const beforeCapList = finalComments;
|
|
1245
|
+
const beforeCap = finalComments.length;
|
|
1246
|
+
if (finalComments.length > effectiveMaxComments) {
|
|
1247
|
+
finalComments = finalComments.slice(0, effectiveMaxComments);
|
|
1248
|
+
}
|
|
1249
|
+
const droppedByCap = beforeCap - finalComments.length;
|
|
1250
|
+
recordDisposition(beforeCapList.slice(effectiveMaxComments), "cap");
|
|
1251
|
+
const omittedCount = reviewedComments.length - finalComments.length;
|
|
1252
|
+
const droppedByFilters = omittedCount - droppedBySuppression - droppedByVerification - droppedByCap;
|
|
1253
|
+
const withheldByParser = reviews.reduce(
|
|
1254
|
+
(sum, review) => sum + (review.withheld_counts?.evidence ?? 0) + (review.withheld_counts?.claimDenied ?? 0),
|
|
1255
|
+
0
|
|
1256
|
+
);
|
|
1257
|
+
const byClaimType = {};
|
|
1258
|
+
for (const comment of reviewedComments) {
|
|
1259
|
+
const key = comment.claimType ?? "unlabelled";
|
|
1260
|
+
byClaimType[key] ??= { generated: 0, posted: 0 };
|
|
1261
|
+
byClaimType[key].generated += 1;
|
|
1262
|
+
}
|
|
1263
|
+
for (const comment of finalComments) {
|
|
1264
|
+
const key = comment.claimType ?? "unlabelled";
|
|
1265
|
+
byClaimType[key] ??= { generated: 0, posted: 0 };
|
|
1266
|
+
byClaimType[key].posted += 1;
|
|
1267
|
+
}
|
|
1268
|
+
return {
|
|
1269
|
+
finalComments,
|
|
1270
|
+
dispositions,
|
|
1271
|
+
verifyReasons,
|
|
1272
|
+
suppressedComments,
|
|
1273
|
+
droppedBySuppression,
|
|
1274
|
+
beforeVerifyList,
|
|
1275
|
+
droppedByVerification,
|
|
1276
|
+
droppedByCap,
|
|
1277
|
+
omittedCount,
|
|
1278
|
+
droppedByFilters,
|
|
1279
|
+
withheldByParser,
|
|
1280
|
+
byClaimType
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// src/review/finalize.ts
|
|
1285
|
+
async function runFinalizePhase(env, job, leaseOwner, github, formatter, model) {
|
|
1286
|
+
await env.jobs.updateJobStep(job.id, "Generating Summary", { status: "running" });
|
|
1287
|
+
const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber);
|
|
1288
|
+
const config = job.configSnapshot ?? defaultRepoConfig3;
|
|
1289
|
+
const reviewSettings = await env.settings.getReviewSettings();
|
|
1290
|
+
const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([
|
|
1291
|
+
getDiffFiles(env, job, github, config, reviewSettings.maxFiles),
|
|
1292
|
+
env.fileReviews.getFileReviewsForJobs([job.id])
|
|
1293
|
+
]);
|
|
1294
|
+
let reviews = initialReviews;
|
|
1295
|
+
{
|
|
1296
|
+
const reviewedPaths = new Set(reviews.map((r) => r.file_path));
|
|
1297
|
+
const missingFiles = files.filter((f) => !reviewedPaths.has(f.path));
|
|
1298
|
+
if (missingFiles.length > 0) {
|
|
1299
|
+
logger.warn(`Job ${job.id} reached finalize phase with ${missingFiles.length} missing file reviews. Forcing them to failed state.`);
|
|
1300
|
+
await env.fileReviews.bulkMarkFilesFailed(
|
|
1301
|
+
job.id,
|
|
1302
|
+
missingFiles.map((file) => ({ filePath: file.path, diffLineCount: file.lineCount })),
|
|
1303
|
+
{ modelUsed: config.model?.main ?? "unconfigured", errorMessage: "This file was not reviewed before the review run completed." }
|
|
1304
|
+
);
|
|
1305
|
+
reviews = await env.fileReviews.getFileReviewsForJobs([job.id]);
|
|
1306
|
+
} else if (reviews.length < files.length) {
|
|
1307
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "running" });
|
|
1308
|
+
await enqueueJobPhase(env, job.id, "review", FRESH_INVOCATION_YIELD_SECONDS);
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
await env.jobs.updateJobStep(job.id, "Reviewing Files", { status: "done" });
|
|
1313
|
+
const reviewedComments = reviews.flatMap((review2) => review2.parsed_comments);
|
|
1314
|
+
const fileSummaries = reviews.map((review2) => ({
|
|
1315
|
+
path: review2.file_path,
|
|
1316
|
+
summary: review2.file_status === "failed" ? `Review failed: ${review2.error_msg ?? "Unknown file review error"}` : review2.file_summary ?? "",
|
|
1317
|
+
verdict: review2.file_status === "failed" ? "failed" : review2.verdict ?? "comment"
|
|
1318
|
+
}));
|
|
1319
|
+
const { concurrencyLevel, maxComments: globalMaxComments } = reviewSettings;
|
|
1320
|
+
const effectiveMaxComments = Math.min(config.review.max_comments, globalMaxComments);
|
|
1321
|
+
const retryCount = job.retryOfJobId ? 1 : 0;
|
|
1322
|
+
if (fileSummaries.length > 0 && fileSummaries.every((file) => file.verdict === "failed")) {
|
|
1323
|
+
await env.jobs.updateJobStep(job.id, "Generating Summary", { status: "failed", error: "All files failed to review" });
|
|
1324
|
+
await sendReviewTelemetry(
|
|
1325
|
+
env,
|
|
1326
|
+
job,
|
|
1327
|
+
files,
|
|
1328
|
+
reviews,
|
|
1329
|
+
{ findingsReported: 0, verdict: "failed", severityDistribution: {} },
|
|
1330
|
+
{ concurrencyLevel, retryCount }
|
|
1331
|
+
);
|
|
1332
|
+
throw new Error("All files failed to review");
|
|
1333
|
+
}
|
|
1334
|
+
const hasFailures = fileSummaries.some((file) => file.verdict === "failed");
|
|
1335
|
+
const failedFileCount = fileSummaries.filter((file) => file.verdict === "failed").length;
|
|
1336
|
+
const {
|
|
1337
|
+
finalComments,
|
|
1338
|
+
dispositions,
|
|
1339
|
+
verifyReasons,
|
|
1340
|
+
suppressedComments,
|
|
1341
|
+
droppedBySuppression,
|
|
1342
|
+
beforeVerifyList,
|
|
1343
|
+
droppedByVerification,
|
|
1344
|
+
droppedByCap,
|
|
1345
|
+
omittedCount,
|
|
1346
|
+
droppedByFilters,
|
|
1347
|
+
withheldByParser,
|
|
1348
|
+
byClaimType
|
|
1349
|
+
} = await applyFindingGates({
|
|
1350
|
+
env,
|
|
1351
|
+
job,
|
|
1352
|
+
config,
|
|
1353
|
+
files,
|
|
1354
|
+
model,
|
|
1355
|
+
effectiveMaxComments,
|
|
1356
|
+
reviewedComments,
|
|
1357
|
+
reviews
|
|
1358
|
+
});
|
|
1359
|
+
logger.info("Finding pipeline outcome", {
|
|
1360
|
+
jobId: job.id,
|
|
1361
|
+
parsed: reviewedComments.length,
|
|
1362
|
+
droppedByFilters,
|
|
1363
|
+
droppedBySuppression,
|
|
1364
|
+
droppedByVerification,
|
|
1365
|
+
droppedByCap,
|
|
1366
|
+
posted: finalComments.length,
|
|
1367
|
+
withheldByParser,
|
|
1368
|
+
byClaimType,
|
|
1369
|
+
byChannel: {
|
|
1370
|
+
llm: finalComments.filter((c) => c.source !== "rule").length,
|
|
1371
|
+
rule: finalComments.filter((c) => c.source === "rule").length
|
|
1372
|
+
},
|
|
1373
|
+
byRule: reviewedComments.reduce((acc, c) => {
|
|
1374
|
+
if (c.source === "rule" && c.ruleId) acc[c.ruleId] = (acc[c.ruleId] ?? 0) + 1;
|
|
1375
|
+
return acc;
|
|
1376
|
+
}, {}),
|
|
1377
|
+
postedAny: finalComments.length > 0,
|
|
1378
|
+
postedPer100Files: files.length > 0 ? Math.round(finalComments.length / files.length * 1e3) / 10 : 0
|
|
1379
|
+
});
|
|
1380
|
+
logger.info("Shadow filter evaluation", {
|
|
1381
|
+
jobId: job.id,
|
|
1382
|
+
...shadowEvaluate(beforeVerifyList, finalComments)
|
|
1383
|
+
});
|
|
1384
|
+
const rawVerdict = formatter.summarizeVerdict([...finalComments, ...suppressedComments], hasFailures);
|
|
1385
|
+
const everythingWithheld = finalComments.length === 0 && suppressedComments.length === 0 && (withheldByParser > 0 || omittedCount > 0);
|
|
1386
|
+
const verdictSummary = everythingWithheld && rawVerdict.verdict === "approve" ? { ...rawVerdict, verdict: "comment" } : rawVerdict;
|
|
1387
|
+
await env.jobs.updateJobStep(job.id, "Generating Summary", { status: "done" });
|
|
1388
|
+
await heartbeatAndCheckSuperseded(env, job.id, leaseOwner);
|
|
1389
|
+
let formattedSummary = formatter.formatReviewOverview(pr.head.sha, env.botUsername);
|
|
1390
|
+
if (filesOverCap > 0) {
|
|
1391
|
+
formattedSummary += `
|
|
1392
|
+
|
|
1393
|
+
> [!WARNING]
|
|
1394
|
+
> **${filesOverCap} file${filesOverCap === 1 ? " was" : "s were"} not reviewed.** This pull request has ${files.length + filesOverCap} reviewable files and the limit is ${reviewSettings.maxFiles}. Raise it in Settings to cover the whole diff.`;
|
|
1395
|
+
}
|
|
1396
|
+
const finalizeRetriedPastPost = job.steps.some(
|
|
1397
|
+
(step) => step.name === "Completing" && (step.status === "running" || step.status === "done")
|
|
1398
|
+
);
|
|
1399
|
+
await env.jobs.updateJobStep(job.id, "Completing", { status: "running" });
|
|
1400
|
+
const existingReview = finalizeRetriedPastPost ? await github.findBotReviewForCommit(job.owner, job.repo, job.prNumber, pr.head.sha, env.botUsername) : null;
|
|
1401
|
+
const review = existingReview ?? await github.createReview(job.owner, job.repo, job.prNumber, {
|
|
1402
|
+
commitSha: pr.head.sha,
|
|
1403
|
+
event: formatter.toReviewEvent(verdictSummary.verdict),
|
|
1404
|
+
body: formattedSummary,
|
|
1405
|
+
comments: finalComments.map((comment) => ({
|
|
1406
|
+
path: comment.path,
|
|
1407
|
+
line: comment.line ?? void 0,
|
|
1408
|
+
side: "RIGHT",
|
|
1409
|
+
position: comment.position ?? void 0,
|
|
1410
|
+
body: formatter.formatInlineComment(comment)
|
|
1411
|
+
}))
|
|
1412
|
+
});
|
|
1413
|
+
if (review.postedIndices && review.postedIndices.length > 0) {
|
|
1414
|
+
const postedFingerprints = review.postedIndices.map((index) => finalComments[index]?.fingerprint).filter((fingerprint) => Boolean(fingerprint));
|
|
1415
|
+
await env.fileReviews.markCommentsPosted(job.id, postedFingerprints);
|
|
1416
|
+
}
|
|
1417
|
+
try {
|
|
1418
|
+
const withReasons = /* @__PURE__ */ new Map();
|
|
1419
|
+
for (const fingerprint of /* @__PURE__ */ new Set([...dispositions.keys(), ...verifyReasons.keys()])) {
|
|
1420
|
+
withReasons.set(fingerprint, {
|
|
1421
|
+
disposition: dispositions.get(fingerprint) ?? null,
|
|
1422
|
+
reason: verifyReasons.get(fingerprint) ?? null
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
await env.fileReviews.markCommentDispositions(job.id, withReasons);
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
logger.warn("Could not record finding dispositions", {
|
|
1428
|
+
jobId: job.id,
|
|
1429
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
const fileInputTokens = reviews.reduce((sum, review2) => sum + (review2.input_tokens ?? 0), 0);
|
|
1433
|
+
const fileOutputTokens = reviews.reduce((sum, review2) => sum + (review2.output_tokens ?? 0), 0);
|
|
1434
|
+
const severityDistribution = {};
|
|
1435
|
+
for (const comment of finalComments) {
|
|
1436
|
+
const sev = comment.severity || "unknown";
|
|
1437
|
+
severityDistribution[sev] = (severityDistribution[sev] || 0) + 1;
|
|
1438
|
+
}
|
|
1439
|
+
const partialErrorMessage = hasFailures ? `Partial review: ${failedFileCount} of ${files.length} file${files.length === 1 ? "" : "s"} could not be reviewed.` : null;
|
|
1440
|
+
await env.jobs.completeJob(job.id, {
|
|
1441
|
+
verdict: verdictSummary.verdict,
|
|
1442
|
+
fileCount: files.length,
|
|
1443
|
+
commentCount: finalComments.length,
|
|
1444
|
+
totalInputTokens: fileInputTokens,
|
|
1445
|
+
totalOutputTokens: fileOutputTokens,
|
|
1446
|
+
summaryMarkdown: formattedSummary,
|
|
1447
|
+
reviewId: review.id,
|
|
1448
|
+
summaryModel: null,
|
|
1449
|
+
errorMessage: partialErrorMessage
|
|
1450
|
+
});
|
|
1451
|
+
logger.info(`Review job completed: ${job.owner}/${job.repo} PR #${job.prNumber}`);
|
|
1452
|
+
try {
|
|
1453
|
+
if (job.checkRunId) {
|
|
1454
|
+
await github.updateCheckRun(job.owner, job.repo, job.checkRunId, {
|
|
1455
|
+
status: "completed",
|
|
1456
|
+
conclusion: hasFailures ? "failure" : verdictSummary.verdict === "approve" ? "success" : "neutral",
|
|
1457
|
+
title: hasFailures ? "Review partially failed" : verdictSummary.verdict === "approve" ? "LGTM" : "Comments posted",
|
|
1458
|
+
summary: `${finalComments.length} inline comments across ${files.length} files.${hasFailures ? ` ${failedFileCount} file${failedFileCount === 1 ? "" : "s"} could not be reviewed.` : ""}`
|
|
1459
|
+
});
|
|
1460
|
+
await env.jobs.markJobCheckRunCompleted(job.id);
|
|
1461
|
+
}
|
|
1462
|
+
if (config.review.labels !== false) {
|
|
1463
|
+
const labels = config.review.labels;
|
|
1464
|
+
const labelMap = {
|
|
1465
|
+
comment: { name: labels.p1, color: "f79009" },
|
|
1466
|
+
approve: { name: labels.p2, color: "027a48" }
|
|
1467
|
+
};
|
|
1468
|
+
const label = labelMap[verdictSummary.verdict];
|
|
1469
|
+
await github.removeIssueLabelsIfPresent(
|
|
1470
|
+
job.owner,
|
|
1471
|
+
job.repo,
|
|
1472
|
+
job.prNumber,
|
|
1473
|
+
[labels.p1, labels.p2, labels.p3].filter((possibleLabel) => possibleLabel !== label.name)
|
|
1474
|
+
);
|
|
1475
|
+
await github.ensureLabel(job.owner, job.repo, label.name, label.color);
|
|
1476
|
+
await github.addIssueLabels(job.owner, job.repo, job.prNumber, [label.name]);
|
|
1477
|
+
}
|
|
1478
|
+
} catch (error) {
|
|
1479
|
+
logger.warn(`Post-review labels/check-run update failed for job ${job.id}; review is posted and job is completed, so leaving it best-effort`, error instanceof Error ? error : new Error(String(error)));
|
|
1480
|
+
}
|
|
1481
|
+
await sendReviewTelemetry(
|
|
1482
|
+
env,
|
|
1483
|
+
job,
|
|
1484
|
+
files,
|
|
1485
|
+
reviews,
|
|
1486
|
+
{ findingsReported: finalComments.length, verdict: verdictSummary.verdict, severityDistribution },
|
|
1487
|
+
{ concurrencyLevel, retryCount }
|
|
1488
|
+
);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/review/index.ts
|
|
1492
|
+
async function runReview(env, message) {
|
|
1493
|
+
const resolved = await resolveQueuedJob(env, message);
|
|
1494
|
+
if (!resolved) {
|
|
1495
|
+
return { action: "ack" };
|
|
1496
|
+
}
|
|
1497
|
+
if (resolved.job.status === "queued") {
|
|
1498
|
+
const { concurrencyLevel } = await env.settings.getReviewSettings();
|
|
1499
|
+
const maxConcurrentJobs = REVIEW_CONCURRENCY_LIMITS2[concurrencyLevel];
|
|
1500
|
+
const runningCount = await env.jobs.getOtherRunningJobsCount(resolved.job.id);
|
|
1501
|
+
if (runningCount >= maxConcurrentJobs) {
|
|
1502
|
+
logger.info(`Throttling admission of job ${resolved.job.id}: ${runningCount} other jobs are currently running.`);
|
|
1503
|
+
return { action: "retry", delaySeconds: 30 };
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
const leaseOwner = env.ids.randomUUID();
|
|
1507
|
+
const claim = await env.jobs.claimJobLease(resolved.job.id, leaseOwner, JOB_LEASE_SECONDS);
|
|
1508
|
+
if (claim.status === "missing") {
|
|
1509
|
+
logger.warn(`Job not found for processing: ${resolved.job.id}`);
|
|
1510
|
+
return { action: "ack" };
|
|
1511
|
+
}
|
|
1512
|
+
if (claim.status === "terminal") {
|
|
1513
|
+
logger.info(`Job ${resolved.job.id} is already terminal (${claim.row.status}), acking queue delivery.`);
|
|
1514
|
+
return { action: "ack" };
|
|
1515
|
+
}
|
|
1516
|
+
if (claim.status === "busy") {
|
|
1517
|
+
logger.info(`Job ${resolved.job.id} has a fresh lease; retrying queue delivery later.`);
|
|
1518
|
+
return { action: "retry", delaySeconds: Math.min(BUSY_RETRY_SECONDS, claim.retryAfterSeconds) };
|
|
1519
|
+
}
|
|
1520
|
+
const job = env.jobs.mapJob(claim.row);
|
|
1521
|
+
if (message.workflowInstanceId && job.workflowInstanceId !== message.workflowInstanceId) {
|
|
1522
|
+
try {
|
|
1523
|
+
await env.jobs.setJobWorkflowInstance(job.id, message.workflowInstanceId);
|
|
1524
|
+
} catch (error) {
|
|
1525
|
+
logger.warn(`Failed to bind workflow instance id for job ${job.id}`, error instanceof Error ? error : new Error(String(error)));
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
const phase = resolved.phase;
|
|
1529
|
+
const tracker = env.createTokenTracker();
|
|
1530
|
+
const github = env.createGitHub(job.installationId, tracker);
|
|
1531
|
+
const model = env.createModel(job.id, tracker);
|
|
1532
|
+
const formatter = env.createFormatter();
|
|
1533
|
+
try {
|
|
1534
|
+
if (phase === "prepare") {
|
|
1535
|
+
await runPreparePhase(env, job, leaseOwner, github);
|
|
1536
|
+
} else if (phase === "finalize") {
|
|
1537
|
+
await runFinalizePhase(env, job, leaseOwner, github, formatter, model);
|
|
1538
|
+
} else {
|
|
1539
|
+
await runReviewPhase(env, job, leaseOwner, github, model, tracker);
|
|
1540
|
+
}
|
|
1541
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1542
|
+
return { action: "ack" };
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
const messageText = error instanceof Error ? error.message : "Unknown review failure";
|
|
1545
|
+
if (messageText === "JOB_SUPERSEDED") {
|
|
1546
|
+
logger.info(`Job ${job.id} was superseded during execution, stopping.`);
|
|
1547
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1548
|
+
return { action: "ack" };
|
|
1549
|
+
}
|
|
1550
|
+
if (error instanceof NextPhaseError) {
|
|
1551
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1552
|
+
return { action: "next_phase", phase: error.phase, delaySeconds: error.delaySeconds, jobId: job.id, freshInstance: error.phase === "finalize" };
|
|
1553
|
+
}
|
|
1554
|
+
if (env.modelErrors.isRetryableModelError(error)) {
|
|
1555
|
+
const delaySeconds = getRetryableModelFailureDelaySeconds(error);
|
|
1556
|
+
logger.warn(`Review job hit transient model/provider failure; scheduling delayed continuation: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
|
|
1557
|
+
error: messageText,
|
|
1558
|
+
phase,
|
|
1559
|
+
delaySeconds
|
|
1560
|
+
});
|
|
1561
|
+
return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, "transient model/provider failures");
|
|
1562
|
+
}
|
|
1563
|
+
if (isSubrequestBudgetError(error)) {
|
|
1564
|
+
const record = error && typeof error === "object" ? error : null;
|
|
1565
|
+
const delaySeconds = typeof record?.retryAfterSeconds === "number" ? record.retryAfterSeconds : FRESH_INVOCATION_YIELD_SECONDS;
|
|
1566
|
+
logger.warn(`Review job hit the per-invocation subrequest limit; rescheduling ${phase} on a fresh budget: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
|
|
1567
|
+
error: messageText,
|
|
1568
|
+
phase,
|
|
1569
|
+
delaySeconds
|
|
1570
|
+
});
|
|
1571
|
+
return continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, "per-invocation subrequest limits");
|
|
1572
|
+
}
|
|
1573
|
+
console.error("JOB FAILED WITH ERROR:", error);
|
|
1574
|
+
logger.error(`Review job failed: ${job.owner}/${job.repo} PR #${job.prNumber}`, error);
|
|
1575
|
+
await failJobAndCheckRun(env, job, github, messageText);
|
|
1576
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1577
|
+
return { action: "ack" };
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
async function continueOrFailWedgedJob(env, job, github, leaseOwner, phase, delaySeconds, reason) {
|
|
1581
|
+
const continuationCount = await env.jobs.markJobContinuationQueued(job.id, delaySeconds);
|
|
1582
|
+
const ceiling = phase === "finalize" ? MAX_FINALIZE_CONTINUATIONS : MAX_JOB_CONTINUATIONS;
|
|
1583
|
+
if (continuationCount > ceiling) {
|
|
1584
|
+
if (phase === "review") {
|
|
1585
|
+
logger.error(`Review job exceeded the continuation ceiling; degrading to a partial review: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
|
|
1586
|
+
phase,
|
|
1587
|
+
continuationCount,
|
|
1588
|
+
reason
|
|
1589
|
+
});
|
|
1590
|
+
const stillPending = (await env.fileReviews.getFileReviewsForJobs([job.id])).filter(isAwaitingAsyncReview);
|
|
1591
|
+
for (const review of stillPending) {
|
|
1592
|
+
await persistFailedFileReview(env, job.id, {
|
|
1593
|
+
filePath: review.file_path,
|
|
1594
|
+
modelUsed: review.async_model ?? review.model_used,
|
|
1595
|
+
diffLineCount: review.diff_line_count,
|
|
1596
|
+
errorMessage: "Async batch review did not complete before the job wedged.",
|
|
1597
|
+
clearAsync: true
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
await env.jobs.resetJobContinuationCount(job.id);
|
|
1601
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1602
|
+
return { action: "next_phase", phase: "finalize", delaySeconds: FRESH_INVOCATION_YIELD_SECONDS, jobId: job.id, freshInstance: true };
|
|
1603
|
+
} else {
|
|
1604
|
+
const message = `Review could not make progress after ${continuationCount} continuation attempts (${reason}). Failing the job to avoid an endless retry loop; re-run it once the underlying provider issue clears.`;
|
|
1605
|
+
logger.error(`Review job exceeded the continuation ceiling; failing terminally: ${job.owner}/${job.repo} PR #${job.prNumber}`, {
|
|
1606
|
+
phase,
|
|
1607
|
+
continuationCount,
|
|
1608
|
+
reason
|
|
1609
|
+
});
|
|
1610
|
+
await failJobAndCheckRun(env, job, github, message);
|
|
1611
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1612
|
+
return { action: "ack" };
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
await env.jobs.releaseJobLease(job.id, leaseOwner);
|
|
1616
|
+
const freshInstance = reason.includes("subrequest");
|
|
1617
|
+
return { action: "next_phase", phase, delaySeconds, jobId: job.id, freshInstance };
|
|
1618
|
+
}
|
|
1619
|
+
async function resolveQueuedJob(env, message) {
|
|
1620
|
+
if (message.jobId) {
|
|
1621
|
+
const row = await env.jobs.getJobForProcessing(message.jobId);
|
|
1622
|
+
return row ? { job: env.jobs.mapJob(row), phase: message.phase ?? "review" } : null;
|
|
1623
|
+
}
|
|
1624
|
+
if (!message.eventName) {
|
|
1625
|
+
logger.warn("Queue message ignored: missing eventName");
|
|
1626
|
+
return null;
|
|
1627
|
+
}
|
|
1628
|
+
let eventName = message.eventName;
|
|
1629
|
+
let payload = message.payload;
|
|
1630
|
+
if (payload === void 0) {
|
|
1631
|
+
const delivery = await env.webhooks.getWebhookDelivery(message.deliveryId);
|
|
1632
|
+
if (!delivery) {
|
|
1633
|
+
logger.warn(`Queue message ignored: webhook delivery not found: ${message.deliveryId}`);
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
eventName = delivery.event_name;
|
|
1637
|
+
payload = delivery.payload;
|
|
1638
|
+
}
|
|
1639
|
+
if (eventName !== "change_request" && eventName !== "comment") {
|
|
1640
|
+
logger.info(`Queue message ignored: unsupported webhook event ${eventName}`);
|
|
1641
|
+
return null;
|
|
1642
|
+
}
|
|
1643
|
+
const installationId = String(payload.installationId ?? "");
|
|
1644
|
+
if (!installationId || !("repository" in payload) || !payload.repository) {
|
|
1645
|
+
logger.info("Queue message ignored: missing installation or repository info");
|
|
1646
|
+
return null;
|
|
1647
|
+
}
|
|
1648
|
+
const repoConfig = await env.repoConfig.loadRepoConfig({
|
|
1649
|
+
installationId,
|
|
1650
|
+
owner: payload.repository.owner,
|
|
1651
|
+
repo: payload.repository.name
|
|
1652
|
+
});
|
|
1653
|
+
if (repoConfig.enabled === false) {
|
|
1654
|
+
logger.info(`Job ignored: repository ${payload.repository.owner}/${payload.repository.name} is disabled`);
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
const extracted = extractReviewRequest({
|
|
1658
|
+
eventName,
|
|
1659
|
+
payload,
|
|
1660
|
+
botUsername: env.botUsername,
|
|
1661
|
+
config: repoConfig.parsedJson
|
|
1662
|
+
});
|
|
1663
|
+
if (!extracted) {
|
|
1664
|
+
if (eventName === "change_request") {
|
|
1665
|
+
const prPayload = payload;
|
|
1666
|
+
if (prPayload.action === "closed" && repoConfig.parsedJson.review.labels !== false) {
|
|
1667
|
+
const labels = repoConfig.parsedJson.review.labels;
|
|
1668
|
+
const gh = env.githubClients.forInstallation(installationId);
|
|
1669
|
+
await gh.removeIssueLabelsIfPresent(
|
|
1670
|
+
prPayload.repository.owner,
|
|
1671
|
+
prPayload.repository.name,
|
|
1672
|
+
prPayload.changeRequest.number,
|
|
1673
|
+
[labels.p1, labels.p2, labels.p3]
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
return null;
|
|
1678
|
+
}
|
|
1679
|
+
let resolved = extracted;
|
|
1680
|
+
const githubClient = env.githubClients.forInstallation(installationId);
|
|
1681
|
+
if (eventName === "comment") {
|
|
1682
|
+
const pr = await githubClient.getPullRequest(extracted.owner, extracted.repo, extracted.prNumber);
|
|
1683
|
+
resolved = {
|
|
1684
|
+
...extracted,
|
|
1685
|
+
prTitle: pr.title,
|
|
1686
|
+
prAuthor: pr.user.login,
|
|
1687
|
+
commitSha: pr.head.sha,
|
|
1688
|
+
baseSha: pr.base.sha,
|
|
1689
|
+
headRef: pr.head.ref,
|
|
1690
|
+
baseRef: pr.base.ref
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
const duplicateJob = await env.jobs.findExistingJobForHead({
|
|
1694
|
+
owner: resolved.owner,
|
|
1695
|
+
repo: resolved.repo,
|
|
1696
|
+
prNumber: resolved.prNumber,
|
|
1697
|
+
commitSha: resolved.commitSha,
|
|
1698
|
+
trigger: resolved.trigger
|
|
1699
|
+
});
|
|
1700
|
+
if (duplicateJob) {
|
|
1701
|
+
if (duplicateJob.status === "queued" || duplicateJob.status === "running") {
|
|
1702
|
+
logger.info(`Resuming duplicate in-flight job ${duplicateJob.id} for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}.`);
|
|
1703
|
+
return { job: duplicateJob, phase: message.phase ?? "prepare" };
|
|
1704
|
+
}
|
|
1705
|
+
logger.info(`Duplicate terminal job found for ${resolved.owner}/${resolved.repo} PR #${resolved.prNumber}, skipping.`);
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
const job = await env.jobs.insertJob({
|
|
1709
|
+
installationId: resolved.installationId,
|
|
1710
|
+
owner: resolved.owner,
|
|
1711
|
+
repo: resolved.repo,
|
|
1712
|
+
prNumber: resolved.prNumber,
|
|
1713
|
+
prTitle: resolved.prTitle,
|
|
1714
|
+
prAuthor: resolved.prAuthor,
|
|
1715
|
+
commitSha: resolved.commitSha,
|
|
1716
|
+
baseSha: resolved.baseSha,
|
|
1717
|
+
trigger: resolved.trigger,
|
|
1718
|
+
headRef: resolved.headRef,
|
|
1719
|
+
baseRef: resolved.baseRef,
|
|
1720
|
+
configSnapshot: repoConfig.parsedJson
|
|
1721
|
+
});
|
|
1722
|
+
await env.jobs.supersedeOlderJobs({
|
|
1723
|
+
installationId: resolved.installationId,
|
|
1724
|
+
owner: resolved.owner,
|
|
1725
|
+
repo: resolved.repo,
|
|
1726
|
+
prNumber: resolved.prNumber,
|
|
1727
|
+
newJobId: job.id
|
|
1728
|
+
});
|
|
1729
|
+
return { job, phase: "prepare" };
|
|
1730
|
+
}
|
|
1731
|
+
export {
|
|
1732
|
+
BIN_DIFF_CHAR_BUDGET,
|
|
1733
|
+
BIN_MAX_FILES,
|
|
1734
|
+
BIN_TARGET_DIFF_LINES,
|
|
1735
|
+
FRESH_INVOCATION_YIELD_SECONDS,
|
|
1736
|
+
InMemoryKV,
|
|
1737
|
+
InMemoryOrchestrator,
|
|
1738
|
+
InMemoryQueue,
|
|
1739
|
+
InMemorySessionStore,
|
|
1740
|
+
NextPhaseError,
|
|
1741
|
+
PACKABLE_MAX_DIFF_LINES,
|
|
1742
|
+
budgetAwareFileLimit,
|
|
1743
|
+
estimatedSubrequestsPerFile,
|
|
1744
|
+
extractReviewRequest,
|
|
1745
|
+
failJobAndCheckRun,
|
|
1746
|
+
getDiffFiles,
|
|
1747
|
+
getOrFetchRawDiffForCompletedJob,
|
|
1748
|
+
narrowUnit,
|
|
1749
|
+
planReviewUnits,
|
|
1750
|
+
proportionalSplit,
|
|
1751
|
+
runReview,
|
|
1752
|
+
unitFiles,
|
|
1753
|
+
verifyFindings
|
|
1754
|
+
};
|
|
1755
|
+
//# sourceMappingURL=index.js.map
|