@doist/doistbot-cli 1.0.5 → 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 (53) hide show
  1. package/dist/actions/auth.js +55 -21
  2. package/dist/actions/doctor.js +13 -6
  3. package/dist/actions/review.js +75 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +34 -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 +257 -176
  16. package/sandbox/dist/core/tracing-setup.js +15 -0
  17. package/sandbox/dist/core/tracing.js +71 -0
  18. package/sandbox/dist/main.js +4 -1
  19. package/sandbox/dist/providers/credentials.js +12 -2
  20. package/sandbox/dist/providers/gemini.js +5 -1
  21. package/sandbox/dist/providers/helpers.js +12 -17
  22. package/sandbox/dist/providers/index.js +4 -0
  23. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  24. package/sandbox/dist/providers/pi-review.js +12 -1
  25. package/sandbox/dist/tasks/chat/chat.js +161 -101
  26. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  27. package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
  28. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  29. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  30. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +31 -8
  31. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  32. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  33. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  34. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  35. package/sandbox/dist/tasks/issue-triage/pr-creator.js +3 -2
  36. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  37. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  38. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  39. package/sandbox/dist/tasks/issue-triage/triage.js +288 -418
  40. package/sandbox/dist/tasks/review/check-run.js +2 -1
  41. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  42. package/sandbox/dist/tasks/review/engines/dedupe.js +5 -4
  43. package/sandbox/dist/tasks/review/engines/multi-focus.js +40 -9
  44. package/sandbox/dist/tasks/review/engines/shared.js +8 -6
  45. package/sandbox/dist/tasks/review/github-comment-format.js +64 -10
  46. package/sandbox/dist/tasks/review/local.js +2 -4
  47. package/sandbox/dist/tasks/review/review-summary.js +45 -7
  48. package/sandbox/dist/tasks/review/review.js +38 -24
  49. package/sandbox/dist/tasks/review/runner.js +23 -2
  50. package/sandbox/dist/tasks/review/summary-model.js +164 -17
  51. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  52. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  53. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
@@ -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' });
@@ -4,6 +4,7 @@ import { logger } from '../../core/logging.js';
4
4
  import { invokePiPrompt } from '../../core/pi.js';
5
5
  import { WORKSPACE_DIR } from '../../core/shared.js';
6
6
  import { resolveReviewProviderCredential } from '../../providers/credentials.js';
7
+ import { normalizeGithubPriorityBadges } from './github-comment-format.js';
7
8
  import { stripReviewFeedbackFooter } from './review-feedback.js';
8
9
  // Roughly 6k tokens: large enough for substantive PR discussions while still
9
10
  // keeping conversation context secondary to the diff.
@@ -36,7 +37,7 @@ function shouldMaskPrAuthor(login, prAuthor) {
36
37
  return Boolean(normalizedLogin && normalizedPrAuthor && normalizedLogin === normalizedPrAuthor);
37
38
  }
38
39
  function cleanBody(body) {
39
- return stripReviewFeedbackFooter(body);
40
+ return normalizeGithubPriorityBadges(stripReviewFeedbackFooter(body));
40
41
  }
41
42
  function toIssueCommentEntry(comment) {
42
43
  const author = comment.user?.login ?? 'unknown';
@@ -7,6 +7,7 @@ import { isDedupeResponse } from '../../../providers/schemas.js';
7
7
  const DEDUPE_TIMEOUT_MS = 5 * 60 * 1000;
8
8
  const DEDUPE_PROVIDER = 'gemini';
9
9
  const DEDUPE_MODEL_BY_PI_PROVIDER = {
10
+ google: 'gemini-3-flash-preview',
10
11
  openrouter: 'google/gemini-3-flash-preview',
11
12
  };
12
13
  const DEDUPE_THINKING_LEVEL = 'medium';
@@ -116,7 +117,7 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
116
117
  if (!availableModels.includes(DEDUPE_PROVIDER)) {
117
118
  logger.error('Multi-focus dedupe unavailable because Gemini is not enabled', {
118
119
  provider: DEDUPE_PROVIDER,
119
- model: DEDUPE_MODEL_BY_PI_PROVIDER.openrouter,
120
+ model: DEDUPE_MODEL_BY_PI_PROVIDER.google,
120
121
  });
121
122
  throw new Error('Multi-focus dedupe requires Gemini to be enabled');
122
123
  }
@@ -124,16 +125,16 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
124
125
  if (!credential) {
125
126
  logger.error('Multi-focus dedupe unavailable because Gemini review credential is unavailable', {
126
127
  provider: DEDUPE_PROVIDER,
127
- model: DEDUPE_MODEL_BY_PI_PROVIDER.openrouter,
128
+ model: DEDUPE_MODEL_BY_PI_PROVIDER.google,
128
129
  });
129
130
  throw new Error('Multi-focus dedupe requires a Gemini review credential');
130
131
  }
131
- const piProviderId = credential.piProviderId ?? 'openrouter';
132
+ const piProviderId = credential.piProviderId ?? 'google';
132
133
  const providerConfig = {
133
134
  provider: DEDUPE_PROVIDER,
134
135
  apiKey: credential.apiKey,
135
136
  ...(credential.piProviderId ? { piProviderId: credential.piProviderId } : {}),
136
- modelName: DEDUPE_MODEL_BY_PI_PROVIDER[piProviderId] ?? DEDUPE_MODEL_BY_PI_PROVIDER.openrouter,
137
+ modelName: DEDUPE_MODEL_BY_PI_PROVIDER[piProviderId] ?? DEDUPE_MODEL_BY_PI_PROVIDER.google,
137
138
  };
138
139
  for (let attempt = 1; attempt <= DEDUPE_MAX_ATTEMPTS; attempt += 1) {
139
140
  let result;
@@ -4,16 +4,25 @@ import { buildReviewOutputFindings } from '../finding-output.js';
4
4
  import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus-prompt.js';
5
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,
25
+ openRouterSessionId,
17
26
  onProgress,
18
27
  });
19
28
  if (!result) {
@@ -94,6 +103,10 @@ function collectReviewObservations(focusRuns) {
94
103
  }
95
104
  return observations.slice(0, 8);
96
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
+ }
97
110
  function formatSummaryOnlyFinding(finding) {
98
111
  const severity = parseSeverity(finding.body);
99
112
  const bodyWithoutSeverity = finding.body.replace(/^\s*\[P\d\]\s*/i, '').trim();
@@ -115,10 +128,24 @@ function appendSummaryOnlyFindings(summary, summaryFindings) {
115
128
  const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
116
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>`;
117
130
  }
118
- async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryCredential, 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, }) {
119
138
  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
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
122
149
  ? null
123
150
  : await summarizeReviewFindings({
124
151
  credential: summaryCredential,
@@ -127,13 +154,14 @@ async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, li
127
154
  prBody,
128
155
  findings,
129
156
  reviewObservations,
157
+ fallbackModels: summaryFallbackModels,
130
158
  });
131
159
  const summaryWithoutNotes = synthesizedSummary ||
132
- (comments.length === 0 && summaryFindings.length > 0
160
+ (comments.length === 0 && hasReviewFindings
133
161
  ? buildNoInlineSummaryWithNotes(prTitle)
134
162
  : buildFallbackSummary({
135
163
  prTitle,
136
- findings,
164
+ findings: inlineCommentFindings,
137
165
  }));
138
166
  return {
139
167
  summary: appendSummaryOnlyFindings(summaryWithoutNotes, summaryFindings),
@@ -158,13 +186,14 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
158
186
  providerFailures: [],
159
187
  };
160
188
  }
161
- 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({
162
190
  focus,
163
191
  model,
164
192
  basePrompt: prompt,
165
193
  standardsSection,
166
194
  apiKeys,
167
195
  workspaceDir: ctx.workspaceDir,
196
+ openRouterSessionId: ctx.openRouterSessionId,
168
197
  onProgress,
169
198
  }))));
170
199
  const successfulRuns = focusRuns.filter((run) => run.status === 'succeeded');
@@ -194,7 +223,7 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
194
223
  findings: allFindings,
195
224
  availableModels,
196
225
  workspaceDir: ctx.workspaceDir,
197
- geminiCredential: ctx.geminiCredential,
226
+ geminiCredential: apiKeys.gemini,
198
227
  });
199
228
  }
200
229
  catch (error) {
@@ -204,6 +233,7 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
204
233
  });
205
234
  }
206
235
  const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
236
+ const geminiSummaryFallback = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
207
237
  const review = await buildMultiFocusReviewOutput({
208
238
  inlineFindings: routedFindings.inlineFindings,
209
239
  summaryFindings: routedFindings.summaryFindings,
@@ -211,7 +241,8 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
211
241
  prTitle,
212
242
  prBody,
213
243
  reviewObservations,
214
- summaryCredential: apiKeys.gemini,
244
+ summaryCredential: apiKeys.zai,
245
+ summaryFallbackModels: geminiSummaryFallback ? [geminiSummaryFallback] : [],
215
246
  workspaceDir: ctx.workspaceDir,
216
247
  });
217
248
  logger.info('Multi-focus review completed', {
@@ -3,11 +3,8 @@ import { getProvider } from '../../../providers/index.js';
3
3
  import { getReviewThinkingLevel } from '../thinking-level.js';
4
4
  export function getApiKeys(ctx) {
5
5
  const apiKeys = {};
6
- if (ctx.reviewModelsEnabled.includes('gemini')) {
7
- apiKeys.gemini = ctx.geminiCredential;
8
- }
9
- if (ctx.reviewModelsEnabled.includes('openai')) {
10
- apiKeys.openai = ctx.openaiApiKey;
6
+ for (const model of ctx.reviewModelsEnabled) {
7
+ apiKeys[model] = ctx.apiKeys[model];
11
8
  }
12
9
  return apiKeys;
13
10
  }
@@ -17,6 +14,10 @@ export function getAvailableModels(apiKeys) {
17
14
  models.push('gemini');
18
15
  if (hasReviewProviderCredential(apiKeys.openai))
19
16
  models.push('openai');
17
+ if (hasReviewProviderCredential(apiKeys.deepseek))
18
+ models.push('deepseek');
19
+ if (hasReviewProviderCredential(apiKeys.zai))
20
+ models.push('zai');
20
21
  return models;
21
22
  }
22
23
  export function getProviderFailures(results) {
@@ -27,7 +28,7 @@ export function getProviderFailures(results) {
27
28
  error: result.error,
28
29
  }));
29
30
  }
30
- export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, onProgress, }) {
31
+ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir, openRouterSessionId, onProgress, }) {
31
32
  const credential = getReviewProviderCredential(apiKeys, model);
32
33
  if (!credential) {
33
34
  return null;
@@ -37,6 +38,7 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
37
38
  ...credential,
38
39
  workspaceDir,
39
40
  thinkingLevel: getReviewThinkingLevel(model),
41
+ openRouterSessionId,
40
42
  onProgress,
41
43
  };
42
44
  try {
@@ -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,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,7 @@ 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: getDefaultReviewModels(),
43
41
  lowPriorityFindingPlacement: 'inline',
44
42
  workspaceDir: input.workspaceDir,
45
43
  onProgress: input.onProgress,
@@ -51,7 +51,7 @@ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR
51
51
  Respond with only valid JSON in this exact shape:
52
52
  {"summary":"<your summary>","comments":[]}`;
53
53
  }
54
- export async function summarizeReviewFindings({ credential, prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
54
+ export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
55
55
  const prompt = buildReviewSummaryPrompt({
56
56
  prTitle,
57
57
  prBody,
@@ -62,15 +62,53 @@ export async function summarizeReviewFindings({ credential, prTitle, prBody, fin
62
62
  findingsCount: findings.length,
63
63
  reviewObservationsCount: reviewObservations?.length ?? 0,
64
64
  });
65
- const result = await requestReview({ credential, prompt, workspaceDir });
66
- const summary = result?.summary?.trim() ?? '';
67
- if (!summary) {
68
- logger.warn('Review summary model returned an empty summary', {
65
+ const summaryModels = [
66
+ {
67
+ provider,
68
+ credential,
69
+ ...(modelName ? { modelName } : {}),
70
+ },
71
+ ...fallbackModels,
72
+ ];
73
+ for (const [index, summaryModel] of summaryModels.entries()) {
74
+ const result = await requestReview({
75
+ credential: summaryModel.credential,
76
+ provider: summaryModel.provider,
77
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
78
+ prompt,
79
+ workspaceDir,
80
+ });
81
+ if (result.status === 'failed') {
82
+ logger.warn('Review summary synthesis failed', {
83
+ findingsCount: findings.length,
84
+ reviewObservationsCount: reviewObservations?.length ?? 0,
85
+ failureReason: result.reason,
86
+ provider: summaryModel.provider,
87
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
88
+ attempt: index + 1,
89
+ attemptsTotal: summaryModels.length,
90
+ ...(result.error ? { error: result.error } : {}),
91
+ ...(result.timedOut !== undefined ? { timedOut: result.timedOut } : {}),
92
+ ...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
93
+ ...(result.outputChars !== undefined ? { outputChars: result.outputChars } : {}),
94
+ });
95
+ continue;
96
+ }
97
+ const summary = result.summary.trim();
98
+ logger.info('Review summary synthesis succeeded', {
69
99
  findingsCount: findings.length,
100
+ reviewObservationsCount: reviewObservations?.length ?? 0,
101
+ provider: summaryModel.provider,
102
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
103
+ attempt: index + 1,
104
+ attemptsTotal: summaryModels.length,
105
+ durationMs: result.durationMs,
106
+ outputChars: result.outputChars,
107
+ summaryChars: summary.length,
70
108
  });
71
- return null;
109
+ return summary;
72
110
  }
73
- return summary;
111
+ return null;
74
112
  }
75
113
  export function buildFallbackSummary({ prTitle, findings, }) {
76
114
  const safeTitle = prTitle.trim();
@@ -1,23 +1,18 @@
1
1
  import { performance } from 'node:perf_hooks';
2
- import { parseReviewModelsEnabled as parseConfiguredReviewModelsEnabled } from 'doistbot-repo-config';
2
+ import { getDefaultReviewModels } from 'doistbot-repo-config';
3
3
  import { recordTaskMetrics } from '../../core/datadog-metrics.js';
4
4
  import { logger } from '../../core/logging.js';
5
- import { createOpenRouterCredential } from '../../providers/credentials.js';
6
5
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
6
+ import { createOpenRouterCredential } from '../../providers/credentials.js';
7
7
  import { getReviewCheckRunTitle, isAnotherReviewInProgress, postReviewFailureCheckRun, postReviewInProgressCheckRun, postReviewSuccessCheckRun, postStaleReviewPointerIfHeadMoved, } from './check-run.js';
8
8
  import { fetchReviewConversationContext } from './conversation-context.js';
9
9
  import {} from './diff.js';
10
10
  import { formatReviewForTerminal } from './format.js';
11
- import { formatGithubReviewComments } from './github-comment-format.js';
11
+ import { formatGithubReviewComments, formatGithubReviewSummary } from './github-comment-format.js';
12
12
  import { appendReviewFeedbackLink } from './review-feedback.js';
13
13
  import { runSharedReview } from './runner.js';
14
14
  const REVIEW_EVENT = 'COMMENT';
15
- const REQUIRED_PRODUCTION_REVIEW_MODELS = ['gemini', 'openai'];
16
- function parseReviewModelsEnabled() {
17
- // Parsed/defaulted in webhook-handler before being passed as task env.
18
- const raw = process.env.REVIEW_MODELS_ENABLED;
19
- return parseConfiguredReviewModelsEnabled(raw, { defaultModels: [] });
20
- }
15
+ const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
21
16
  function optionalEnv(name) {
22
17
  const value = process.env[name]?.trim();
23
18
  return value ? value : undefined;
@@ -29,16 +24,27 @@ function parseContext() {
29
24
  branch: requiredEnv('BRANCH'),
30
25
  baseBranch: requiredEnv('BASE_BRANCH'),
31
26
  headSha: optionalEnv('HEAD_SHA'),
27
+ geminiApiKey: optionalEnv('GEMINI_API_KEY'),
32
28
  openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
33
29
  openaiApiKey: optionalEnv('OPENAI_KEY'),
34
- reviewModelsEnabled: parseReviewModelsEnabled(),
30
+ reviewModelsEnabled: [...REQUIRED_PRODUCTION_REVIEW_MODELS],
31
+ reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
32
+ reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
35
33
  dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
36
34
  };
37
35
  }
36
+ function optionalNumberEnv(name) {
37
+ const value = optionalEnv(name);
38
+ if (value === undefined) {
39
+ return undefined;
40
+ }
41
+ const parsed = Number(value);
42
+ return Number.isFinite(parsed) ? parsed : undefined;
43
+ }
38
44
  function getConversationSummaryApiKeys(ctx) {
39
45
  const apiKeys = {};
40
- if (ctx.reviewModelsEnabled.includes('gemini') && ctx.openRouterApiKey) {
41
- apiKeys.gemini = createOpenRouterCredential(ctx.openRouterApiKey);
46
+ if (ctx.reviewModelsEnabled.includes('gemini') && ctx.geminiApiKey) {
47
+ apiKeys.gemini = ctx.geminiApiKey;
42
48
  }
43
49
  if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
44
50
  apiKeys.openai = ctx.openaiApiKey;
@@ -80,6 +86,7 @@ async function main() {
80
86
  sha: ctx.headSha,
81
87
  pullNumber: ctx.prNumber,
82
88
  deliveryId: ctx.deliveryId,
89
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
83
90
  };
84
91
  if (input.conclusion === 'success') {
85
92
  await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
@@ -119,16 +126,18 @@ async function main() {
119
126
  // There's also a small TOCTOU window between this check and the
120
127
  // in_progress post — same race the prior comment-based lock had,
121
128
  // and same bound (one duplicate review when it fires).
122
- const otherInProgress = await isAnotherReviewInProgress({
123
- octokit: checkRunOctokit,
124
- owner: ctx.owner,
125
- repo: ctx.repo,
126
- sha: ctx.headSha,
127
- deliveryId: ctx.deliveryId,
128
- });
129
- if (otherInProgress) {
130
- logger.info('Another review is already in progress on this SHA, skipping');
131
- return;
129
+ if (!ctx.reviewDispatchLockAcquired) {
130
+ const otherInProgress = await isAnotherReviewInProgress({
131
+ octokit: checkRunOctokit,
132
+ owner: ctx.owner,
133
+ repo: ctx.repo,
134
+ sha: ctx.headSha,
135
+ deliveryId: ctx.deliveryId,
136
+ });
137
+ if (otherInProgress) {
138
+ logger.info('Another review is already in progress on this SHA, skipping');
139
+ return;
140
+ }
132
141
  }
133
142
  await postReviewInProgressCheckRun({
134
143
  octokit: checkRunOctokit,
@@ -137,6 +146,7 @@ async function main() {
137
146
  sha: ctx.headSha,
138
147
  pullNumber: ctx.prNumber,
139
148
  deliveryId: ctx.deliveryId,
149
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
140
150
  });
141
151
  }
142
152
  else if (ctx.dryRun) {
@@ -184,8 +194,12 @@ async function main() {
184
194
  }
185
195
  reviewMetricModels = [...ctx.reviewModelsEnabled];
186
196
  const reviewApiKeys = {};
197
+ if (ctx.geminiApiKey) {
198
+ reviewApiKeys.gemini = ctx.geminiApiKey;
199
+ }
187
200
  if (ctx.openRouterApiKey) {
188
- reviewApiKeys.gemini = createOpenRouterCredential(ctx.openRouterApiKey);
201
+ reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
202
+ reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
189
203
  }
190
204
  if (ctx.openaiApiKey) {
191
205
  reviewApiKeys.openai = ctx.openaiApiKey;
@@ -272,7 +286,7 @@ async function submitReview({ octokit, owner, repo, pull_number, deliveryId, rev
272
286
  event: REVIEW_EVENT,
273
287
  };
274
288
  if (review.summary?.length) {
275
- const summaryWithFeedbackLink = appendReviewFeedbackLink(review.summary, {
289
+ const summaryWithFeedbackLink = appendReviewFeedbackLink(formatGithubReviewSummary(review.summary), {
276
290
  owner,
277
291
  repo,
278
292
  prNumber: pull_number,
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'crypto';
1
2
  import fs from 'fs';
2
3
  import { logger } from '../../core/logging.js';
3
4
  import { hasReviewProviderCredential } from '../../providers/credentials.js';
@@ -8,6 +9,7 @@ import { buildMultiFocusSharedPrompt } from './multi-focus-prompt.js';
8
9
  import { loadStandardsSection } from './prompt.js';
9
10
  const REVIEW_GUIDANCE_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
10
11
  const MASKED_PR_AUTHOR_LABEL = 'PR author';
12
+ const OPENROUTER_SESSION_REPO_SEGMENT_MAX_LENGTH = 96;
11
13
  function getGuidanceFilePresence(workspaceDir) {
12
14
  return Object.fromEntries(REVIEW_GUIDANCE_FILES.map((fileName) => [
13
15
  fileName,
@@ -53,6 +55,20 @@ function getEnabledReviewModels({ apiKeys, reviewModelsEnabled, }) {
53
55
  function getMissingRequiredReviewModels(availableModels, requiredReviewModels = []) {
54
56
  return requiredReviewModels.filter((model) => !availableModels.includes(model));
55
57
  }
58
+ function toOpenRouterSessionSegment(value, fallback) {
59
+ const normalized = value
60
+ .trim()
61
+ .toLowerCase()
62
+ .replace(/[^a-z0-9._-]+/g, '-')
63
+ .replace(/^-+|-+$/g, '')
64
+ .slice(0, OPENROUTER_SESSION_REPO_SEGMENT_MAX_LENGTH);
65
+ return normalized || fallback;
66
+ }
67
+ export function buildOpenRouterReviewSessionId({ owner, repo, prNumber, prompt, }) {
68
+ const repoSegment = toOpenRouterSessionSegment(`${owner}-${repo}`, 'repo');
69
+ const promptHash = createHash('sha256').update(prompt).digest('hex').slice(0, 32);
70
+ return `doistbot-review-${repoSegment}-pr-${prNumber}-${promptHash}`;
71
+ }
56
72
  export async function runSharedReview(input) {
57
73
  const { annotatedFiles, lineLookup } = buildAnnotatedDiff(input.files);
58
74
  if (annotatedFiles.length === 0) {
@@ -134,9 +150,14 @@ export async function runSharedReview(input) {
134
150
  repo: input.repo,
135
151
  prNumber: input.prNumber,
136
152
  workspaceDir: input.workspaceDir,
137
- geminiCredential: input.apiKeys.gemini,
138
- openaiApiKey: input.apiKeys.openai,
153
+ apiKeys: input.apiKeys,
139
154
  reviewModelsEnabled: input.reviewModelsEnabled,
155
+ openRouterSessionId: buildOpenRouterReviewSessionId({
156
+ owner: input.owner,
157
+ repo: input.repo,
158
+ prNumber: input.prNumber,
159
+ prompt,
160
+ }),
140
161
  };
141
162
  const { review, availableModels, metricModels, mode, providerFailures } = await runMultiFocusReviewEngine({
142
163
  ctx: engineContext,