@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
@@ -1,13 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import { getCustomPrompt } from 'doistbot-repo-config';
5
4
  import { logger } from '../../core/logging.js';
6
- import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
7
5
  const MAX_BODY_CHARS = 4000;
8
6
  let cachedStandardsSection = null;
9
7
  const REVIEW_TASK_DIR = path.dirname(fileURLToPath(import.meta.url));
10
- const PROMPT_FILE = path.resolve(REVIEW_TASK_DIR, '../../../review-prompt.md');
11
8
  const STANDARDS_PROMPTS_DIR = path.resolve(REVIEW_TASK_DIR, '../../../src/review/prompts');
12
9
  function loadOutlineMap() {
13
10
  try {
@@ -51,29 +48,6 @@ export function loadStandardsSection() {
51
48
  return '';
52
49
  }
53
50
  }
54
- export async function resolvePromptTemplate({ octokit, owner, repo, promptFile, }) {
55
- if (promptFile) {
56
- const customPrompt = await getCustomPrompt({
57
- octokit,
58
- owner,
59
- repo,
60
- path: promptFile,
61
- logger,
62
- });
63
- if (customPrompt) {
64
- logger.info('Using custom prompt file', { path: promptFile });
65
- return customPrompt;
66
- }
67
- logger.info('Custom prompt not found, using default', { path: promptFile });
68
- }
69
- return loadDefaultReviewPromptTemplate();
70
- }
71
- export function loadDefaultReviewPromptTemplate() {
72
- return loadPromptTemplateFile({
73
- fileName: PROMPT_FILE,
74
- promptLabel: 'review',
75
- });
76
- }
77
51
  function formatPrConversation(prConversation) {
78
52
  const promptText = prConversation?.promptText.trim();
79
53
  if (promptText) {
@@ -89,38 +63,17 @@ export function buildReviewPromptVariables({ repoOwner, repoName, prNumber, prTi
89
63
  const condensedBody = safeBody
90
64
  ? safeBody.slice(0, MAX_BODY_CHARS)
91
65
  : 'No PR description was provided.';
92
- const conversation = formatPrConversation(prConversation);
93
- const repoFull = `${repoOwner}/${repoName}`;
94
66
  const fullDiffSection = diffSection + (truncated ? '\n[Diff was truncated to fit token limits]' : '');
95
67
  return {
96
- REPO_FULL: repoFull,
68
+ REPO_FULL: `${repoOwner}/${repoName}`,
97
69
  PR_NUMBER: String(prNumber),
98
70
  PR_TITLE: prTitle || 'Untitled',
99
71
  PR_AUTHOR: prAuthor,
100
72
  BASE_REF: baseRef,
101
73
  HEAD_REF: headRef,
102
74
  PR_BODY: condensedBody,
103
- PR_CONVERSATION: conversation,
75
+ PR_CONVERSATION: formatPrConversation(prConversation),
104
76
  DIFF_SECTION: fullDiffSection,
105
77
  STANDARDS_SECTION: standardsSection,
106
78
  };
107
79
  }
108
- /**
109
- * Builds the final prompt sent to the review summary model, combining metadata, description, and annotated diff.
110
- */
111
- export function buildPrompt({ promptTemplate, repoOwner, repoName, prNumber, prTitle, prBody, prConversation, prAuthor, baseRef, headRef, diffSection, truncated, standardsSection, }) {
112
- return fillPromptTemplate(promptTemplate, buildReviewPromptVariables({
113
- repoOwner,
114
- repoName,
115
- prNumber,
116
- prTitle,
117
- prBody,
118
- prConversation,
119
- prAuthor,
120
- baseRef,
121
- headRef,
122
- diffSection,
123
- truncated,
124
- standardsSection,
125
- }));
126
- }
@@ -0,0 +1,120 @@
1
+ import { logger } from '../../core/logging.js';
2
+ import { requestReviewSummary } from './summary-model.js';
3
+ export const MAX_SUMMARY_BODY_CHARS = 2000;
4
+ const MAX_REVIEW_OBSERVATION_CHARS = 500;
5
+ export function buildReviewSummaryPrompt({ prTitle, prBody, findings, reviewObservations = [], }) {
6
+ const safeTitle = prTitle.trim() || 'Untitled';
7
+ const safeBody = prBody.trim();
8
+ const condensedBody = safeBody
9
+ ? safeBody.slice(0, MAX_SUMMARY_BODY_CHARS)
10
+ : 'No PR description was provided.';
11
+ const findingsBlock = findings.length > 0
12
+ ? findings
13
+ .map((finding) => `<review-finding>\n${finding.trim()}\n</review-finding>`)
14
+ .join('\n\n')
15
+ : 'No issues were flagged in the inline comments.';
16
+ const observationsBlock = reviewObservations.length > 0
17
+ ? reviewObservations
18
+ .map((observation) => observation.trim())
19
+ .filter(Boolean)
20
+ .slice(0, 8)
21
+ .map((observation) => `<review-observation>\n${observation.slice(0, MAX_REVIEW_OBSERVATION_CHARS)}\n</review-observation>`)
22
+ .join('\n\n') || 'No additional review observations were captured.'
23
+ : 'No additional review observations were captured.';
24
+ return `You are an expert code review summarizer for the Doist engineering team.
25
+
26
+ <pr_title>
27
+ ${safeTitle}
28
+ </pr_title>
29
+
30
+ <pr_description>
31
+ ${condensedBody}
32
+ </pr_description>
33
+
34
+ <review_findings>
35
+ ${findingsBlock}
36
+ </review_findings>
37
+
38
+ <review_observations>
39
+ ${observationsBlock}
40
+ </review_observations>
41
+
42
+ Write the complete GitHub review summary body. Synthesize the findings into grouped themes—do not mirror every inline comment one-for-one or reference the reviewers, models, focus passes, or review observations. Use review observations only as context for the overview. Do not mention issues not present in the <review-finding> blocks. If there are no <review-finding> blocks, state that no issues were flagged.
43
+
44
+ Structure:
45
+ - Start with one short sentence giving a concise overview of what changed.
46
+ - If issues exist, add a blank line, then the exact heading "Few things worth tightening:", then 1-4 concise bullets grouping what to improve.
47
+ - If no issues exist, do not add a heading or bullets.
48
+
49
+ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR is large and the second sentence adds useful context. Use a kind, warm, and supportive tone while staying concrete, constructive, and pragmatic. Avoid generic praise or flattery. Bullets should be short fragments or single sentences, not paragraphs. Do not include any <review-finding> or <review-observation> tags in the output.
50
+
51
+ Respond with only valid JSON in this exact shape:
52
+ {"summary":"<your summary>","comments":[]}`;
53
+ }
54
+ export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
55
+ const prompt = buildReviewSummaryPrompt({
56
+ prTitle,
57
+ prBody,
58
+ findings,
59
+ reviewObservations,
60
+ });
61
+ logger.info('Summarizing review findings with the review summary model', {
62
+ findingsCount: findings.length,
63
+ reviewObservationsCount: reviewObservations?.length ?? 0,
64
+ });
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', {
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,
108
+ });
109
+ return summary;
110
+ }
111
+ return null;
112
+ }
113
+ export function buildFallbackSummary({ prTitle, findings, }) {
114
+ const safeTitle = prTitle.trim();
115
+ const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
116
+ if (findings.length === 0) {
117
+ return `${base} No issues were flagged in the reviewed diff.`;
118
+ }
119
+ return `${base}\n\nFew things worth tightening:\n- See the inline comments for the issues found in the review.`;
120
+ }
@@ -1,61 +1,54 @@
1
1
  import { performance } from 'node:perf_hooks';
2
- import { parseCouncilModelsEnabled as parseConfiguredCouncilModelsEnabled } 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
5
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
6
- import { resolvePrAuthorGreetingName, shouldExcludePrAuthorFromReviewSummary, } from './author-profile.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';
12
- import { resolvePromptTemplate } from './prompt.js';
13
- import { parseReviewEngine } from './review-engine.js';
11
+ import { formatGithubReviewComments, formatGithubReviewSummary } from './github-comment-format.js';
14
12
  import { appendReviewFeedbackLink } from './review-feedback.js';
15
13
  import { runSharedReview } from './runner.js';
16
14
  const REVIEW_EVENT = 'COMMENT';
17
- function parseCouncilEnabled() {
18
- // Council is enabled by default unless explicitly disabled
19
- return process.env.COUNCIL_ENABLED?.toLowerCase() !== 'false';
20
- }
21
- function parseCouncilModelsEnabled() {
22
- // Parsed/defaulted in webhook-handler before being passed as task env.
23
- const raw = process.env.COUNCIL_MODELS_ENABLED;
24
- return parseConfiguredCouncilModelsEnabled(raw, { defaultModels: [] });
25
- }
15
+ const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
26
16
  function optionalEnv(name) {
27
17
  const value = process.env[name]?.trim();
28
18
  return value ? value : undefined;
29
19
  }
30
20
  function parseContext() {
31
21
  const base = parseBaseContext();
32
- const requestedReviewEngine = parseReviewEngine(process.env.REVIEW_ENGINE);
33
22
  return {
34
23
  ...base,
35
24
  branch: requiredEnv('BRANCH'),
36
25
  baseBranch: requiredEnv('BASE_BRANCH'),
37
26
  headSha: optionalEnv('HEAD_SHA'),
38
27
  geminiApiKey: optionalEnv('GEMINI_API_KEY'),
28
+ openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
39
29
  openaiApiKey: optionalEnv('OPENAI_KEY'),
40
- anthropicApiKey: optionalEnv('ANTHROPIC_KEY'),
41
- requestedReviewEngine,
42
- reviewEngine: requestedReviewEngine,
43
- councilEnabled: parseCouncilEnabled(),
44
- councilModelsEnabled: parseCouncilModelsEnabled(),
30
+ reviewModelsEnabled: [...REQUIRED_PRODUCTION_REVIEW_MODELS],
31
+ reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
32
+ reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
45
33
  dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
46
34
  };
47
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
+ }
48
44
  function getConversationSummaryApiKeys(ctx) {
49
45
  const apiKeys = {};
50
- if (ctx.councilModelsEnabled.includes('gemini') && ctx.geminiApiKey) {
46
+ if (ctx.reviewModelsEnabled.includes('gemini') && ctx.geminiApiKey) {
51
47
  apiKeys.gemini = ctx.geminiApiKey;
52
48
  }
53
- if (ctx.councilModelsEnabled.includes('openai') && ctx.openaiApiKey) {
49
+ if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
54
50
  apiKeys.openai = ctx.openaiApiKey;
55
51
  }
56
- if (ctx.councilModelsEnabled.includes('anthropic') && ctx.anthropicApiKey) {
57
- apiKeys.anthropic = ctx.anthropicApiKey;
58
- }
59
52
  return apiKeys;
60
53
  }
61
54
  async function main() {
@@ -69,17 +62,8 @@ async function main() {
69
62
  logger.setBaseContext(logContext);
70
63
  logger.info('Review task started', {
71
64
  dryRun: ctx.dryRun,
72
- requestedReviewEngine: ctx.requestedReviewEngine,
73
- reviewEngine: ctx.reviewEngine,
74
- councilEnabled: ctx.councilEnabled,
75
- councilModelsEnabled: ctx.councilModelsEnabled,
65
+ reviewModelsEnabled: ctx.reviewModelsEnabled,
76
66
  });
77
- if (ctx.requestedReviewEngine !== ctx.reviewEngine) {
78
- logger.warn('Requested review engine is not implemented yet, falling back', {
79
- requestedReviewEngine: ctx.requestedReviewEngine,
80
- effectiveReviewEngine: ctx.reviewEngine,
81
- });
82
- }
83
67
  const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
84
68
  // GitHub's Check Runs API requires App-installation auth; calling it with
85
69
  // writeOctokit (the doistbot PAT) returns 403. Centralize the choice here
@@ -102,6 +86,7 @@ async function main() {
102
86
  sha: ctx.headSha,
103
87
  pullNumber: ctx.prNumber,
104
88
  deliveryId: ctx.deliveryId,
89
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
105
90
  };
106
91
  if (input.conclusion === 'success') {
107
92
  await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
@@ -141,16 +126,18 @@ async function main() {
141
126
  // There's also a small TOCTOU window between this check and the
142
127
  // in_progress post — same race the prior comment-based lock had,
143
128
  // and same bound (one duplicate review when it fires).
144
- const otherInProgress = await isAnotherReviewInProgress({
145
- octokit: checkRunOctokit,
146
- owner: ctx.owner,
147
- repo: ctx.repo,
148
- sha: ctx.headSha,
149
- deliveryId: ctx.deliveryId,
150
- });
151
- if (otherInProgress) {
152
- logger.info('Another review is already in progress on this SHA, skipping');
153
- 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
+ }
154
141
  }
155
142
  await postReviewInProgressCheckRun({
156
143
  octokit: checkRunOctokit,
@@ -159,6 +146,7 @@ async function main() {
159
146
  sha: ctx.headSha,
160
147
  pullNumber: ctx.prNumber,
161
148
  deliveryId: ctx.deliveryId,
149
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
162
150
  });
163
151
  }
164
152
  else if (ctx.dryRun) {
@@ -193,7 +181,6 @@ async function main() {
193
181
  ]);
194
182
  const pr = prResponse.data;
195
183
  const prAuthor = pr.user?.login ?? 'unknown';
196
- const excludePrAuthorFromSummary = shouldExcludePrAuthorFromReviewSummary(prAuthor);
197
184
  function loadPrConversation() {
198
185
  return fetchReviewConversationContext({
199
186
  octokit: readOctokit,
@@ -205,14 +192,18 @@ async function main() {
205
192
  workspaceDir: WORKSPACE_DIR,
206
193
  });
207
194
  }
208
- const promptTemplate = ctx.reviewEngine === 'council'
209
- ? await resolvePromptTemplate({
210
- octokit: readOctokit,
211
- owner: ctx.owner,
212
- repo: ctx.repo,
213
- })
214
- : undefined;
215
- reviewMetricModels = [...ctx.councilModelsEnabled];
195
+ reviewMetricModels = [...ctx.reviewModelsEnabled];
196
+ const reviewApiKeys = {};
197
+ if (ctx.geminiApiKey) {
198
+ reviewApiKeys.gemini = ctx.geminiApiKey;
199
+ }
200
+ if (ctx.openRouterApiKey) {
201
+ reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
202
+ reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
203
+ }
204
+ if (ctx.openaiApiKey) {
205
+ reviewApiKeys.openai = ctx.openaiApiKey;
206
+ }
216
207
  const reviewResult = await runSharedReview({
217
208
  owner: ctx.owner,
218
209
  repo: ctx.repo,
@@ -223,21 +214,10 @@ async function main() {
223
214
  prBody: pr.body ?? '',
224
215
  loadPrConversation,
225
216
  prAuthor,
226
- resolvePrAuthorGreetingName: () => resolvePrAuthorGreetingName({
227
- octokit: readOctokit,
228
- login: prAuthor,
229
- }),
230
- excludePrAuthorFromSummary,
231
217
  files: files,
232
- reviewEngine: ctx.reviewEngine,
233
- promptTemplate,
234
- apiKeys: {
235
- gemini: ctx.geminiApiKey,
236
- openai: ctx.openaiApiKey,
237
- anthropic: ctx.anthropicApiKey,
238
- },
239
- councilEnabled: ctx.councilEnabled,
240
- councilModelsEnabled: ctx.councilModelsEnabled,
218
+ apiKeys: reviewApiKeys,
219
+ reviewModelsEnabled: ctx.reviewModelsEnabled,
220
+ requiredReviewModels: REQUIRED_PRODUCTION_REVIEW_MODELS,
241
221
  workspaceDir: WORKSPACE_DIR,
242
222
  });
243
223
  reviewMetricModels = reviewResult.metricModels;
@@ -306,7 +286,7 @@ async function submitReview({ octokit, owner, repo, pull_number, deliveryId, rev
306
286
  event: REVIEW_EVENT,
307
287
  };
308
288
  if (review.summary?.length) {
309
- const summaryWithFeedbackLink = appendReviewFeedbackLink(review.summary, {
289
+ const summaryWithFeedbackLink = appendReviewFeedbackLink(formatGithubReviewSummary(review.summary), {
310
290
  owner,
311
291
  repo,
312
292
  prNumber: pull_number,
@@ -1,12 +1,15 @@
1
+ import { createHash } from 'crypto';
1
2
  import fs from 'fs';
2
3
  import { logger } from '../../core/logging.js';
3
- import { getReviewEngineRunner } from './engines/index.js';
4
+ import { hasReviewProviderCredential } from '../../providers/credentials.js';
5
+ import { runMultiFocusReviewEngine } from './engines/multi-focus.js';
4
6
  import {} from './engines/types.js';
5
7
  import { buildAnnotatedDiff, buildDiffSection } from './diff.js';
6
8
  import { buildMultiFocusSharedPrompt } from './multi-focus-prompt.js';
7
- import { buildPrompt, loadStandardsSection } from './prompt.js';
9
+ import { loadStandardsSection } from './prompt.js';
8
10
  const REVIEW_GUIDANCE_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
11
  const MASKED_PR_AUTHOR_LABEL = 'PR author';
12
+ const OPENROUTER_SESSION_REPO_SEGMENT_MAX_LENGTH = 96;
10
13
  function getGuidanceFilePresence(workspaceDir) {
11
14
  return Object.fromEntries(REVIEW_GUIDANCE_FILES.map((fileName) => [
12
15
  fileName,
@@ -42,25 +45,29 @@ function buildEnginePrompt({ input, diffSection, truncated, standardsSection, pr
42
45
  truncated,
43
46
  standardsSection,
44
47
  };
45
- if (input.reviewEngine === 'council') {
46
- const promptTemplate = input.promptTemplate;
47
- if (!promptTemplate) {
48
- throw new Error('Council review requires a prompt template');
49
- }
50
- return {
51
- prompt: buildPrompt({
52
- promptTemplate,
53
- ...sharedPromptArgs,
54
- }),
55
- promptTemplateLength: promptTemplate.length,
56
- };
57
- }
58
48
  return {
59
49
  prompt: buildMultiFocusSharedPrompt(sharedPromptArgs),
60
50
  };
61
51
  }
62
- function getEnabledReviewModels({ apiKeys, councilModelsEnabled, }) {
63
- return councilModelsEnabled.filter((model) => apiKeys[model]);
52
+ function getEnabledReviewModels({ apiKeys, reviewModelsEnabled, }) {
53
+ return reviewModelsEnabled.filter((model) => hasReviewProviderCredential(apiKeys[model]));
54
+ }
55
+ function getMissingRequiredReviewModels(availableModels, requiredReviewModels = []) {
56
+ return requiredReviewModels.filter((model) => !availableModels.includes(model));
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}`;
64
71
  }
65
72
  export async function runSharedReview(input) {
66
73
  const { annotatedFiles, lineLookup } = buildAnnotatedDiff(input.files);
@@ -85,35 +92,40 @@ export async function runSharedReview(input) {
85
92
  providerFailures: [],
86
93
  };
87
94
  }
88
- if (getEnabledReviewModels(input).length === 0) {
95
+ const configuredAvailableModels = getEnabledReviewModels(input);
96
+ if (configuredAvailableModels.length === 0) {
89
97
  logger.error('No enabled review models with API keys are available, skipping review', {
90
- review_engine: input.reviewEngine,
91
- council_models_enabled: input.councilModelsEnabled,
98
+ review_models_enabled: input.reviewModelsEnabled,
92
99
  });
93
100
  return {
94
101
  status: 'skipped',
95
102
  reason: 'no_models',
96
103
  availableModels: [],
97
- metricModels: [...input.councilModelsEnabled],
98
- mode: input.reviewEngine === 'multi_focus' ? 'multi_focus' : 'single',
104
+ metricModels: [...input.reviewModelsEnabled],
105
+ mode: 'multi_focus',
99
106
  providerFailures: [],
100
107
  };
101
108
  }
102
- const unresolvedPrAuthorGreetingNamePromise = input.excludePrAuthorFromSummary === true
103
- ? undefined
104
- : input.prAuthorGreetingName != null
105
- ? Promise.resolve(input.prAuthorGreetingName)
106
- : input.resolvePrAuthorGreetingName?.();
107
- const prAuthorGreetingNamePromise = unresolvedPrAuthorGreetingNamePromise?.catch((error) => {
108
- logger.warn('Failed to resolve PR author greeting name for review summary', {
109
- error: error instanceof Error ? error.message : String(error),
109
+ const missingRequiredModels = getMissingRequiredReviewModels(configuredAvailableModels, input.requiredReviewModels);
110
+ if (missingRequiredModels.length > 0) {
111
+ logger.error('Required review models with API keys are unavailable, skipping review', {
112
+ review_models_enabled: input.reviewModelsEnabled,
113
+ available_models: configuredAvailableModels,
114
+ missing_required_models: missingRequiredModels,
110
115
  });
111
- return undefined;
112
- });
116
+ return {
117
+ status: 'skipped',
118
+ reason: 'no_models',
119
+ availableModels: configuredAvailableModels,
120
+ metricModels: [...input.reviewModelsEnabled],
121
+ mode: 'multi_focus',
122
+ providerFailures: [],
123
+ };
124
+ }
113
125
  const prConversationPromise = input.loadPrConversation?.() ?? Promise.resolve(undefined);
114
126
  const prConversation = maskPrAuthorInConversation(await prConversationPromise, input.prAuthor);
115
127
  const standardsSection = loadStandardsSection();
116
- const { prompt, promptTemplateLength } = buildEnginePrompt({
128
+ const { prompt } = buildEnginePrompt({
117
129
  input,
118
130
  diffSection,
119
131
  truncated,
@@ -121,9 +133,8 @@ export async function runSharedReview(input) {
121
133
  prConversation,
122
134
  });
123
135
  logger.info('Review prompt prepared', {
124
- engine: input.reviewEngine,
136
+ engine: 'multi_focus',
125
137
  prompt_length: prompt.length,
126
- prompt_template_length: promptTemplateLength,
127
138
  pr_body_length: input.prBody.trim().slice(0, 4000).length,
128
139
  pr_conversation_length: prConversation?.promptText.trim().length ?? 0,
129
140
  diff_length: diffSection.length,
@@ -134,39 +145,33 @@ export async function runSharedReview(input) {
134
145
  guidance_files: getGuidanceFilePresence(input.workspaceDir),
135
146
  });
136
147
  input.onProgress?.({ type: 'prompt_prepared', fileCount: annotatedFiles.length });
137
- const runReviewEngine = getReviewEngineRunner(input.reviewEngine);
138
148
  const engineContext = {
139
149
  owner: input.owner,
140
150
  repo: input.repo,
141
151
  prNumber: input.prNumber,
142
152
  workspaceDir: input.workspaceDir,
143
- geminiApiKey: input.apiKeys.gemini,
144
- openaiApiKey: input.apiKeys.openai,
145
- anthropicApiKey: input.apiKeys.anthropic,
146
- reviewEngine: input.reviewEngine,
147
- councilEnabled: input.councilEnabled,
148
- councilModelsEnabled: input.councilModelsEnabled,
153
+ apiKeys: input.apiKeys,
154
+ reviewModelsEnabled: input.reviewModelsEnabled,
155
+ openRouterSessionId: buildOpenRouterReviewSessionId({
156
+ owner: input.owner,
157
+ repo: input.repo,
158
+ prNumber: input.prNumber,
159
+ prompt,
160
+ }),
149
161
  };
150
- const { review, availableModels, metricModels, mode, providerFailures } = await runReviewEngine({
162
+ const { review, availableModels, metricModels, mode, providerFailures } = await runMultiFocusReviewEngine({
151
163
  ctx: engineContext,
152
164
  prompt,
153
165
  standardsSection,
154
166
  lineLookup,
155
167
  prTitle: input.prTitle,
156
168
  prBody: input.prBody,
157
- prAuthor: input.prAuthor,
158
- prAuthorGreetingName: input.prAuthorGreetingName,
159
- resolvePrAuthorGreetingName: prAuthorGreetingNamePromise
160
- ? () => prAuthorGreetingNamePromise
161
- : undefined,
162
- excludePrAuthorFromSummary: input.excludePrAuthorFromSummary,
163
169
  lowPriorityFindingPlacement: input.lowPriorityFindingPlacement,
164
170
  onProgress: input.onProgress,
165
171
  });
166
172
  if (availableModels.length === 0) {
167
173
  logger.error('No enabled review models with API keys are available, skipping review', {
168
- review_engine: input.reviewEngine,
169
- council_models_enabled: input.councilModelsEnabled,
174
+ review_models_enabled: input.reviewModelsEnabled,
170
175
  });
171
176
  return {
172
177
  status: 'skipped',
@@ -200,7 +205,7 @@ export async function runSharedReview(input) {
200
205
  };
201
206
  }
202
207
  logger.info('Review completed', {
203
- engine: input.reviewEngine,
208
+ engine: 'multi_focus',
204
209
  mode,
205
210
  comments_count: review.comments.length,
206
211
  });