@doist/doistbot-cli 1.0.1 → 1.0.3

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 (54) hide show
  1. package/dist/actions/review-usage.js +20 -0
  2. package/dist/actions/review.js +126 -10
  3. package/dist/actions/setup.js +3 -2
  4. package/dist/index.js +18 -8
  5. package/dist/runtime.js +3 -3
  6. package/dist/update-check.js +1 -1
  7. package/package.json +6 -5
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  9. package/sandbox/dist/core/bot-logins.js +13 -0
  10. package/sandbox/dist/core/retry.js +29 -0
  11. package/sandbox/dist/core/shared.js +2 -2
  12. package/sandbox/dist/main.js +3 -0
  13. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -7
  14. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +157 -28
  15. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  21. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  22. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  23. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  24. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  25. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  26. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  27. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  28. package/sandbox/dist/tasks/issue-triage/triage.js +31 -10
  29. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  30. package/sandbox/dist/tasks/review/author-format.js +14 -0
  31. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  32. package/sandbox/dist/tasks/review/check-run.js +8 -1
  33. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  34. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  35. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  36. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  37. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  38. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  39. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  40. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  41. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  42. package/sandbox/dist/tasks/review/local.js +1 -0
  43. package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
  44. package/sandbox/dist/tasks/review/review.js +12 -2
  45. package/sandbox/dist/tasks/review/runner.js +36 -2
  46. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  47. package/sandbox/review-prompt.md +18 -1
  48. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
  49. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
  50. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
  51. package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
  52. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
  53. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
  54. package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +0 -13
@@ -1,8 +1,104 @@
1
1
  import { logger } from '../../../core/logging.js';
2
+ import { formatPrAuthorForGreeting, normalizePrAuthor } from '../author-format.js';
2
3
  import { requestReviewSummary } from '../summary-model.js';
3
4
  export const MAX_SUMMARY_BODY_CHARS = 2000;
4
- export function buildCouncilSummaryPrompt({ prTitle, prBody, findings, }) {
5
+ const MAX_REVIEW_OBSERVATION_CHARS = 500;
6
+ const REVIEW_SUMMARY_EMOJIS = ['😊', '😇', '🤗', '😄 🙌', '😎 👊'];
7
+ const REVIEW_AUTHOR_PLACEHOLDER = 'PR_AUTHOR';
8
+ const GENERIC_REVIEW_SUMMARY_GREETING = 'Thanks for your contribution.';
9
+ function pickSummaryEmoji(prAuthor, seedContext) {
10
+ const normalizedAuthor = normalizePrAuthor(prAuthor);
11
+ const normalizedContext = seedContext?.trim();
12
+ const seed = [normalizedAuthor || 'doistbot-review', normalizedContext]
13
+ .filter(Boolean)
14
+ .join(':');
15
+ const hash = Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
16
+ return REVIEW_SUMMARY_EMOJIS[hash % REVIEW_SUMMARY_EMOJIS.length];
17
+ }
18
+ function escapeRegExp(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
21
+ function resolveAuthorGreetingName(prAuthor, prAuthorGreetingName) {
22
+ return formatPrAuthorForGreeting(prAuthorGreetingName) ?? formatPrAuthorForGreeting(prAuthor);
23
+ }
24
+ export function buildReviewSummaryGreeting(prAuthor, seedContext, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
25
+ if (excludePrAuthorFromSummary) {
26
+ return '';
27
+ }
28
+ const authorForGreeting = resolveAuthorGreetingName(prAuthor, prAuthorGreetingName);
29
+ const emoji = pickSummaryEmoji(prAuthor, seedContext);
30
+ if (!authorForGreeting) {
31
+ return `Thanks for your contribution ${emoji}.`;
32
+ }
33
+ return `Thanks ${authorForGreeting} for your contribution ${emoji}.`;
34
+ }
35
+ export function withReviewSummaryGreeting(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
36
+ const trimmedSummary = summary.trim();
37
+ if (excludePrAuthorFromSummary) {
38
+ return trimmedSummary;
39
+ }
40
+ const greeting = buildReviewSummaryGreeting(prAuthor, trimmedSummary, prAuthorGreetingName, excludePrAuthorFromSummary);
41
+ return trimmedSummary ? `${greeting} ${trimmedSummary}` : greeting;
42
+ }
43
+ function splitOpeningLine(summary) {
44
+ const firstLineBreak = summary.indexOf('\n');
45
+ if (firstLineBreak === -1) {
46
+ return { openingLine: summary, remainingText: '' };
47
+ }
48
+ return {
49
+ openingLine: summary.slice(0, firstLineBreak),
50
+ remainingText: summary.slice(firstLineBreak),
51
+ };
52
+ }
53
+ function normalizeSummarySpacing(summary) {
54
+ return summary
55
+ .replace(/[ \t]{2,}/g, ' ')
56
+ .replace(/\s+([.!?,;:])/g, '$1')
57
+ .trimEnd();
58
+ }
59
+ function replaceUnsafeAuthorPlaceholder(summary) {
60
+ const placeholderPattern = escapeRegExp(REVIEW_AUTHOR_PLACEHOLDER);
61
+ const genericGreeting = GENERIC_REVIEW_SUMMARY_GREETING.replace(/[.!?]+$/, '');
62
+ return normalizeSummarySpacing(summary
63
+ .replace(new RegExp(`\\bThanks\\s+${placeholderPattern}(?=\\s+for\\b)`, 'g'), 'Thanks')
64
+ .replace(new RegExp(`\\bThanks\\s+${placeholderPattern}(?=[.!?])`, 'g'), genericGreeting)
65
+ .replace(new RegExp(`\\bThank you\\s+${placeholderPattern}(?=\\s+for\\b)`, 'g'), 'Thank you')
66
+ .replace(new RegExp(`\\bThank you\\s+${placeholderPattern}(?=[.!?])`, 'g'), 'Thank you for your contribution')
67
+ .split(REVIEW_AUTHOR_PLACEHOLDER)
68
+ .join('the contributor'));
69
+ }
70
+ function replaceAuthorPlaceholders(summary, greetingName) {
71
+ if (!summary.includes(REVIEW_AUTHOR_PLACEHOLDER)) {
72
+ return summary;
73
+ }
74
+ if (!greetingName) {
75
+ return replaceUnsafeAuthorPlaceholder(summary);
76
+ }
77
+ return summary.split(REVIEW_AUTHOR_PLACEHOLDER).join(greetingName);
78
+ }
79
+ function addEmojiToOpeningSentence(openingLine, emoji) {
80
+ const sentenceEndMatch = /[.!?](?=\s|$)/.exec(openingLine);
81
+ if (!sentenceEndMatch) {
82
+ return `${openingLine} ${emoji}`;
83
+ }
84
+ const sentenceEndIndex = sentenceEndMatch.index;
85
+ return `${openingLine.slice(0, sentenceEndIndex).trimEnd()} ${emoji}${openingLine.slice(sentenceEndIndex)}`;
86
+ }
87
+ export function ensureReviewSummaryApprovedEmoji(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
88
+ const trimmedSummary = summary.trim();
89
+ if (!trimmedSummary || excludePrAuthorFromSummary) {
90
+ return trimmedSummary;
91
+ }
92
+ const emoji = pickSummaryEmoji(prAuthor, trimmedSummary);
93
+ const greetingName = resolveAuthorGreetingName(prAuthor, prAuthorGreetingName);
94
+ const summaryWithAuthor = replaceAuthorPlaceholders(trimmedSummary, greetingName);
95
+ const { openingLine: firstLineWithAuthor, remainingText: finalRemainingText } = splitOpeningLine(summaryWithAuthor);
96
+ const firstLineWithEmoji = addEmojiToOpeningSentence(firstLineWithAuthor, emoji);
97
+ return firstLineWithEmoji + finalRemainingText;
98
+ }
99
+ export function buildCouncilSummaryPrompt({ prTitle, prBody, findings, excludePrAuthorFromSummary = false, reviewObservations = [], }) {
5
100
  const safeTitle = prTitle.trim() || 'Untitled';
101
+ const safeAuthor = excludePrAuthorFromSummary ? 'unknown' : REVIEW_AUTHOR_PLACEHOLDER;
6
102
  const safeBody = prBody.trim();
7
103
  const condensedBody = safeBody
8
104
  ? safeBody.slice(0, MAX_SUMMARY_BODY_CHARS)
@@ -12,8 +108,24 @@ export function buildCouncilSummaryPrompt({ prTitle, prBody, findings, }) {
12
108
  .map((finding) => `<review-finding>\n${finding.trim()}\n</review-finding>`)
13
109
  .join('\n\n')
14
110
  : 'No issues were flagged in the inline comments.';
111
+ const observationsBlock = reviewObservations.length > 0
112
+ ? reviewObservations
113
+ .map((observation) => observation.trim())
114
+ .filter(Boolean)
115
+ .slice(0, 8)
116
+ .map((observation) => `<review-observation>\n${observation.slice(0, MAX_REVIEW_OBSERVATION_CHARS)}\n</review-observation>`)
117
+ .join('\n\n') || 'No additional review observations were captured.'
118
+ : 'No additional review observations were captured.';
15
119
  return `You are an expert code review summarizer for the Doist engineering team.
16
120
 
121
+ <pr_author>
122
+ ${safeAuthor}
123
+ </pr_author>
124
+
125
+ <pr_author_excluded_from_summary>
126
+ ${excludePrAuthorFromSummary ? 'true' : 'false'}
127
+ </pr_author_excluded_from_summary>
128
+
17
129
  <pr_title>
18
130
  ${safeTitle}
19
131
  </pr_title>
@@ -26,22 +138,39 @@ ${condensedBody}
26
138
  ${findingsBlock}
27
139
  </review_findings>
28
140
 
29
- Write a single coherent review summary for the PR author. Synthesize the findings into unified prose—do not list them individually or reference the reviewers, models, or any council. Do not mention issues not present in the <review-finding> blocks. If there are no <review-finding> blocks, state that no issues were flagged.
141
+ <review_observations>
142
+ ${observationsBlock}
143
+ </review_observations>
144
+
145
+ Write the complete GitHub review summary body for the PR author. Synthesize the findings into grouped themes—do not mirror every inline comment one-for-one or reference the reviewers, models, council, focus passes, or review observations. Do not mention issues not present in the <review-finding> blocks. If there are no <review-finding> blocks, state that no issues were flagged.
146
+
147
+ If <pr_author_excluded_from_summary> is "true", do not thank, address, or name the PR author. Otherwise, start with a natural, concise thanks to the author using the literal placeholder ${REVIEW_AUTHOR_PLACEHOLDER}. Do not use a real user name, first name, login, handle, or @ mention. Vary the wording instead of always using the fixed template "Thanks ${REVIEW_AUTHOR_PLACEHOLDER} for your contribution".
148
+
149
+ Do not include emojis in the summary. The caller will replace ${REVIEW_AUTHOR_PLACEHOLDER} locally and add one approved emoji after the author name for non-excluded PR authors.
30
150
 
31
151
  Structure:
32
- - Lead with a concise overview of what changed
33
- - Follow with one sentence on impact or quality (clarity, correctness, maintainability)
34
- - If issues exist, end with a neutral sentence summarizing them (no direct asks or blocking language)
152
+ - Start with one short sentence. When the PR author is not excluded, include the thanks and ${REVIEW_AUTHOR_PLACEHOLDER} in that sentence. When the author is excluded, start with a concise overview of what changed.
153
+ - If issues exist, add a blank line, then the exact heading "Few things worth tightening:", then 1-4 concise bullets grouping what to improve.
154
+ - If no issues exist, do not add a heading or bullets.
35
155
 
36
- Keep it to 1-2 sentences for small changes, 3-4 for larger ones. Use a kind, constructive, slightly appreciative tone. Write in flowing prose without headings, bullet points, or multiple paragraphs. Do not include any <review-finding> tags in the output.
156
+ 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, constructive, appreciative tone without generic praise. Bullets should be short fragments or single sentences, not paragraphs. Do not include any <review-finding> or <review-observation> tags in the output.
37
157
 
38
158
  Respond with only valid JSON in this exact shape:
39
159
  {"summary":"<your summary>","comments":[]}`;
40
160
  }
41
- export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, findings, workspaceDir, requestReview = requestReviewSummary, }) {
42
- const prompt = buildCouncilSummaryPrompt({ prTitle, prBody, findings });
161
+ export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, findings, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
162
+ const prompt = buildCouncilSummaryPrompt({
163
+ prTitle,
164
+ prBody,
165
+ findings,
166
+ prAuthor,
167
+ prAuthorGreetingName,
168
+ excludePrAuthorFromSummary,
169
+ reviewObservations,
170
+ });
43
171
  logger.info('Summarizing council reviews with the review summary model', {
44
172
  findingsCount: findings.length,
173
+ reviewObservationsCount: reviewObservations?.length ?? 0,
45
174
  });
46
175
  const result = await requestReview({ apiKey, prompt, workspaceDir });
47
176
  const summary = result?.summary?.trim() ?? '';
@@ -51,13 +180,13 @@ export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, finding
51
180
  });
52
181
  return null;
53
182
  }
54
- return summary;
183
+ return ensureReviewSummaryApprovedEmoji(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
55
184
  }
56
- export function buildFallbackSummary({ prTitle, findings, }) {
185
+ export function buildFallbackSummary({ prTitle, findings, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary, }) {
57
186
  const safeTitle = prTitle.trim();
58
187
  const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
59
188
  if (findings.length === 0) {
60
- return `${base} No issues were flagged in the reviewed diff.`;
189
+ return withReviewSummaryGreeting(`${base} No issues were flagged in the reviewed diff.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
61
190
  }
62
- return `${base} The inline comments cover the issues found in the review.`;
191
+ return withReviewSummaryGreeting(`${base}\n\nFew things worth tightening:\n- See the inline comments for the issues found in the review.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
63
192
  }
@@ -1,6 +1,6 @@
1
1
  import { runCouncilReview } from '../council/orchestrator.js';
2
2
  import { buildReviewOutputFromFindings, getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
3
- async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, workspaceDir, apiKeys, onProgress, }) {
3
+ async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, workspaceDir, apiKeys, onProgress, }) {
4
4
  const availableModels = getAvailableModels(apiKeys);
5
5
  const model = availableModels[0];
6
6
  if (!model) {
@@ -27,6 +27,10 @@ async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, works
27
27
  lineLookup,
28
28
  prTitle,
29
29
  prBody,
30
+ prAuthor,
31
+ prAuthorGreetingName,
32
+ resolvePrAuthorGreetingName,
33
+ excludePrAuthorFromSummary,
30
34
  summaryApiKey: apiKeys.gemini,
31
35
  workspaceDir,
32
36
  preferredSummary: reviewResult.summary,
@@ -34,7 +38,7 @@ async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, works
34
38
  providerFailures: [],
35
39
  };
36
40
  }
37
- async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, apiKeys, onProgress, }) {
41
+ async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, apiKeys, onProgress, }) {
38
42
  const result = await runCouncilReview({
39
43
  prompt,
40
44
  apiKeys,
@@ -52,13 +56,17 @@ async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, a
52
56
  lineLookup,
53
57
  prTitle,
54
58
  prBody,
59
+ prAuthor,
60
+ prAuthorGreetingName,
61
+ resolvePrAuthorGreetingName,
62
+ excludePrAuthorFromSummary,
55
63
  summaryApiKey: apiKeys.gemini,
56
64
  workspaceDir: ctx.workspaceDir,
57
65
  }),
58
66
  providerFailures: result.errors,
59
67
  };
60
68
  }
61
- export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, onProgress, standardsSection: _standardsSection, }) {
69
+ export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, onProgress, standardsSection: _standardsSection, }) {
62
70
  const apiKeys = getApiKeys(ctx);
63
71
  const availableModels = getAvailableModels(apiKeys);
64
72
  let metricModels = [];
@@ -88,6 +96,10 @@ export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle,
88
96
  lineLookup,
89
97
  prTitle,
90
98
  prBody,
99
+ prAuthor,
100
+ prAuthorGreetingName,
101
+ resolvePrAuthorGreetingName,
102
+ excludePrAuthorFromSummary,
91
103
  apiKeys,
92
104
  onProgress,
93
105
  })
@@ -96,6 +108,10 @@ export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle,
96
108
  lineLookup,
97
109
  prTitle,
98
110
  prBody,
111
+ prAuthor,
112
+ prAuthorGreetingName,
113
+ resolvePrAuthorGreetingName,
114
+ excludePrAuthorFromSummary,
99
115
  workspaceDir: ctx.workspaceDir,
100
116
  apiKeys,
101
117
  onProgress,
@@ -55,7 +55,7 @@ function parseDedupeResponse(raw) {
55
55
  .filter((group) => group.ids.length >= 2),
56
56
  };
57
57
  }
58
- function logDedupeProgress(event) {
58
+ function logDedupeProgress(providerConfig, event) {
59
59
  logPiProgressEvent({
60
60
  event,
61
61
  messages: {
@@ -67,7 +67,7 @@ function logDedupeProgress(event) {
67
67
  toolUpdate: 'Multi-focus dedupe tool still running',
68
68
  toolEnd: 'Multi-focus dedupe tool finished',
69
69
  },
70
- meta: { provider: DEDUPE_PROVIDER, model: DEDUPE_MODEL_NAME },
70
+ meta: { provider: providerConfig.provider, model: providerConfig.modelName },
71
71
  logResponding: true,
72
72
  logTools: true,
73
73
  toolLogLevel: 'warn',
@@ -111,30 +111,39 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
111
111
  return findings;
112
112
  }
113
113
  if (!availableModels.includes(DEDUPE_PROVIDER)) {
114
- return findings;
114
+ logger.error('Multi-focus dedupe unavailable because Gemini is not enabled', {
115
+ provider: DEDUPE_PROVIDER,
116
+ model: DEDUPE_MODEL_NAME,
117
+ });
118
+ throw new Error('Multi-focus dedupe requires Gemini to be enabled');
115
119
  }
116
120
  if (!geminiApiKey) {
117
- logger.warn('Multi-focus dedupe skipped because Gemini API key is unavailable', {
121
+ logger.error('Multi-focus dedupe unavailable because Gemini API key is unavailable', {
118
122
  provider: DEDUPE_PROVIDER,
119
123
  model: DEDUPE_MODEL_NAME,
120
124
  });
121
- return findings;
125
+ throw new Error('Multi-focus dedupe requires a Gemini API key');
122
126
  }
127
+ const providerConfig = {
128
+ provider: DEDUPE_PROVIDER,
129
+ apiKey: geminiApiKey,
130
+ modelName: DEDUPE_MODEL_NAME,
131
+ };
123
132
  for (let attempt = 1; attempt <= DEDUPE_MAX_ATTEMPTS; attempt += 1) {
124
133
  let result;
125
134
  try {
126
135
  result = await invokePiPrompt({
127
- provider: DEDUPE_PROVIDER,
128
- apiKey: geminiApiKey,
136
+ provider: providerConfig.provider,
137
+ apiKey: providerConfig.apiKey,
129
138
  prompt: buildDedupePrompt(findings),
130
139
  cwd: workspaceDir,
131
140
  timeoutMs: DEDUPE_TIMEOUT_MS,
132
141
  capabilityPreset: 'read-only',
133
- modelName: DEDUPE_MODEL_NAME,
142
+ modelName: providerConfig.modelName,
134
143
  thinkingLevel: DEDUPE_THINKING_LEVEL,
135
144
  systemPrompt: DEDUPE_SYSTEM_PROMPT,
136
145
  onProgress: (event) => {
137
- logDedupeProgress(event);
146
+ logDedupeProgress(providerConfig, event);
138
147
  },
139
148
  });
140
149
  }
@@ -142,9 +151,9 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
142
151
  const shouldRetry = attempt < DEDUPE_MAX_ATTEMPTS;
143
152
  logger.warn(shouldRetry
144
153
  ? 'Multi-focus dedupe request threw, retrying'
145
- : 'Multi-focus dedupe request threw, returning original findings', {
146
- provider: DEDUPE_PROVIDER,
147
- model: DEDUPE_MODEL_NAME,
154
+ : 'Multi-focus dedupe request threw', {
155
+ provider: providerConfig.provider,
156
+ model: providerConfig.modelName,
148
157
  attempt,
149
158
  attempts_total: DEDUPE_MAX_ATTEMPTS,
150
159
  error: error instanceof Error ? error.message : String(error),
@@ -153,15 +162,15 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
153
162
  await waitForDedupeRetry(attempt);
154
163
  continue;
155
164
  }
156
- return findings;
165
+ throw new Error(`Multi-focus dedupe request threw: ${error instanceof Error ? error.message : String(error)}`);
157
166
  }
158
167
  if (!result.ok) {
159
168
  const shouldRetry = attempt < DEDUPE_MAX_ATTEMPTS;
160
169
  logger.warn(shouldRetry
161
170
  ? 'Multi-focus dedupe request failed, retrying'
162
- : 'Multi-focus dedupe request failed, returning original findings', {
163
- provider: DEDUPE_PROVIDER,
164
- model: DEDUPE_MODEL_NAME,
171
+ : 'Multi-focus dedupe request failed', {
172
+ provider: providerConfig.provider,
173
+ model: providerConfig.modelName,
165
174
  attempt,
166
175
  attempts_total: DEDUPE_MAX_ATTEMPTS,
167
176
  error: result.error,
@@ -171,37 +180,37 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
171
180
  await waitForDedupeRetry(attempt);
172
181
  continue;
173
182
  }
174
- return findings;
183
+ throw new Error(`Multi-focus dedupe request failed: ${result.error}`);
175
184
  }
176
185
  if (hasToolUsage(result.toolUsage)) {
177
- logger.warn('Multi-focus dedupe used tools, returning original findings', {
178
- provider: DEDUPE_PROVIDER,
179
- model: DEDUPE_MODEL_NAME,
186
+ logger.error('Multi-focus dedupe used tools', {
187
+ provider: providerConfig.provider,
188
+ model: providerConfig.modelName,
180
189
  tool_usage: result.toolUsage,
181
190
  });
182
- return findings;
191
+ throw new Error('Multi-focus dedupe used tools unexpectedly');
183
192
  }
184
193
  let parsed;
185
194
  try {
186
195
  parsed = parseDedupeResponse(result.output);
187
196
  }
188
197
  catch (error) {
189
- logger.warn('Multi-focus dedupe parsing failed, returning original findings', {
190
- provider: DEDUPE_PROVIDER,
191
- model: DEDUPE_MODEL_NAME,
198
+ logger.error('Multi-focus dedupe parsing failed', {
199
+ provider: providerConfig.provider,
200
+ model: providerConfig.modelName,
192
201
  error: error instanceof Error ? error.message : String(error),
193
202
  });
194
- return findings;
203
+ throw new Error(`Multi-focus dedupe parsing failed: ${error instanceof Error ? error.message : String(error)}`);
195
204
  }
196
205
  const deduped = applyDedupeGroups(findings, parsed.groups);
197
206
  logger.info('Multi-focus dedupe completed', {
198
- provider: DEDUPE_PROVIDER,
199
- model: DEDUPE_MODEL_NAME,
207
+ provider: providerConfig.provider,
208
+ model: providerConfig.modelName,
200
209
  originalCount: findings.length,
201
210
  dedupedCount: deduped.length,
202
211
  groupsCount: parsed.groups.length,
203
212
  });
204
213
  return deduped;
205
214
  }
206
- return findings;
215
+ throw new Error('Multi-focus dedupe exhausted attempts without a result');
207
216
  }
@@ -1,7 +1,12 @@
1
1
  import { logger } from '../../../core/logging.js';
2
+ import { convertFindingsToComments } from '../comment-mapper.js';
3
+ import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
4
+ import { buildReviewOutputFindings } from '../finding-output.js';
2
5
  import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus-prompt.js';
6
+ import { parseSeverity } from '../severity.js';
3
7
  import { dedupeMultiFocusFindings } from './dedupe.js';
4
- import { buildReviewOutputFromFindings, getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
8
+ import { getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
9
+ const LOW_PRIORITY_SUMMARY_NOTICE = 'I also included a few optional follow-up notes in the details below.';
5
10
  async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKeys, workspaceDir, onProgress, }) {
6
11
  const prompt = buildMultiFocusPrompt(basePrompt, focus, standardsSection);
7
12
  const result = await requestModelReview({
@@ -41,13 +46,116 @@ async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKey
41
46
  status: 'succeeded',
42
47
  focus: focus.name,
43
48
  model,
49
+ summary: result.summary.trim(),
44
50
  findings: result.findings.map((finding) => ({
45
51
  ...finding,
46
52
  focus: focus.name,
47
53
  })),
48
54
  };
49
55
  }
50
- export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, standardsSection, onProgress, }) {
56
+ function routeLowPriorityFindings(findings, placement = 'summary') {
57
+ if (placement === 'inline') {
58
+ return { inlineFindings: findings, summaryFindings: [] };
59
+ }
60
+ const inlineFindings = [];
61
+ const summaryFindings = [];
62
+ for (const finding of findings) {
63
+ const severity = parseSeverity(finding.body);
64
+ if (severity === 'P3') {
65
+ summaryFindings.push(finding);
66
+ }
67
+ else {
68
+ inlineFindings.push(finding);
69
+ }
70
+ }
71
+ return { inlineFindings, summaryFindings };
72
+ }
73
+ function buildNoInlineSummaryWithNotes(prTitle, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary) {
74
+ const safeTitle = prTitle.trim();
75
+ const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
76
+ return withReviewSummaryGreeting(`${base} No inline issues were flagged.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
77
+ }
78
+ function collectReviewObservations(focusRuns) {
79
+ const observations = [];
80
+ const seen = new Set();
81
+ for (const run of focusRuns) {
82
+ if (run.status !== 'succeeded') {
83
+ continue;
84
+ }
85
+ const summary = run.summary.replace(/\s+/g, ' ').trim();
86
+ if (!summary) {
87
+ continue;
88
+ }
89
+ const key = summary.toLowerCase();
90
+ if (seen.has(key)) {
91
+ continue;
92
+ }
93
+ seen.add(key);
94
+ observations.push(`${run.focus}: ${summary}`);
95
+ }
96
+ return observations.slice(0, 8);
97
+ }
98
+ function formatSummaryOnlyFinding(finding) {
99
+ const severity = parseSeverity(finding.body);
100
+ const bodyWithoutSeverity = finding.body.replace(/^\s*\[P\d\]\s*/i, '').trim();
101
+ const normalizedBody = (bodyWithoutSeverity || finding.body).replace(/\s+/g, ' ').trim();
102
+ const location = finding.side === 'LEFT'
103
+ ? `${finding.path}:${finding.line} (removed line)`
104
+ : `${finding.path}:${finding.line}`;
105
+ const label = severity ? `[${severity}] ${location}` : location;
106
+ return `- **${label}**: ${normalizedBody}`;
107
+ }
108
+ function appendSummaryOnlyFindings(summary, summaryFindings) {
109
+ if (summaryFindings.length === 0) {
110
+ return summary;
111
+ }
112
+ const notes = Array.from(new Set(summaryFindings.map((finding) => formatSummaryOnlyFinding(finding)).filter(Boolean)));
113
+ if (notes.length === 0) {
114
+ return summary;
115
+ }
116
+ const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
117
+ 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
+ }
119
+ async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, reviewObservations, summaryApiKey, workspaceDir, }) {
120
+ 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
126
+ ? null
127
+ : await summarizeCouncilReviews({
128
+ apiKey: summaryApiKey,
129
+ workspaceDir,
130
+ prTitle,
131
+ prBody,
132
+ prAuthor,
133
+ prAuthorGreetingName: resolvedPrAuthorGreetingName,
134
+ excludePrAuthorFromSummary,
135
+ findings,
136
+ reviewObservations,
137
+ });
138
+ const summaryWithoutNotes = synthesizedSummary ||
139
+ (comments.length === 0 && summaryFindings.length > 0
140
+ ? buildNoInlineSummaryWithNotes(prTitle, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
141
+ : buildFallbackSummary({
142
+ prTitle,
143
+ findings,
144
+ prAuthor,
145
+ prAuthorGreetingName: resolvedPrAuthorGreetingName,
146
+ excludePrAuthorFromSummary,
147
+ }));
148
+ return {
149
+ summary: appendSummaryOnlyFindings(summaryWithoutNotes, summaryFindings),
150
+ comments,
151
+ findings: buildReviewOutputFindings({
152
+ inlineFindings,
153
+ summaryFindings,
154
+ lineLookup,
155
+ }),
156
+ };
157
+ }
158
+ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, lowPriorityFindingPlacement, standardsSection, onProgress, }) {
51
159
  const apiKeys = getApiKeys(ctx);
52
160
  const availableModels = getAvailableModels(apiKeys);
53
161
  const metricModels = availableModels.length > 0 ? [...availableModels] : [...ctx.councilModelsEnabled];
@@ -89,19 +197,25 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
89
197
  };
90
198
  }
91
199
  const allFindings = successfulRuns.flatMap((run) => run.findings);
200
+ const reviewObservations = collectReviewObservations(successfulRuns);
92
201
  const dedupedFindings = await dedupeMultiFocusFindings({
93
202
  findings: allFindings,
94
203
  availableModels,
95
204
  workspaceDir: ctx.workspaceDir,
96
205
  geminiApiKey: ctx.geminiApiKey,
97
- openaiApiKey: ctx.openaiApiKey,
98
- anthropicApiKey: ctx.anthropicApiKey,
99
206
  });
100
- const review = await buildReviewOutputFromFindings({
101
- commentsSource: dedupedFindings,
207
+ const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
208
+ const review = await buildMultiFocusReviewOutput({
209
+ inlineFindings: routedFindings.inlineFindings,
210
+ summaryFindings: routedFindings.summaryFindings,
102
211
  lineLookup,
103
212
  prTitle,
104
213
  prBody,
214
+ prAuthor,
215
+ prAuthorGreetingName,
216
+ resolvePrAuthorGreetingName,
217
+ excludePrAuthorFromSummary,
218
+ reviewObservations,
105
219
  summaryApiKey: apiKeys.gemini,
106
220
  workspaceDir: ctx.workspaceDir,
107
221
  });
@@ -111,6 +225,9 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
111
225
  successfulRuns: successfulRuns.length,
112
226
  findingsCount: allFindings.length,
113
227
  dedupedFindingsCount: dedupedFindings.length,
228
+ inlineFindingsCount: routedFindings.inlineFindings.length,
229
+ summaryOnlyFindingsCount: routedFindings.summaryFindings.length,
230
+ reviewObservationsCount: reviewObservations.length,
114
231
  commentsCount: review.comments.length,
115
232
  });
116
233
  return {
@@ -1,6 +1,7 @@
1
1
  import { getProvider } from '../../../providers/index.js';
2
2
  import { convertFindingsToComments } from '../comment-mapper.js';
3
- import { buildFallbackSummary, summarizeCouncilReviews } from '../council/summary.js';
3
+ import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
4
+ import { buildReviewOutputFindings } from '../finding-output.js';
4
5
  import { getReviewThinkingLevel } from '../thinking-level.js';
5
6
  export function getApiKeys(ctx) {
6
7
  const apiKeys = {};
@@ -52,22 +53,44 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
52
53
  return null;
53
54
  }
54
55
  }
55
- export async function buildReviewOutputFromFindings({ commentsSource, lineLookup, prTitle, prBody, summaryApiKey, workspaceDir, preferredSummary, }) {
56
+ export async function buildReviewOutputFromFindings({ commentsSource, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, summaryApiKey, workspaceDir, preferredSummary, reviewObservations, }) {
56
57
  const comments = convertFindingsToComments(commentsSource, lineLookup);
57
58
  const findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
58
59
  const preferredSummaryText = preferredSummary?.trim();
59
- const synthesizedSummary = preferredSummaryText || summaryApiKey == null
60
+ const hasPreferredSummary = Boolean(preferredSummaryText);
61
+ const resolvedPrAuthorGreetingName = excludePrAuthorFromSummary === true
62
+ ? undefined
63
+ : (prAuthorGreetingName ?? (await resolvePrAuthorGreetingName?.()));
64
+ const synthesizedSummary = hasPreferredSummary || summaryApiKey == null
60
65
  ? null
61
66
  : await summarizeCouncilReviews({
62
67
  apiKey: summaryApiKey,
63
68
  workspaceDir,
64
69
  prTitle,
65
70
  prBody,
71
+ prAuthor,
72
+ prAuthorGreetingName: resolvedPrAuthorGreetingName,
73
+ excludePrAuthorFromSummary,
66
74
  findings,
75
+ reviewObservations,
76
+ });
77
+ const summary = preferredSummaryText ||
78
+ synthesizedSummary ||
79
+ buildFallbackSummary({
80
+ prTitle,
81
+ findings,
82
+ prAuthor,
83
+ prAuthorGreetingName: resolvedPrAuthorGreetingName,
84
+ excludePrAuthorFromSummary,
67
85
  });
68
- const summary = preferredSummaryText || synthesizedSummary || buildFallbackSummary({ prTitle, findings });
69
86
  return {
70
- summary,
87
+ summary: hasPreferredSummary
88
+ ? withReviewSummaryGreeting(summary, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
89
+ : summary,
71
90
  comments,
91
+ findings: buildReviewOutputFindings({
92
+ inlineFindings: commentsSource,
93
+ lineLookup,
94
+ }),
72
95
  };
73
96
  }
@@ -0,0 +1,18 @@
1
+ import { canMapFindingToComment } from './comment-mapper.js';
2
+ import { parseSeverity } from './severity.js';
3
+ function convertFindingToOutputFinding(finding, placement) {
4
+ return {
5
+ body: finding.body,
6
+ severity: parseSeverity(finding.body),
7
+ placement,
8
+ path: finding.path,
9
+ line: finding.line,
10
+ side: finding.side,
11
+ };
12
+ }
13
+ export function buildReviewOutputFindings({ inlineFindings, summaryFindings = [], lineLookup, }) {
14
+ return [
15
+ ...inlineFindings.map((finding) => convertFindingToOutputFinding(finding, canMapFindingToComment(finding, lineLookup) ? 'inline' : 'summary')),
16
+ ...summaryFindings.map((finding) => convertFindingToOutputFinding(finding, 'summary')),
17
+ ];
18
+ }