@doist/doistbot-cli 1.0.4 → 1.0.6

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 (82) hide show
  1. package/dist/actions/auth.js +62 -28
  2. package/dist/actions/doctor.js +16 -11
  3. package/dist/actions/review.js +77 -11
  4. package/dist/auth.js +53 -8
  5. package/dist/config.js +40 -6
  6. package/dist/env.js +1 -1
  7. package/dist/file-store.js +1 -1
  8. package/dist/runtime.js +1 -1
  9. package/dist/terminal.js +2 -7
  10. package/package.json +7 -7
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
  12. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +9 -85
  13. package/sandbox/dist/core/bot-logins.js +0 -13
  14. package/sandbox/dist/core/check-run.js +16 -0
  15. package/sandbox/dist/core/datadog-metrics.js +6 -54
  16. package/sandbox/dist/core/logger-span-processor.js +78 -0
  17. package/sandbox/dist/core/pi.js +270 -186
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +71 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/config.js +1 -1
  22. package/sandbox/dist/providers/credentials.js +40 -0
  23. package/sandbox/dist/providers/gemini.js +8 -10
  24. package/sandbox/dist/providers/helpers.js +18 -38
  25. package/sandbox/dist/providers/index.js +4 -3
  26. package/sandbox/dist/providers/openai.js +1 -8
  27. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  28. package/sandbox/dist/providers/pi-review.js +28 -55
  29. package/sandbox/dist/providers/schemas.js +0 -39
  30. package/sandbox/dist/tasks/chat/chat.js +162 -101
  31. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  32. package/sandbox/dist/tasks/issue-summarize/context.js +1 -1
  33. package/sandbox/dist/tasks/issue-summarize/model.js +4 -2
  34. package/sandbox/dist/tasks/issue-summarize/summarize.js +1 -1
  35. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  36. package/sandbox/dist/tasks/issue-triage/context.js +25 -2
  37. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +31 -8
  38. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  39. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  40. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  41. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  42. package/sandbox/dist/tasks/issue-triage/pr-creator.js +3 -2
  43. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  44. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  45. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  46. package/sandbox/dist/tasks/issue-triage/triage.js +288 -418
  47. package/sandbox/dist/tasks/persist-logs/dd-proxy.js +2 -2
  48. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +1 -1
  49. package/sandbox/dist/tasks/persist-logs/persist-logs.js +1 -1
  50. package/sandbox/dist/tasks/review/check-run.js +2 -1
  51. package/sandbox/dist/tasks/review/conversation-context.js +15 -8
  52. package/sandbox/dist/tasks/review/engines/dedupe.js +19 -9
  53. package/sandbox/dist/tasks/review/engines/multi-focus.js +62 -36
  54. package/sandbox/dist/tasks/review/engines/shared.js +15 -61
  55. package/sandbox/dist/tasks/review/github-comment-format.js +64 -10
  56. package/sandbox/dist/tasks/review/local.js +2 -29
  57. package/sandbox/dist/tasks/review/prompt.js +2 -49
  58. package/sandbox/dist/tasks/review/review-summary.js +120 -0
  59. package/sandbox/dist/tasks/review/review.js +49 -69
  60. package/sandbox/dist/tasks/review/runner.js +57 -52
  61. package/sandbox/dist/tasks/review/summary-model.js +172 -16
  62. package/sandbox/dist/tasks/review/thinking-level.js +4 -2
  63. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
  64. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +9 -85
  65. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  66. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  67. package/sandbox/dist/providers/anthropic.js +0 -30
  68. package/sandbox/dist/tasks/review/author-format.js +0 -14
  69. package/sandbox/dist/tasks/review/author-profile.js +0 -67
  70. package/sandbox/dist/tasks/review/council/normalizer.js +0 -104
  71. package/sandbox/dist/tasks/review/council/orchestrator.js +0 -194
  72. package/sandbox/dist/tasks/review/council/provider-health.js +0 -31
  73. package/sandbox/dist/tasks/review/council/summary.js +0 -192
  74. package/sandbox/dist/tasks/review/council/types.js +0 -4
  75. package/sandbox/dist/tasks/review/council/voter.js +0 -384
  76. package/sandbox/dist/tasks/review/engines/council.js +0 -126
  77. package/sandbox/dist/tasks/review/engines/index.js +0 -12
  78. package/sandbox/dist/tasks/review/review-engine.js +0 -5
  79. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  80. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  81. package/sandbox/review-prompt.md +0 -209
  82. package/sandbox/vote-prompt.md +0 -69
@@ -7,8 +7,8 @@ const DD_HOST = 'api.datadoghq.com';
7
7
  * Starts a localhost HTTP proxy that forwards requests to the Datadog API,
8
8
  * scrubs PII from responses, and logs each query for auditability.
9
9
  *
10
- * Claude hits http://localhost:{port}/api/v2/... instead of the DD API directly.
11
- * The proxy injects DD auth headers so Claude never sees the API keys.
10
+ * The agent hits http://localhost:{port}/api/v2/... instead of the DD API directly.
11
+ * The proxy injects DD auth headers so the agent never sees the API keys.
12
12
  */
13
13
  export function startDdProxy({ port, datadogApiKey, datadogAppKey }) {
14
14
  return new Promise((resolve, reject) => {
@@ -49,7 +49,7 @@ export async function runInvestigation(params) {
49
49
  });
50
50
  // Ensure output directory exists
51
51
  fs.mkdirSync(params.outputDir, { recursive: true });
52
- // Start the DD proxy — Claude queries localhost, proxy injects auth and scrubs PII from responses
52
+ // Start the DD proxy so the agent queries localhost; the proxy injects auth and scrubs PII.
53
53
  const proxy = await startDdProxy({
54
54
  port: DD_PROXY_PORT,
55
55
  datadogApiKey: params.datadogApiKey,
@@ -441,7 +441,7 @@ export function buildSnapshotComment({ analysisContent, csvContent, worklogConte
441
441
  '_Links expire in 7 days. Use `@doistbot /refresh-s3-urls` to generate new links._',
442
442
  ].join('\n');
443
443
  const csvPreview = buildCsvPreview(csvContent);
444
- // Always prepend the marker — Claude's output won't contain it
444
+ // Always prepend the marker; model output won't contain it.
445
445
  const analysis = analysisContent
446
446
  ? `${LOG_SNAPSHOT_MARKER}\n${analysisContent}`
447
447
  : `${LOG_SNAPSHOT_MARKER}\n## Datadog Log Snapshot\n\n**${totalLogs} log entries** captured from Datadog.`;
@@ -135,7 +135,7 @@ export function buildSuccessSummary(review, pullRequestUrl, counts = countFindin
135
135
  * PR conversation comment.
136
136
  */
137
137
  async function postReviewCheckRun(params) {
138
- const { octokit, owner, repo, sha, pullNumber, deliveryId, title, summaryBody } = params;
138
+ const { octokit, owner, repo, sha, pullNumber, deliveryId, checkRunId, title, summaryBody } = params;
139
139
  const pullRequestUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`;
140
140
  const summary = appendReviewFeedbackLink(summaryBody, {
141
141
  owner,
@@ -153,6 +153,7 @@ async function postReviewCheckRun(params) {
153
153
  detailsUrl: pullRequestUrl,
154
154
  name: CHECK_RUN_NAME,
155
155
  externalId: deliveryId,
156
+ checkRunId,
156
157
  };
157
158
  if (params.status === 'in_progress') {
158
159
  await postCheckRun({ ...sharedArgs, status: 'in_progress' });
@@ -3,6 +3,8 @@ import { fetchIssueComments } from '../../core/github-issues.js';
3
3
  import { logger } from '../../core/logging.js';
4
4
  import { invokePiPrompt } from '../../core/pi.js';
5
5
  import { WORKSPACE_DIR } from '../../core/shared.js';
6
+ import { resolveReviewProviderCredential } from '../../providers/credentials.js';
7
+ import { normalizeGithubPriorityBadges } from './github-comment-format.js';
6
8
  import { stripReviewFeedbackFooter } from './review-feedback.js';
7
9
  // Roughly 6k tokens: large enough for substantive PR discussions while still
8
10
  // keeping conversation context secondary to the diff.
@@ -14,11 +16,8 @@ const GITHUB_ACTIONS_BOT_LOGIN = 'github-actions[bot]';
14
16
  const MASKED_PR_AUTHOR_LABEL = 'PR author';
15
17
  function getSummaryProviders(apiKeys) {
16
18
  return [
17
- ...(apiKeys?.gemini ? [{ provider: 'gemini', apiKey: apiKeys.gemini }] : []),
18
- ...(apiKeys?.openai ? [{ provider: 'openai', apiKey: apiKeys.openai }] : []),
19
- ...(apiKeys?.anthropic
20
- ? [{ provider: 'anthropic', apiKey: apiKeys.anthropic }]
21
- : []),
19
+ ...(apiKeys?.gemini ? [{ provider: 'gemini', credential: apiKeys.gemini }] : []),
20
+ ...(apiKeys?.openai ? [{ provider: 'openai', credential: apiKeys.openai }] : []),
22
21
  ];
23
22
  }
24
23
  function isRelevantBotLogin(login) {
@@ -38,7 +37,7 @@ function shouldMaskPrAuthor(login, prAuthor) {
38
37
  return Boolean(normalizedLogin && normalizedPrAuthor && normalizedLogin === normalizedPrAuthor);
39
38
  }
40
39
  function cleanBody(body) {
41
- return stripReviewFeedbackFooter(body);
40
+ return normalizeGithubPriorityBadges(stripReviewFeedbackFooter(body));
42
41
  }
43
42
  function toIssueCommentEntry(comment) {
44
43
  const author = comment.user?.login ?? 'unknown';
@@ -253,7 +252,7 @@ If a section has no relevant information, use "- None."
253
252
  ${rawContext}
254
253
  </pr_conversation>`;
255
254
  }
256
- async function summarizeConversationContextWithModel({ provider, apiKey, rawContext, workspaceDir, }) {
255
+ async function summarizeConversationContextWithModel({ provider, credential, rawContext, workspaceDir, }) {
257
256
  const startedAt = Date.now();
258
257
  logger.info('PR conversation summarization started', {
259
258
  task: 'review',
@@ -261,9 +260,17 @@ async function summarizeConversationContextWithModel({ provider, apiKey, rawCont
261
260
  raw_chars: rawContext.length,
262
261
  });
263
262
  try {
263
+ const resolvedCredential = resolveReviewProviderCredential(credential);
264
+ if (!resolvedCredential) {
265
+ return null;
266
+ }
264
267
  const result = await invokePiPrompt({
265
268
  provider,
266
- apiKey,
269
+ apiKey: resolvedCredential.apiKey,
270
+ ...(resolvedCredential.piProviderId
271
+ ? { piProviderId: resolvedCredential.piProviderId }
272
+ : {}),
273
+ ...(resolvedCredential.modelName ? { modelName: resolvedCredential.modelName } : {}),
267
274
  prompt: buildSummaryPrompt(rawContext),
268
275
  cwd: workspaceDir ?? WORKSPACE_DIR,
269
276
  timeoutMs: CONVERSATION_SUMMARY_TIMEOUT_MS,
@@ -1,11 +1,15 @@
1
1
  import { logger } from '../../../core/logging.js';
2
2
  import { invokePiPrompt } from '../../../core/pi.js';
3
3
  import { logPiProgressEvent } from '../../../core/pi-progress-logging.js';
4
+ import { resolveReviewProviderCredential } from '../../../providers/credentials.js';
4
5
  import { extractJsonFromResponse } from '../../../providers/helpers.js';
5
6
  import { isDedupeResponse } from '../../../providers/schemas.js';
6
7
  const DEDUPE_TIMEOUT_MS = 5 * 60 * 1000;
7
8
  const DEDUPE_PROVIDER = 'gemini';
8
- const DEDUPE_MODEL_NAME = 'gemini-3-flash-preview';
9
+ const DEDUPE_MODEL_BY_PI_PROVIDER = {
10
+ google: 'gemini-3-flash-preview',
11
+ openrouter: 'google/gemini-3-flash-preview',
12
+ };
9
13
  const DEDUPE_THINKING_LEVEL = 'medium';
10
14
  const DEDUPE_MAX_ATTEMPTS = 2;
11
15
  const DEDUPE_RETRY_DELAY_MS = 1_000;
@@ -106,28 +110,31 @@ export function applyDedupeGroups(findings, groups) {
106
110
  });
107
111
  return findings.filter((finding) => !dropped.has(finding.id));
108
112
  }
109
- export async function dedupeMultiFocusFindings({ findings, availableModels, workspaceDir, geminiApiKey, }) {
113
+ export async function dedupeMultiFocusFindings({ findings, availableModels, workspaceDir, geminiCredential, }) {
110
114
  if (findings.length <= 1) {
111
115
  return findings;
112
116
  }
113
117
  if (!availableModels.includes(DEDUPE_PROVIDER)) {
114
118
  logger.error('Multi-focus dedupe unavailable because Gemini is not enabled', {
115
119
  provider: DEDUPE_PROVIDER,
116
- model: DEDUPE_MODEL_NAME,
120
+ model: DEDUPE_MODEL_BY_PI_PROVIDER.google,
117
121
  });
118
122
  throw new Error('Multi-focus dedupe requires Gemini to be enabled');
119
123
  }
120
- if (!geminiApiKey) {
121
- logger.error('Multi-focus dedupe unavailable because Gemini API key is unavailable', {
124
+ const credential = resolveReviewProviderCredential(geminiCredential);
125
+ if (!credential) {
126
+ logger.error('Multi-focus dedupe unavailable because Gemini review credential is unavailable', {
122
127
  provider: DEDUPE_PROVIDER,
123
- model: DEDUPE_MODEL_NAME,
128
+ model: DEDUPE_MODEL_BY_PI_PROVIDER.google,
124
129
  });
125
- throw new Error('Multi-focus dedupe requires a Gemini API key');
130
+ throw new Error('Multi-focus dedupe requires a Gemini review credential');
126
131
  }
132
+ const piProviderId = credential.piProviderId ?? 'google';
127
133
  const providerConfig = {
128
134
  provider: DEDUPE_PROVIDER,
129
- apiKey: geminiApiKey,
130
- modelName: DEDUPE_MODEL_NAME,
135
+ apiKey: credential.apiKey,
136
+ ...(credential.piProviderId ? { piProviderId: credential.piProviderId } : {}),
137
+ modelName: DEDUPE_MODEL_BY_PI_PROVIDER[piProviderId] ?? DEDUPE_MODEL_BY_PI_PROVIDER.google,
131
138
  };
132
139
  for (let attempt = 1; attempt <= DEDUPE_MAX_ATTEMPTS; attempt += 1) {
133
140
  let result;
@@ -135,6 +142,9 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
135
142
  result = await invokePiPrompt({
136
143
  provider: providerConfig.provider,
137
144
  apiKey: providerConfig.apiKey,
145
+ ...(providerConfig.piProviderId
146
+ ? { piProviderId: providerConfig.piProviderId }
147
+ : {}),
138
148
  prompt: buildDedupePrompt(findings),
139
149
  cwd: workspaceDir,
140
150
  timeoutMs: DEDUPE_TIMEOUT_MS,
@@ -1,20 +1,28 @@
1
1
  import { logger } from '../../../core/logging.js';
2
2
  import { convertFindingsToComments } from '../comment-mapper.js';
3
- import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
4
3
  import { buildReviewOutputFindings } from '../finding-output.js';
5
4
  import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus-prompt.js';
5
+ import { buildFallbackSummary, summarizeReviewFindings } from '../review-summary.js';
6
6
  import { parseSeverity } from '../severity.js';
7
+ import { buildGeminiFlashReviewSummaryModel } from '../summary-model.js';
7
8
  import { dedupeMultiFocusFindings } from './dedupe.js';
8
9
  import { getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
10
+ const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
11
+ general: ['gemini', 'openai'],
12
+ quality: ['gemini', 'openai'],
13
+ efficiency: ['gemini', 'openai'],
14
+ tests: ['deepseek', 'zai'],
15
+ standards: ['deepseek', 'zai'],
16
+ };
9
17
  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, }) {
18
+ async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKeys, workspaceDir, openRouterSessionId, onProgress, }) {
11
19
  const prompt = buildMultiFocusPrompt(basePrompt, focus, standardsSection);
12
20
  const result = await requestModelReview({
13
21
  model,
14
22
  prompt,
15
23
  apiKeys,
16
24
  workspaceDir,
17
- logEngine: 'multi_focus',
25
+ openRouterSessionId,
18
26
  onProgress,
19
27
  });
20
28
  if (!result) {
@@ -70,10 +78,10 @@ function routeLowPriorityFindings(findings, placement = 'summary') {
70
78
  }
71
79
  return { inlineFindings, summaryFindings };
72
80
  }
73
- function buildNoInlineSummaryWithNotes(prTitle, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary) {
81
+ function buildNoInlineSummaryWithNotes(prTitle) {
74
82
  const safeTitle = prTitle.trim();
75
83
  const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
76
- return withReviewSummaryGreeting(`${base} No inline issues were flagged.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
84
+ return `${base} No inline issues were flagged.`;
77
85
  }
78
86
  function collectReviewObservations(focusRuns) {
79
87
  const observations = [];
@@ -95,6 +103,10 @@ function collectReviewObservations(focusRuns) {
95
103
  }
96
104
  return observations.slice(0, 8);
97
105
  }
106
+ function getModelsForFocus(focus, availableModels) {
107
+ const preferredModels = DEFAULT_REVIEW_MODELS_BY_FOCUS[focus.name];
108
+ return preferredModels.filter((model) => availableModels.includes(model));
109
+ }
98
110
  function formatSummaryOnlyFinding(finding) {
99
111
  const severity = parseSeverity(finding.body);
100
112
  const bodyWithoutSeverity = finding.body.replace(/^\s*\[P\d\]\s*/i, '').trim();
@@ -116,34 +128,40 @@ function appendSummaryOnlyFindings(summary, summaryFindings) {
116
128
  const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
117
129
  return `${summary.trim()}\n\n${LOW_PRIORITY_SUMMARY_NOTICE}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
118
130
  }
119
- async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, reviewObservations, summaryApiKey, workspaceDir, }) {
131
+ function buildSummarySynthesisFindings({ inlineFindings, summaryFindings, }) {
132
+ return Array.from(new Set([
133
+ ...inlineFindings.map((finding) => finding.body.trim()),
134
+ ...summaryFindings.map((finding) => formatSummaryOnlyFinding(finding)),
135
+ ].filter(Boolean)));
136
+ }
137
+ async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryCredential, summaryFallbackModels, workspaceDir, }) {
120
138
  const comments = convertFindingsToComments(inlineFindings, lineLookup);
121
- const findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
122
- const resolvedPrAuthorGreetingName = excludePrAuthorFromSummary === true
123
- ? undefined
124
- : (prAuthorGreetingName ?? (await resolvePrAuthorGreetingName?.()));
125
- const synthesizedSummary = summaryApiKey == null
139
+ const inlineCommentFindings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
140
+ const findings = buildSummarySynthesisFindings({ inlineFindings, summaryFindings });
141
+ const hasReviewFindings = inlineFindings.length > 0 || summaryFindings.length > 0;
142
+ if (!hasReviewFindings) {
143
+ logger.info('Review summary synthesis skipped for clean review', {
144
+ inlineFindingsCount: inlineFindings.length,
145
+ summaryFindingsCount: summaryFindings.length,
146
+ });
147
+ }
148
+ const synthesizedSummary = summaryCredential == null || !hasReviewFindings
126
149
  ? null
127
- : await summarizeCouncilReviews({
128
- apiKey: summaryApiKey,
150
+ : await summarizeReviewFindings({
151
+ credential: summaryCredential,
129
152
  workspaceDir,
130
153
  prTitle,
131
154
  prBody,
132
- prAuthor,
133
- prAuthorGreetingName: resolvedPrAuthorGreetingName,
134
- excludePrAuthorFromSummary,
135
155
  findings,
136
156
  reviewObservations,
157
+ fallbackModels: summaryFallbackModels,
137
158
  });
138
159
  const summaryWithoutNotes = synthesizedSummary ||
139
- (comments.length === 0 && summaryFindings.length > 0
140
- ? buildNoInlineSummaryWithNotes(prTitle, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
160
+ (comments.length === 0 && hasReviewFindings
161
+ ? buildNoInlineSummaryWithNotes(prTitle)
141
162
  : buildFallbackSummary({
142
163
  prTitle,
143
- findings,
144
- prAuthor,
145
- prAuthorGreetingName: resolvedPrAuthorGreetingName,
146
- excludePrAuthorFromSummary,
164
+ findings: inlineCommentFindings,
147
165
  }));
148
166
  return {
149
167
  summary: appendSummaryOnlyFindings(summaryWithoutNotes, summaryFindings),
@@ -155,10 +173,10 @@ async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, li
155
173
  }),
156
174
  };
157
175
  }
158
- export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, lowPriorityFindingPlacement, standardsSection, onProgress, }) {
176
+ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, lowPriorityFindingPlacement, standardsSection, onProgress, }) {
159
177
  const apiKeys = getApiKeys(ctx);
160
178
  const availableModels = getAvailableModels(apiKeys);
161
- const metricModels = availableModels.length > 0 ? [...availableModels] : [...ctx.councilModelsEnabled];
179
+ const metricModels = availableModels.length > 0 ? [...availableModels] : [...ctx.reviewModelsEnabled];
162
180
  if (availableModels.length === 0) {
163
181
  return {
164
182
  review: null,
@@ -168,13 +186,14 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
168
186
  providerFailures: [],
169
187
  };
170
188
  }
171
- const focusRuns = await Promise.all(REVIEW_FOCUS_DEFINITIONS.flatMap((focus) => availableModels.map((model) => runFocusPass({
189
+ const focusRuns = await Promise.all(REVIEW_FOCUS_DEFINITIONS.flatMap((focus) => getModelsForFocus(focus, availableModels).map((model) => runFocusPass({
172
190
  focus,
173
191
  model,
174
192
  basePrompt: prompt,
175
193
  standardsSection,
176
194
  apiKeys,
177
195
  workspaceDir: ctx.workspaceDir,
196
+ openRouterSessionId: ctx.openRouterSessionId,
178
197
  onProgress,
179
198
  }))));
180
199
  const successfulRuns = focusRuns.filter((run) => run.status === 'succeeded');
@@ -198,25 +217,32 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
198
217
  }
199
218
  const allFindings = successfulRuns.flatMap((run) => run.findings);
200
219
  const reviewObservations = collectReviewObservations(successfulRuns);
201
- const dedupedFindings = await dedupeMultiFocusFindings({
202
- findings: allFindings,
203
- availableModels,
204
- workspaceDir: ctx.workspaceDir,
205
- geminiApiKey: ctx.geminiApiKey,
206
- });
220
+ let dedupedFindings = allFindings;
221
+ try {
222
+ dedupedFindings = await dedupeMultiFocusFindings({
223
+ findings: allFindings,
224
+ availableModels,
225
+ workspaceDir: ctx.workspaceDir,
226
+ geminiCredential: apiKeys.gemini,
227
+ });
228
+ }
229
+ catch (error) {
230
+ logger.warn('Multi-focus dedupe failed, continuing with raw findings', {
231
+ findingsCount: allFindings.length,
232
+ error: error instanceof Error ? error.message : String(error),
233
+ });
234
+ }
207
235
  const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
236
+ const geminiSummaryFallback = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
208
237
  const review = await buildMultiFocusReviewOutput({
209
238
  inlineFindings: routedFindings.inlineFindings,
210
239
  summaryFindings: routedFindings.summaryFindings,
211
240
  lineLookup,
212
241
  prTitle,
213
242
  prBody,
214
- prAuthor,
215
- prAuthorGreetingName,
216
- resolvePrAuthorGreetingName,
217
- excludePrAuthorFromSummary,
218
243
  reviewObservations,
219
- summaryApiKey: apiKeys.gemini,
244
+ summaryCredential: apiKeys.zai,
245
+ summaryFallbackModels: geminiSummaryFallback ? [geminiSummaryFallback] : [],
220
246
  workspaceDir: ctx.workspaceDir,
221
247
  });
222
248
  logger.info('Multi-focus review completed', {
@@ -1,29 +1,23 @@
1
+ import { getReviewProviderCredential, hasReviewProviderCredential, } from '../../../providers/credentials.js';
1
2
  import { getProvider } from '../../../providers/index.js';
2
- import { convertFindingsToComments } from '../comment-mapper.js';
3
- import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
4
- import { buildReviewOutputFindings } from '../finding-output.js';
5
3
  import { getReviewThinkingLevel } from '../thinking-level.js';
6
4
  export function getApiKeys(ctx) {
7
5
  const apiKeys = {};
8
- if (ctx.councilModelsEnabled.includes('gemini')) {
9
- apiKeys.gemini = ctx.geminiApiKey;
10
- }
11
- if (ctx.councilModelsEnabled.includes('openai')) {
12
- apiKeys.openai = ctx.openaiApiKey;
13
- }
14
- if (ctx.councilModelsEnabled.includes('anthropic')) {
15
- apiKeys.anthropic = ctx.anthropicApiKey;
6
+ for (const model of ctx.reviewModelsEnabled) {
7
+ apiKeys[model] = ctx.apiKeys[model];
16
8
  }
17
9
  return apiKeys;
18
10
  }
19
11
  export function getAvailableModels(apiKeys) {
20
12
  const models = [];
21
- if (apiKeys.gemini)
13
+ if (hasReviewProviderCredential(apiKeys.gemini))
22
14
  models.push('gemini');
23
- if (apiKeys.openai)
15
+ if (hasReviewProviderCredential(apiKeys.openai))
24
16
  models.push('openai');
25
- if (apiKeys.anthropic)
26
- models.push('anthropic');
17
+ if (hasReviewProviderCredential(apiKeys.deepseek))
18
+ models.push('deepseek');
19
+ if (hasReviewProviderCredential(apiKeys.zai))
20
+ models.push('zai');
27
21
  return models;
28
22
  }
29
23
  export function getProviderFailures(results) {
@@ -34,63 +28,23 @@ export function getProviderFailures(results) {
34
28
  error: result.error,
35
29
  }));
36
30
  }
37
- export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, logEngine, onProgress, }) {
38
- const apiKey = apiKeys[model];
39
- if (!apiKey) {
31
+ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, openRouterSessionId, onProgress, }) {
32
+ const credential = getReviewProviderCredential(apiKeys, model);
33
+ if (!credential) {
40
34
  return null;
41
35
  }
42
36
  const provider = getProvider(model);
43
37
  const config = {
44
- apiKey,
38
+ ...credential,
45
39
  workspaceDir,
46
40
  thinkingLevel: getReviewThinkingLevel(model),
41
+ openRouterSessionId,
47
42
  onProgress,
48
43
  };
49
44
  try {
50
- return await provider.review(prompt, config, logEngine ? { engine: logEngine } : undefined);
45
+ return await provider.review(prompt, config);
51
46
  }
52
47
  catch {
53
48
  return null;
54
49
  }
55
50
  }
56
- export async function buildReviewOutputFromFindings({ commentsSource, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, summaryApiKey, workspaceDir, preferredSummary, reviewObservations, }) {
57
- const comments = convertFindingsToComments(commentsSource, lineLookup);
58
- const findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
59
- const preferredSummaryText = preferredSummary?.trim();
60
- const hasPreferredSummary = Boolean(preferredSummaryText);
61
- const resolvedPrAuthorGreetingName = excludePrAuthorFromSummary === true
62
- ? undefined
63
- : (prAuthorGreetingName ?? (await resolvePrAuthorGreetingName?.()));
64
- const synthesizedSummary = hasPreferredSummary || summaryApiKey == null
65
- ? null
66
- : await summarizeCouncilReviews({
67
- apiKey: summaryApiKey,
68
- workspaceDir,
69
- prTitle,
70
- prBody,
71
- prAuthor,
72
- prAuthorGreetingName: resolvedPrAuthorGreetingName,
73
- excludePrAuthorFromSummary,
74
- findings,
75
- reviewObservations,
76
- });
77
- const summary = preferredSummaryText ||
78
- synthesizedSummary ||
79
- buildFallbackSummary({
80
- prTitle,
81
- findings,
82
- prAuthor,
83
- prAuthorGreetingName: resolvedPrAuthorGreetingName,
84
- excludePrAuthorFromSummary,
85
- });
86
- return {
87
- summary: hasPreferredSummary
88
- ? withReviewSummaryGreeting(summary, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
89
- : summary,
90
- comments,
91
- findings: buildReviewOutputFindings({
92
- inlineFindings: commentsSource,
93
- lineLookup,
94
- }),
95
- };
96
- }
@@ -1,28 +1,82 @@
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_PATTERN = /(?:<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>)?([^\S\r\n]*)/gi;
12
+ const PRIORITY_BADGE_ALT_PATTERN = /\balt=["'](P[0-3])["']/i;
8
13
  const INLINE_BADGE_SEPARATOR = ' ';
9
14
  export function renderPriorityBadge(severity) {
10
- const label = PRIORITY_BADGE_LABELS[severity];
11
- if (!label) {
15
+ const badgeUrl = PRIORITY_BADGE_URLS[severity];
16
+ // GitHub auto-links bare images to the asset, so keep an explicit anchor around the badge.
17
+ return `<a href="#"><img alt="${severity}" src="${badgeUrl}" align="top"></a>`;
18
+ }
19
+ function renderPriorityMetadata(severity) {
20
+ return `<!--[${severity}] -->`;
21
+ }
22
+ function renderPriorityBadgeWithMetadata(severity) {
23
+ return `${renderPriorityMetadata(severity)}${renderPriorityBadge(severity)}`;
24
+ }
25
+ function getOpeningFence(line) {
26
+ const fence = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
27
+ if (!fence) {
12
28
  return null;
13
29
  }
14
- return `<big><kbd>${label}</kbd></big>`;
30
+ return {
31
+ marker: fence[0],
32
+ length: fence.length,
33
+ };
34
+ }
35
+ function isClosingFence(line, activeFence) {
36
+ const fence = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/)?.[1];
37
+ return fence?.[0] === activeFence.marker && fence.length >= activeFence.length;
15
38
  }
16
39
  export function formatGithubReviewCommentBody(body) {
17
40
  const match = body.match(PRIORITY_PREFIX_PATTERN);
18
41
  const severity = match ? parseSeverity(match[0]) : null;
19
- const badge = severity ? renderPriorityBadge(severity) : null;
20
- if (!match || !badge) {
42
+ if (!match || !severity) {
21
43
  return body;
22
44
  }
23
45
  const separator = match[3] ?? INLINE_BADGE_SEPARATOR;
24
46
  const content = body.slice(match[0].length);
25
- return `${badge}${separator}${content}`;
47
+ return `${renderPriorityBadgeWithMetadata(severity)}${separator}${content}`;
48
+ }
49
+ export function formatGithubReviewSummary(body) {
50
+ const parts = body.split(/(\r?\n)/);
51
+ let activeFence = null;
52
+ return parts
53
+ .map((part) => {
54
+ if (/^\r?\n$/.test(part)) {
55
+ return part;
56
+ }
57
+ if (activeFence) {
58
+ if (isClosingFence(part, activeFence)) {
59
+ activeFence = null;
60
+ }
61
+ return part;
62
+ }
63
+ const openingFence = getOpeningFence(part);
64
+ if (openingFence) {
65
+ activeFence = openingFence;
66
+ return part;
67
+ }
68
+ return normalizeGithubPriorityBadges(part).replace(PRIORITY_TAG_PATTERN, (_tag, severity) => renderPriorityBadgeWithMetadata(severity.toUpperCase()));
69
+ })
70
+ .join('');
71
+ }
72
+ export function normalizeGithubPriorityBadges(body) {
73
+ const normalizedBadges = body.replace(PRIORITY_BADGE_HTML_PATTERN, (badge, separator) => {
74
+ const severity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
75
+ return severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
76
+ });
77
+ return normalizedBadges.replace(PRIORITY_METADATA_PATTERN, (_match, severity) => {
78
+ return `[${severity.toUpperCase()}] `;
79
+ });
26
80
  }
27
81
  export function formatGithubReviewComments(comments) {
28
82
  return comments.map((comment) => ({
@@ -1,24 +1,9 @@
1
- import { getLocalCustomPrompt, getLocalRepoConfig, parseCouncilModelsEnabled, resolveReviewEngine, } 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';
5
- import { loadDefaultReviewPromptTemplate } from './prompt.js';
6
5
  import { runSharedReview } from './runner.js';
7
6
  const LOCAL_REVIEW_PR_NUMBER = 0;
8
- function resolveLocalPromptTemplate(workspaceDir, promptFile) {
9
- if (promptFile) {
10
- const customPrompt = getLocalCustomPrompt({
11
- workspaceDir,
12
- path: promptFile,
13
- logger,
14
- });
15
- if (customPrompt) {
16
- return customPrompt;
17
- }
18
- logger.info('Local custom prompt not found, using default', { path: promptFile });
19
- }
20
- return loadDefaultReviewPromptTemplate();
21
- }
22
7
  export async function runLocalReview(input) {
23
8
  if (input.suppressLogs === true) {
24
9
  return await withLogsSuppressed(['info', 'warn', 'error'], async () => await runLocalReviewWithLogging(input));
@@ -41,13 +26,6 @@ async function runLocalReviewWithLogging(input) {
41
26
  source: input.source,
42
27
  });
43
28
  input.onProgress?.({ type: 'local_files_resolved', fileCount: files.length });
44
- const repoConfig = getLocalRepoConfig({ workspaceDir: input.workspaceDir, logger });
45
- const reviewEngine = resolveReviewEngine({
46
- configuredEngine: repoConfig?.review?.engine,
47
- envEngine: process.env.REVIEW_ENGINE,
48
- logger,
49
- repository: `${owner}/${repo}`,
50
- });
51
29
  const reviewResult = await runSharedReview({
52
30
  owner,
53
31
  repo,
@@ -58,13 +36,8 @@ async function runLocalReviewWithLogging(input) {
58
36
  prBody: 'Local self-review before opening a pull request.',
59
37
  prAuthor: author,
60
38
  files,
61
- reviewEngine,
62
- promptTemplate: reviewEngine === 'council'
63
- ? resolveLocalPromptTemplate(input.workspaceDir, repoConfig?.review?.prompt_file)
64
- : undefined,
65
39
  apiKeys: input.apiKeys,
66
- councilEnabled: repoConfig?.review?.council?.enabled !== false,
67
- councilModelsEnabled: parseCouncilModelsEnabled(process.env.COUNCIL_MODELS_ENABLED),
40
+ reviewModelsEnabled: getDefaultReviewModels(),
68
41
  lowPriorityFindingPlacement: 'inline',
69
42
  workspaceDir: input.workspaceDir,
70
43
  onProgress: input.onProgress,