@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.
Files changed (61) hide show
  1. package/dist/actions/auth.js +78 -24
  2. package/dist/actions/doctor.js +14 -7
  3. package/dist/actions/review.js +67 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +77 -5
  6. package/dist/env.js +1 -0
  7. package/dist/runtime.js +1 -1
  8. package/dist/terminal.js +2 -0
  9. package/package.json +4 -4
  10. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
  12. package/sandbox/dist/core/check-run.js +16 -0
  13. package/sandbox/dist/core/datadog-metrics.js +6 -52
  14. package/sandbox/dist/core/logger-span-processor.js +78 -0
  15. package/sandbox/dist/core/pi.js +285 -174
  16. package/sandbox/dist/core/prompt-template.js +45 -3
  17. package/sandbox/dist/core/repository-map.js +24 -0
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +80 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/credentials.js +12 -2
  22. package/sandbox/dist/providers/gemini.js +5 -1
  23. package/sandbox/dist/providers/helpers.js +63 -17
  24. package/sandbox/dist/providers/index.js +4 -0
  25. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  26. package/sandbox/dist/providers/pi-review.js +19 -2
  27. package/sandbox/dist/tasks/chat/chat.js +173 -102
  28. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  29. package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
  30. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  31. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  32. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  33. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  34. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
  35. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  36. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  37. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  38. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  39. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  40. package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
  41. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  42. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  43. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  44. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  45. package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
  46. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  47. package/sandbox/dist/tasks/review/check-run.js +2 -1
  48. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  49. package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
  50. package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
  51. package/sandbox/dist/tasks/review/engines/shared.js +43 -6
  52. package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
  53. package/sandbox/dist/tasks/review/local.js +7 -4
  54. package/sandbox/dist/tasks/review/review-summary.js +48 -7
  55. package/sandbox/dist/tasks/review/review.js +267 -186
  56. package/sandbox/dist/tasks/review/runner.js +33 -7
  57. package/sandbox/dist/tasks/review/summary-model.js +189 -20
  58. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  59. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  60. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
  61. 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
- async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKeys, workspaceDir, onProgress, }) {
11
- const prompt = buildMultiFocusPrompt(basePrompt, focus, standardsSection);
12
- const result = await requestModelReview({
13
- model,
14
- prompt,
15
- apiKeys,
16
- workspaceDir,
17
- onProgress,
18
- });
19
- if (!result) {
20
- logger.warn('Multi-focus review pass failed without a result', {
21
- focus: focus.name,
22
- model,
23
- });
24
- return {
25
- status: 'failed',
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
- error: `No review result returned for ${model}`,
29
- };
30
- }
31
- if (result.error) {
32
- logger.warn('Multi-focus review pass failed', {
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: 'failed',
84
+ status: 'succeeded',
39
85
  focus: focus.name,
40
86
  model,
41
- error: result.error,
87
+ summary: result.summary.trim(),
88
+ findings: result.findings.map((finding) => ({
89
+ ...finding,
90
+ focus: focus.name,
91
+ })),
42
92
  };
43
- }
44
- return {
45
- status: 'succeeded',
46
- focus: focus.name,
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
- summary: result.summary.trim(),
49
- findings: result.findings.map((finding) => ({
50
- ...finding,
51
- focus: focus.name,
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
- async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryCredential, workspaceDir, }) {
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 findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
121
- const synthesizedSummary = summaryCredential == null
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: summaryCredential,
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 && summaryFindings.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 availableModels = getAvailableModels(apiKeys);
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
- const focusRuns = await Promise.all(REVIEW_FOCUS_DEFINITIONS.flatMap((focus) => availableModels.map((model) => runFocusPass({
162
- focus,
163
- model,
164
- basePrompt: prompt,
165
- standardsSection,
166
- apiKeys,
167
- workspaceDir: ctx.workspaceDir,
168
- onProgress,
169
- }))));
170
- const successfulRuns = focusRuns.filter((run) => run.status === 'succeeded');
171
- const providerFailures = getProviderFailures(focusRuns
172
- .filter((run) => run.status === 'failed')
173
- .map((run) => ({
174
- model: run.model,
175
- summary: '',
176
- findings: [],
177
- durationMs: 0,
178
- error: run.error,
179
- })));
180
- if (successfulRuns.length === 0) {
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: null,
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 { getReviewProviderCredential, hasReviewProviderCredential, } from '../../../providers/credentials.js';
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
- if (ctx.reviewModelsEnabled.includes('gemini')) {
7
- apiKeys.gemini = ctx.geminiCredential;
7
+ for (const model of ctx.reviewModelsEnabled) {
8
+ apiKeys[model] = ctx.apiKeys[model];
8
9
  }
9
- if (ctx.reviewModelsEnabled.includes('openai')) {
10
- apiKeys.openai = ctx.openaiApiKey;
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 PRIORITY_BADGE_LABELS = {
3
- P0: '🔴 P0',
4
- P1: '🟠 P1',
5
- P2: '🟡 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 label = PRIORITY_BADGE_LABELS[severity];
11
- if (!label) {
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 `<big><kbd>${label}</kbd></big>`;
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
- const badge = severity ? renderPriorityBadge(severity) : null;
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 `${badge}${separator}${content}`;
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 { parseReviewModelsEnabled } from 'doistbot-repo-config';
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: parseReviewModelsEnabled(process.env.REVIEW_MODELS_ENABLED, {
41
- requiredModels: [],
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,