@doist/doistbot-cli 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/auth.js +78 -24
- package/dist/actions/doctor.js +14 -7
- package/dist/actions/review.js +67 -10
- package/dist/auth.js +51 -6
- package/dist/config.js +77 -5
- package/dist/env.js +1 -0
- package/dist/runtime.js +1 -1
- package/dist/terminal.js +2 -0
- package/package.json +4 -4
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
- package/sandbox/dist/core/check-run.js +16 -0
- package/sandbox/dist/core/datadog-metrics.js +6 -52
- package/sandbox/dist/core/logger-span-processor.js +78 -0
- package/sandbox/dist/core/pi.js +285 -174
- package/sandbox/dist/core/prompt-template.js +45 -3
- package/sandbox/dist/core/repository-map.js +24 -0
- package/sandbox/dist/core/tracing-setup.js +15 -0
- package/sandbox/dist/core/tracing.js +80 -0
- package/sandbox/dist/main.js +4 -1
- package/sandbox/dist/providers/credentials.js +12 -2
- package/sandbox/dist/providers/gemini.js +5 -1
- package/sandbox/dist/providers/helpers.js +63 -17
- package/sandbox/dist/providers/index.js +4 -0
- package/sandbox/dist/providers/openrouter-pi.js +25 -0
- package/sandbox/dist/providers/pi-review.js +19 -2
- package/sandbox/dist/tasks/chat/chat.js +173 -102
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
- package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
- package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
- package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
- package/sandbox/dist/tasks/issue-triage/context.js +25 -1
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
- package/sandbox/dist/tasks/issue-triage/model.js +120 -36
- package/sandbox/dist/tasks/issue-triage/output.js +27 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
- package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
- package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
- package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
- package/sandbox/dist/tasks/review/check-run.js +2 -1
- package/sandbox/dist/tasks/review/conversation-context.js +2 -1
- package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
- package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
- package/sandbox/dist/tasks/review/engines/shared.js +43 -6
- package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
- package/sandbox/dist/tasks/review/local.js +7 -4
- package/sandbox/dist/tasks/review/review-summary.js +48 -7
- package/sandbox/dist/tasks/review/review.js +267 -186
- package/sandbox/dist/tasks/review/runner.js +33 -7
- package/sandbox/dist/tasks/review/summary-model.js +189 -20
- package/sandbox/dist/tasks/review/thinking-level.js +4 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
|
@@ -1,56 +1,127 @@
|
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
1
2
|
import { logger } from '../../../core/logging.js';
|
|
3
|
+
import { genAiProviderName, withPhase } from '../../../core/tracing.js';
|
|
2
4
|
import { convertFindingsToComments } from '../comment-mapper.js';
|
|
3
5
|
import { buildReviewOutputFindings } from '../finding-output.js';
|
|
4
6
|
import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus-prompt.js';
|
|
5
7
|
import { buildFallbackSummary, summarizeReviewFindings } from '../review-summary.js';
|
|
6
8
|
import { parseSeverity } from '../severity.js';
|
|
9
|
+
import { buildGeminiFlashReviewSummaryModel } from '../summary-model.js';
|
|
7
10
|
import { dedupeMultiFocusFindings } from './dedupe.js';
|
|
8
|
-
import { getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
|
|
11
|
+
import { buildGeminiFlashCredential, getApiKeys, getAvailableModels, getEnabledReviewModels, getOpenRouterReviewCredential, getProviderFailures, requestModelReview, } from './shared.js';
|
|
12
|
+
const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
|
|
13
|
+
general: ['deepseek', 'zai', 'openai'],
|
|
14
|
+
quality: ['deepseek', 'zai', 'openai'],
|
|
15
|
+
efficiency: ['deepseek', 'zai', 'openai'],
|
|
16
|
+
tests: ['deepseek', 'zai'],
|
|
17
|
+
standards: ['deepseek', 'zai'],
|
|
18
|
+
};
|
|
19
|
+
// Default Gemini-flash pass on failure. tests/standards keep no fallback unless
|
|
20
|
+
// a caller opts into a wider fallback set.
|
|
21
|
+
const DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES = [
|
|
22
|
+
'general',
|
|
23
|
+
'quality',
|
|
24
|
+
'efficiency',
|
|
25
|
+
];
|
|
26
|
+
// Open models whose failed pass triggers that fallback.
|
|
27
|
+
const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', 'zai'];
|
|
9
28
|
const LOW_PRIORITY_SUMMARY_NOTICE = 'I also included a few optional follow-up notes in the details below.';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
focus: focus.name,
|
|
29
|
+
// Workflow phase orchestrating the council fan-out; one model span per pass.
|
|
30
|
+
const MULTI_FOCUS_PHASE = 'review.multi-focus';
|
|
31
|
+
const FOCUS_PASS_PHASE = 'review.focus-pass';
|
|
32
|
+
async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKeys, workspaceDir, openRouterSessionId, onProgress, }) {
|
|
33
|
+
// One model span per council pass. Started concurrently inside the engine's
|
|
34
|
+
// Promise.all, but withPhase captures the active context at span start, so
|
|
35
|
+
// each parents to `review.multi-focus` without nesting under a sibling.
|
|
36
|
+
// Provider is known now; the resolved model only after the invoke (via
|
|
37
|
+
// ModelReviewResult.requestModel), so request.model is stamped on return.
|
|
38
|
+
return withPhase(FOCUS_PASS_PHASE, {
|
|
39
|
+
'gen_ai.operation.name': 'chat',
|
|
40
|
+
'gen_ai.provider.name': genAiProviderName(model),
|
|
41
|
+
'review.focus': focus.name,
|
|
42
|
+
}, async (span) => {
|
|
43
|
+
const prompt = buildMultiFocusPrompt(basePrompt, focus, standardsSection);
|
|
44
|
+
const result = await requestModelReview({
|
|
27
45
|
model,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
focus: focus.name,
|
|
34
|
-
model,
|
|
35
|
-
error: result.error,
|
|
46
|
+
prompt,
|
|
47
|
+
apiKeys,
|
|
48
|
+
workspaceDir,
|
|
49
|
+
openRouterSessionId,
|
|
50
|
+
onProgress,
|
|
36
51
|
});
|
|
52
|
+
if (result?.requestModel) {
|
|
53
|
+
span.setAttributes({ 'gen_ai.request.model': result.requestModel });
|
|
54
|
+
}
|
|
55
|
+
if (!result) {
|
|
56
|
+
const error = `No review result returned for ${model}`;
|
|
57
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error });
|
|
58
|
+
logger.warn('Multi-focus review pass failed without a result', {
|
|
59
|
+
focus: focus.name,
|
|
60
|
+
model,
|
|
61
|
+
});
|
|
62
|
+
return {
|
|
63
|
+
status: 'failed',
|
|
64
|
+
focus: focus.name,
|
|
65
|
+
model,
|
|
66
|
+
error,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (result.error) {
|
|
70
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
|
|
71
|
+
logger.warn('Multi-focus review pass failed', {
|
|
72
|
+
focus: focus.name,
|
|
73
|
+
model,
|
|
74
|
+
error: result.error,
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
status: 'failed',
|
|
78
|
+
focus: focus.name,
|
|
79
|
+
model,
|
|
80
|
+
error: result.error,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
37
83
|
return {
|
|
38
|
-
status: '
|
|
84
|
+
status: 'succeeded',
|
|
39
85
|
focus: focus.name,
|
|
40
86
|
model,
|
|
41
|
-
|
|
87
|
+
summary: result.summary.trim(),
|
|
88
|
+
findings: result.findings.map((finding) => ({
|
|
89
|
+
...finding,
|
|
90
|
+
focus: focus.name,
|
|
91
|
+
})),
|
|
42
92
|
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Runs a focus's primary passes, then at most one Gemini-flash fallback pass for
|
|
96
|
+
// that focus when a fallback-eligible primary failed and gemini is available.
|
|
97
|
+
async function runFocusGroup({ focus, models, basePrompt, standardsSection, apiKeys, hasGeminiFallback, geminiFallbackFocuses, workspaceDir, openRouterSessionId, onProgress, }) {
|
|
98
|
+
const primaryRuns = await Promise.all(models.map((model) => runFocusPass({
|
|
99
|
+
focus,
|
|
47
100
|
model,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
101
|
+
basePrompt,
|
|
102
|
+
standardsSection,
|
|
103
|
+
apiKeys,
|
|
104
|
+
workspaceDir,
|
|
105
|
+
openRouterSessionId,
|
|
106
|
+
onProgress,
|
|
107
|
+
})));
|
|
108
|
+
const fallbackEligibleFailed = geminiFallbackFocuses.includes(focus.name) &&
|
|
109
|
+
primaryRuns.some((run) => run.status === 'failed' && GEMINI_FLASH_FALLBACK_MODELS.includes(run.model));
|
|
110
|
+
const flashAlreadyRan = models.includes('gemini');
|
|
111
|
+
if (!fallbackEligibleFailed || flashAlreadyRan || !hasGeminiFallback) {
|
|
112
|
+
return primaryRuns;
|
|
113
|
+
}
|
|
114
|
+
const flashRun = await runFocusPass({
|
|
115
|
+
focus,
|
|
116
|
+
model: 'gemini',
|
|
117
|
+
basePrompt,
|
|
118
|
+
standardsSection,
|
|
119
|
+
apiKeys: { ...apiKeys, gemini: buildGeminiFlashCredential(apiKeys.gemini) },
|
|
120
|
+
workspaceDir,
|
|
121
|
+
openRouterSessionId,
|
|
122
|
+
onProgress,
|
|
123
|
+
});
|
|
124
|
+
return [...primaryRuns, flashRun];
|
|
54
125
|
}
|
|
55
126
|
function routeLowPriorityFindings(findings, placement = 'summary') {
|
|
56
127
|
if (placement === 'inline') {
|
|
@@ -94,6 +165,25 @@ function collectReviewObservations(focusRuns) {
|
|
|
94
165
|
}
|
|
95
166
|
return observations.slice(0, 8);
|
|
96
167
|
}
|
|
168
|
+
function getModelsForFocus(focus, availableModels, modelsByFocus = DEFAULT_REVIEW_MODELS_BY_FOCUS) {
|
|
169
|
+
const preferredModels = modelsByFocus[focus.name] ?? DEFAULT_REVIEW_MODELS_BY_FOCUS[focus.name];
|
|
170
|
+
const models = preferredModels.filter((model) => availableModels.includes(model));
|
|
171
|
+
// Last resort: a focus has no configured open/openai primary but Gemini is
|
|
172
|
+
// available (e.g. a Gemini-only local/CLI setup). Run Gemini so the review
|
|
173
|
+
// still produces a result instead of falling through to `review: null`.
|
|
174
|
+
if (models.length === 0 && availableModels.includes('gemini')) {
|
|
175
|
+
return ['gemini'];
|
|
176
|
+
}
|
|
177
|
+
return models;
|
|
178
|
+
}
|
|
179
|
+
function getEnabledFocusDefinitions(enabledFocuses) {
|
|
180
|
+
if (!enabledFocuses?.length) {
|
|
181
|
+
return REVIEW_FOCUS_DEFINITIONS;
|
|
182
|
+
}
|
|
183
|
+
const enabled = new Set(enabledFocuses);
|
|
184
|
+
const focusDefinitions = REVIEW_FOCUS_DEFINITIONS.filter((focus) => enabled.has(focus.name));
|
|
185
|
+
return focusDefinitions.length > 0 ? focusDefinitions : REVIEW_FOCUS_DEFINITIONS;
|
|
186
|
+
}
|
|
97
187
|
function formatSummaryOnlyFinding(finding) {
|
|
98
188
|
const severity = parseSeverity(finding.body);
|
|
99
189
|
const bodyWithoutSeverity = finding.body.replace(/^\s*\[P\d\]\s*/i, '').trim();
|
|
@@ -115,25 +205,42 @@ function appendSummaryOnlyFindings(summary, summaryFindings) {
|
|
|
115
205
|
const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
|
|
116
206
|
return `${summary.trim()}\n\n${LOW_PRIORITY_SUMMARY_NOTICE}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
|
|
117
207
|
}
|
|
118
|
-
|
|
208
|
+
function buildSummarySynthesisFindings({ inlineFindings, summaryFindings, }) {
|
|
209
|
+
return Array.from(new Set([
|
|
210
|
+
...inlineFindings.map((finding) => finding.body.trim()),
|
|
211
|
+
...summaryFindings.map((finding) => formatSummaryOnlyFinding(finding)),
|
|
212
|
+
].filter(Boolean)));
|
|
213
|
+
}
|
|
214
|
+
async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryModel, summaryFallbackModels, workspaceDir, }) {
|
|
119
215
|
const comments = convertFindingsToComments(inlineFindings, lineLookup);
|
|
120
|
-
const
|
|
121
|
-
const
|
|
216
|
+
const inlineCommentFindings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
|
|
217
|
+
const findings = buildSummarySynthesisFindings({ inlineFindings, summaryFindings });
|
|
218
|
+
const hasReviewFindings = inlineFindings.length > 0 || summaryFindings.length > 0;
|
|
219
|
+
if (!hasReviewFindings) {
|
|
220
|
+
logger.info('Review summary synthesis skipped for clean review', {
|
|
221
|
+
inlineFindingsCount: inlineFindings.length,
|
|
222
|
+
summaryFindingsCount: summaryFindings.length,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const synthesizedSummary = summaryModel == null || !hasReviewFindings
|
|
122
226
|
? null
|
|
123
227
|
: await summarizeReviewFindings({
|
|
124
|
-
credential:
|
|
228
|
+
credential: summaryModel.credential,
|
|
229
|
+
provider: summaryModel.provider,
|
|
230
|
+
...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
|
|
125
231
|
workspaceDir,
|
|
126
232
|
prTitle,
|
|
127
233
|
prBody,
|
|
128
234
|
findings,
|
|
129
235
|
reviewObservations,
|
|
236
|
+
fallbackModels: summaryFallbackModels,
|
|
130
237
|
});
|
|
131
238
|
const summaryWithoutNotes = synthesizedSummary ||
|
|
132
|
-
(comments.length === 0 &&
|
|
239
|
+
(comments.length === 0 && hasReviewFindings
|
|
133
240
|
? buildNoInlineSummaryWithNotes(prTitle)
|
|
134
241
|
: buildFallbackSummary({
|
|
135
242
|
prTitle,
|
|
136
|
-
findings,
|
|
243
|
+
findings: inlineCommentFindings,
|
|
137
244
|
}));
|
|
138
245
|
return {
|
|
139
246
|
summary: appendSummaryOnlyFindings(summaryWithoutNotes, summaryFindings),
|
|
@@ -145,10 +252,14 @@ async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, li
|
|
|
145
252
|
}),
|
|
146
253
|
};
|
|
147
254
|
}
|
|
148
|
-
export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, lowPriorityFindingPlacement, standardsSection, onProgress, }) {
|
|
255
|
+
export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, lowPriorityFindingPlacement, standardsSection, enabledFocuses, onProgress, }) {
|
|
149
256
|
const apiKeys = getApiKeys(ctx);
|
|
150
|
-
const
|
|
257
|
+
const allAvailableModels = getAvailableModels(apiKeys);
|
|
258
|
+
const availableModels = getEnabledReviewModels(apiKeys, ctx.reviewModelsEnabled);
|
|
151
259
|
const metricModels = availableModels.length > 0 ? [...availableModels] : [...ctx.reviewModelsEnabled];
|
|
260
|
+
const focusDefinitions = getEnabledFocusDefinitions(enabledFocuses);
|
|
261
|
+
const hasGeminiFallback = allAvailableModels.includes('gemini');
|
|
262
|
+
const geminiFallbackFocuses = ctx.geminiFallbackFocuses ?? DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES;
|
|
152
263
|
if (availableModels.length === 0) {
|
|
153
264
|
return {
|
|
154
265
|
review: null,
|
|
@@ -158,78 +269,108 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
158
269
|
providerFailures: [],
|
|
159
270
|
};
|
|
160
271
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
272
|
+
// Workflow phase (no gen_ai.*): it fans out N model passes and post-
|
|
273
|
+
// processes them. The per-pass model spans are the gen-AI operations.
|
|
274
|
+
return withPhase(MULTI_FOCUS_PHASE, {
|
|
275
|
+
'review.models': availableModels.length,
|
|
276
|
+
'review.support_models': allAvailableModels.length,
|
|
277
|
+
'review.focuses': focusDefinitions.length,
|
|
278
|
+
}, async (span) => {
|
|
279
|
+
// One group per focus (its primary passes plus an optional Gemini-flash
|
|
280
|
+
// fallback), flattened into a single list of passes.
|
|
281
|
+
const focusGroups = await Promise.all(focusDefinitions.map((focus) => runFocusGroup({
|
|
282
|
+
focus,
|
|
283
|
+
models: getModelsForFocus(focus, availableModels, ctx.reviewModelsByFocus),
|
|
284
|
+
basePrompt: prompt,
|
|
285
|
+
standardsSection,
|
|
286
|
+
apiKeys,
|
|
287
|
+
hasGeminiFallback,
|
|
288
|
+
geminiFallbackFocuses,
|
|
289
|
+
workspaceDir: ctx.workspaceDir,
|
|
290
|
+
openRouterSessionId: ctx.openRouterSessionId,
|
|
291
|
+
onProgress,
|
|
292
|
+
})));
|
|
293
|
+
const focusRuns = focusGroups.flat();
|
|
294
|
+
const successfulRuns = focusRuns.filter((run) => run.status === 'succeeded');
|
|
295
|
+
const providerFailures = getProviderFailures(focusRuns
|
|
296
|
+
.filter((run) => run.status === 'failed')
|
|
297
|
+
.map((run) => ({
|
|
298
|
+
model: run.model,
|
|
299
|
+
summary: '',
|
|
300
|
+
findings: [],
|
|
301
|
+
durationMs: 0,
|
|
302
|
+
error: run.error,
|
|
303
|
+
})));
|
|
304
|
+
span.setAttributes({
|
|
305
|
+
'review.passes': focusRuns.length,
|
|
306
|
+
'review.passes.succeeded': successfulRuns.length,
|
|
307
|
+
});
|
|
308
|
+
if (successfulRuns.length === 0) {
|
|
309
|
+
span.setStatus({
|
|
310
|
+
code: SpanStatusCode.ERROR,
|
|
311
|
+
message: 'all review passes failed',
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
review: null,
|
|
315
|
+
availableModels,
|
|
316
|
+
metricModels,
|
|
317
|
+
mode: 'multi_focus',
|
|
318
|
+
providerFailures,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const allFindings = successfulRuns.flatMap((run) => run.findings);
|
|
322
|
+
const reviewObservations = collectReviewObservations(successfulRuns);
|
|
323
|
+
let dedupedFindings = allFindings;
|
|
324
|
+
try {
|
|
325
|
+
dedupedFindings = await dedupeMultiFocusFindings({
|
|
326
|
+
findings: allFindings,
|
|
327
|
+
availableModels: allAvailableModels,
|
|
328
|
+
workspaceDir: ctx.workspaceDir,
|
|
329
|
+
geminiCredential: apiKeys.gemini,
|
|
330
|
+
openRouterCredential: getOpenRouterReviewCredential(apiKeys, ctx.apiKeys),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
logger.warn('Multi-focus dedupe failed, continuing with raw findings', {
|
|
335
|
+
findingsCount: allFindings.length,
|
|
336
|
+
error: error instanceof Error ? error.message : String(error),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
|
|
340
|
+
const geminiSummaryFallback = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
|
|
341
|
+
const summaryModel = ctx.reviewSummaryModel ??
|
|
342
|
+
(apiKeys.zai ? { provider: 'zai', credential: apiKeys.zai } : undefined);
|
|
343
|
+
const review = await buildMultiFocusReviewOutput({
|
|
344
|
+
inlineFindings: routedFindings.inlineFindings,
|
|
345
|
+
summaryFindings: routedFindings.summaryFindings,
|
|
346
|
+
lineLookup,
|
|
347
|
+
prTitle,
|
|
348
|
+
prBody,
|
|
349
|
+
reviewObservations,
|
|
350
|
+
summaryModel,
|
|
351
|
+
summaryFallbackModels: ctx.reviewSummaryModel == null && geminiSummaryFallback
|
|
352
|
+
? [geminiSummaryFallback]
|
|
353
|
+
: [],
|
|
354
|
+
workspaceDir: ctx.workspaceDir,
|
|
355
|
+
});
|
|
356
|
+
logger.info('Multi-focus review completed', {
|
|
357
|
+
models: availableModels,
|
|
358
|
+
supportModels: allAvailableModels,
|
|
359
|
+
focuses: focusDefinitions.map((focus) => focus.name),
|
|
360
|
+
successfulRuns: successfulRuns.length,
|
|
361
|
+
findingsCount: allFindings.length,
|
|
362
|
+
dedupedFindingsCount: dedupedFindings.length,
|
|
363
|
+
inlineFindingsCount: routedFindings.inlineFindings.length,
|
|
364
|
+
summaryOnlyFindingsCount: routedFindings.summaryFindings.length,
|
|
365
|
+
reviewObservationsCount: reviewObservations.length,
|
|
366
|
+
commentsCount: review.comments.length,
|
|
367
|
+
});
|
|
181
368
|
return {
|
|
182
|
-
review
|
|
369
|
+
review,
|
|
183
370
|
availableModels,
|
|
184
371
|
metricModels,
|
|
185
372
|
mode: 'multi_focus',
|
|
186
373
|
providerFailures,
|
|
187
374
|
};
|
|
188
|
-
}
|
|
189
|
-
const allFindings = successfulRuns.flatMap((run) => run.findings);
|
|
190
|
-
const reviewObservations = collectReviewObservations(successfulRuns);
|
|
191
|
-
let dedupedFindings = allFindings;
|
|
192
|
-
try {
|
|
193
|
-
dedupedFindings = await dedupeMultiFocusFindings({
|
|
194
|
-
findings: allFindings,
|
|
195
|
-
availableModels,
|
|
196
|
-
workspaceDir: ctx.workspaceDir,
|
|
197
|
-
geminiCredential: ctx.geminiCredential,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
catch (error) {
|
|
201
|
-
logger.warn('Multi-focus dedupe failed, continuing with raw findings', {
|
|
202
|
-
findingsCount: allFindings.length,
|
|
203
|
-
error: error instanceof Error ? error.message : String(error),
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
|
|
207
|
-
const review = await buildMultiFocusReviewOutput({
|
|
208
|
-
inlineFindings: routedFindings.inlineFindings,
|
|
209
|
-
summaryFindings: routedFindings.summaryFindings,
|
|
210
|
-
lineLookup,
|
|
211
|
-
prTitle,
|
|
212
|
-
prBody,
|
|
213
|
-
reviewObservations,
|
|
214
|
-
summaryCredential: apiKeys.gemini,
|
|
215
|
-
workspaceDir: ctx.workspaceDir,
|
|
216
|
-
});
|
|
217
|
-
logger.info('Multi-focus review completed', {
|
|
218
|
-
models: availableModels,
|
|
219
|
-
focuses: REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name),
|
|
220
|
-
successfulRuns: successfulRuns.length,
|
|
221
|
-
findingsCount: allFindings.length,
|
|
222
|
-
dedupedFindingsCount: dedupedFindings.length,
|
|
223
|
-
inlineFindingsCount: routedFindings.inlineFindings.length,
|
|
224
|
-
summaryOnlyFindingsCount: routedFindings.summaryFindings.length,
|
|
225
|
-
reviewObservationsCount: reviewObservations.length,
|
|
226
|
-
commentsCount: review.comments.length,
|
|
227
375
|
});
|
|
228
|
-
return {
|
|
229
|
-
review,
|
|
230
|
-
availableModels,
|
|
231
|
-
metricModels,
|
|
232
|
-
mode: 'multi_focus',
|
|
233
|
-
providerFailures,
|
|
234
|
-
};
|
|
235
376
|
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_GEMINI_FLASH_MODEL } from '../../../core/pi.js';
|
|
2
|
+
import { getReviewProviderCredential, hasReviewProviderCredential, resolveReviewProviderCredential, } from '../../../providers/credentials.js';
|
|
2
3
|
import { getProvider } from '../../../providers/index.js';
|
|
3
4
|
import { getReviewThinkingLevel } from '../thinking-level.js';
|
|
4
5
|
export function getApiKeys(ctx) {
|
|
5
6
|
const apiKeys = {};
|
|
6
|
-
|
|
7
|
-
apiKeys
|
|
7
|
+
for (const model of ctx.reviewModelsEnabled) {
|
|
8
|
+
apiKeys[model] = ctx.apiKeys[model];
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// Gemini is a support credential for fallback, dedupe, and utility work. It
|
|
11
|
+
// does not need to be part of the routed primary model set.
|
|
12
|
+
if (ctx.apiKeys.gemini) {
|
|
13
|
+
apiKeys.gemini = ctx.apiKeys.gemini;
|
|
11
14
|
}
|
|
12
15
|
return apiKeys;
|
|
13
16
|
}
|
|
@@ -17,8 +20,29 @@ export function getAvailableModels(apiKeys) {
|
|
|
17
20
|
models.push('gemini');
|
|
18
21
|
if (hasReviewProviderCredential(apiKeys.openai))
|
|
19
22
|
models.push('openai');
|
|
23
|
+
if (hasReviewProviderCredential(apiKeys.deepseek))
|
|
24
|
+
models.push('deepseek');
|
|
25
|
+
if (hasReviewProviderCredential(apiKeys.zai))
|
|
26
|
+
models.push('zai');
|
|
20
27
|
return models;
|
|
21
28
|
}
|
|
29
|
+
export function getEnabledReviewModels(apiKeys, reviewModelsEnabled) {
|
|
30
|
+
return reviewModelsEnabled.filter((model) => hasReviewProviderCredential(apiKeys[model]));
|
|
31
|
+
}
|
|
32
|
+
export function getOpenRouterReviewCredential(...apiKeySets) {
|
|
33
|
+
for (const apiKeys of apiKeySets) {
|
|
34
|
+
if (!apiKeys) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const credential of Object.values(apiKeys)) {
|
|
38
|
+
const resolved = resolveReviewProviderCredential(credential);
|
|
39
|
+
if (resolved?.piProviderId === 'openrouter') {
|
|
40
|
+
return credential;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
22
46
|
export function getProviderFailures(results) {
|
|
23
47
|
return results
|
|
24
48
|
.filter((result) => result.error)
|
|
@@ -27,7 +51,7 @@ export function getProviderFailures(results) {
|
|
|
27
51
|
error: result.error,
|
|
28
52
|
}));
|
|
29
53
|
}
|
|
30
|
-
export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, onProgress, }) {
|
|
54
|
+
export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, openRouterSessionId, onProgress, }) {
|
|
31
55
|
const credential = getReviewProviderCredential(apiKeys, model);
|
|
32
56
|
if (!credential) {
|
|
33
57
|
return null;
|
|
@@ -37,6 +61,7 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
|
|
|
37
61
|
...credential,
|
|
38
62
|
workspaceDir,
|
|
39
63
|
thinkingLevel: getReviewThinkingLevel(model),
|
|
64
|
+
openRouterSessionId,
|
|
40
65
|
onProgress,
|
|
41
66
|
};
|
|
42
67
|
try {
|
|
@@ -46,3 +71,15 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
|
|
|
46
71
|
return null;
|
|
47
72
|
}
|
|
48
73
|
}
|
|
74
|
+
// Resolves the gemini credential and stamps the flash model id onto it, so the
|
|
75
|
+
// fallback pass runs flash via the existing apiKeys path (no extra param).
|
|
76
|
+
export function buildGeminiFlashCredential(credential) {
|
|
77
|
+
const resolved = resolveReviewProviderCredential(credential);
|
|
78
|
+
if (!resolved)
|
|
79
|
+
return undefined;
|
|
80
|
+
// No piProviderId → always the Google provider (never OpenRouter) for Gemini.
|
|
81
|
+
return {
|
|
82
|
+
apiKey: resolved.apiKey,
|
|
83
|
+
modelName: DEFAULT_GEMINI_FLASH_MODEL,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -1,28 +1,90 @@
|
|
|
1
1
|
import { parseSeverity } from './severity.js';
|
|
2
|
-
const
|
|
3
|
-
P0: '
|
|
4
|
-
P1: '
|
|
5
|
-
P2: '
|
|
2
|
+
export const PRIORITY_BADGE_URLS = {
|
|
3
|
+
P0: 'https://res.cloudinary.com/dqrcfnzm9/image/upload/v1779779477/p0_xezwrq.svg',
|
|
4
|
+
P1: 'https://res.cloudinary.com/dqrcfnzm9/image/upload/v1779779477/p1_iqydtr.svg',
|
|
5
|
+
P2: 'https://res.cloudinary.com/dqrcfnzm9/image/upload/v1779779477/p2_skkn6d.svg',
|
|
6
|
+
P3: 'https://res.cloudinary.com/dqrcfnzm9/image/upload/v1782200379/p3_syntlm.svg',
|
|
6
7
|
};
|
|
7
8
|
const PRIORITY_PREFIX_PATTERN = /^\s*\[(P[0-3])\]([^\S\r\n]*)(\r?\n)?/i;
|
|
9
|
+
const PRIORITY_TAG_PATTERN = /\[(P[0-3])\]/gi;
|
|
10
|
+
const PRIORITY_METADATA_PATTERN = /<!--\s*\[(P[0-3])\]\s*-->\s*(?:\[\1\]\s*)?/gi;
|
|
11
|
+
const PRIORITY_BADGE_HTML_SOURCE = String.raw `(?:<a\b[^>]*>\s*)?<img\b(?=[^>]*\balt=["']P[0-3]["'])(?=[^>]*\bsrc=["']https:\/\/res\.cloudinary\.com\/dqrcfnzm9\/image\/upload\/v\d+\/p[0-3]_[^"']+\.svg["'])[^>]*>(?:\s*<\/a>)?`;
|
|
12
|
+
const PRIORITY_BADGE_WITH_METADATA_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}[^\\S\\r\\n]*<!--\\s*\\[(P[0-3])\\]\\s*-->([^\\S\\r\\n]*)`, 'gi');
|
|
13
|
+
const PRIORITY_BADGE_HTML_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}([^\\S\\r\\n]*)`, 'gi');
|
|
14
|
+
const PRIORITY_BADGE_ALT_PATTERN = /\balt=["'](P[0-3])["']/i;
|
|
8
15
|
const INLINE_BADGE_SEPARATOR = ' ';
|
|
9
16
|
export function renderPriorityBadge(severity) {
|
|
10
|
-
const
|
|
11
|
-
|
|
17
|
+
const badgeUrl = PRIORITY_BADGE_URLS[severity];
|
|
18
|
+
// GitHub auto-links bare images to the asset, so keep an explicit anchor around the badge.
|
|
19
|
+
return `<a href="#"><img alt="${severity}" src="${badgeUrl}" align="top"></a>`;
|
|
20
|
+
}
|
|
21
|
+
function renderPriorityMetadata(severity) {
|
|
22
|
+
return `<!--[${severity}] -->`;
|
|
23
|
+
}
|
|
24
|
+
function renderPriorityBadgeWithMetadata(severity) {
|
|
25
|
+
return `${renderPriorityBadge(severity)}${renderPriorityMetadata(severity)}`;
|
|
26
|
+
}
|
|
27
|
+
function getOpeningFence(line) {
|
|
28
|
+
const fence = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
|
|
29
|
+
if (!fence) {
|
|
12
30
|
return null;
|
|
13
31
|
}
|
|
14
|
-
return
|
|
32
|
+
return {
|
|
33
|
+
marker: fence[0],
|
|
34
|
+
length: fence.length,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function isClosingFence(line, activeFence) {
|
|
38
|
+
const fence = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/)?.[1];
|
|
39
|
+
return fence?.[0] === activeFence.marker && fence.length >= activeFence.length;
|
|
15
40
|
}
|
|
16
41
|
export function formatGithubReviewCommentBody(body) {
|
|
17
42
|
const match = body.match(PRIORITY_PREFIX_PATTERN);
|
|
18
43
|
const severity = match ? parseSeverity(match[0]) : null;
|
|
19
|
-
|
|
20
|
-
if (!match || !badge) {
|
|
44
|
+
if (!match || !severity) {
|
|
21
45
|
return body;
|
|
22
46
|
}
|
|
23
47
|
const separator = match[3] ?? INLINE_BADGE_SEPARATOR;
|
|
24
48
|
const content = body.slice(match[0].length);
|
|
25
|
-
return `${
|
|
49
|
+
return `${renderPriorityBadgeWithMetadata(severity)}${separator}${content}`;
|
|
50
|
+
}
|
|
51
|
+
export function formatGithubReviewSummary(body) {
|
|
52
|
+
const parts = body.split(/(\r?\n)/);
|
|
53
|
+
let activeFence = null;
|
|
54
|
+
return parts
|
|
55
|
+
.map((part) => {
|
|
56
|
+
if (/^\r?\n$/.test(part)) {
|
|
57
|
+
return part;
|
|
58
|
+
}
|
|
59
|
+
if (activeFence) {
|
|
60
|
+
if (isClosingFence(part, activeFence)) {
|
|
61
|
+
activeFence = null;
|
|
62
|
+
}
|
|
63
|
+
return part;
|
|
64
|
+
}
|
|
65
|
+
const openingFence = getOpeningFence(part);
|
|
66
|
+
if (openingFence) {
|
|
67
|
+
activeFence = openingFence;
|
|
68
|
+
return part;
|
|
69
|
+
}
|
|
70
|
+
return normalizeGithubPriorityBadges(part).replace(PRIORITY_TAG_PATTERN, (_tag, severity) => renderPriorityBadgeWithMetadata(severity.toUpperCase()));
|
|
71
|
+
})
|
|
72
|
+
.join('');
|
|
73
|
+
}
|
|
74
|
+
export function normalizeGithubPriorityBadges(body) {
|
|
75
|
+
const normalizedBadgeMetadata = body.replace(PRIORITY_BADGE_WITH_METADATA_PATTERN, (badge, metadataSeverity, separator) => {
|
|
76
|
+
const badgeSeverity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
|
|
77
|
+
const severity = metadataSeverity.toUpperCase();
|
|
78
|
+
return badgeSeverity === severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
|
|
79
|
+
});
|
|
80
|
+
const normalizedBadges = normalizedBadgeMetadata.replace(PRIORITY_BADGE_HTML_PATTERN, (badge, separator) => {
|
|
81
|
+
const severity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
|
|
82
|
+
return severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
|
|
83
|
+
});
|
|
84
|
+
const normalizedMetadata = normalizedBadges.replace(PRIORITY_METADATA_PATTERN, (_match, severity) => {
|
|
85
|
+
return `[${severity.toUpperCase()}] `;
|
|
86
|
+
});
|
|
87
|
+
return normalizedMetadata;
|
|
26
88
|
}
|
|
27
89
|
export function formatGithubReviewComments(comments) {
|
|
28
90
|
return comments.map((comment) => ({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getDefaultReviewModels } from 'doistbot-repo-config';
|
|
2
2
|
import { logger, withInfoLogsSuppressed, withLogsSuppressed } from '../../core/logging.js';
|
|
3
3
|
import {} from './diff.js';
|
|
4
4
|
import { createGitRunner, getLocalReviewFiles, resolveLocalAuthor, resolveRepositoryMetadata, tryGit, } from './local-git.js';
|
|
@@ -37,9 +37,12 @@ async function runLocalReviewWithLogging(input) {
|
|
|
37
37
|
prAuthor: author,
|
|
38
38
|
files,
|
|
39
39
|
apiKeys: input.apiKeys,
|
|
40
|
-
reviewModelsEnabled:
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
reviewModelsEnabled: input.reviewModelsEnabled ?? getDefaultReviewModels(),
|
|
41
|
+
...(input.reviewModelsByFocus ? { reviewModelsByFocus: input.reviewModelsByFocus } : {}),
|
|
42
|
+
...(input.geminiFallbackFocuses
|
|
43
|
+
? { geminiFallbackFocuses: input.geminiFallbackFocuses }
|
|
44
|
+
: {}),
|
|
45
|
+
...(input.reviewSummaryModel ? { reviewSummaryModel: input.reviewSummaryModel } : {}),
|
|
43
46
|
lowPriorityFindingPlacement: 'inline',
|
|
44
47
|
workspaceDir: input.workspaceDir,
|
|
45
48
|
onProgress: input.onProgress,
|