@doist/doistbot-cli 1.0.4 → 1.0.5

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 (62) hide show
  1. package/dist/actions/auth.js +23 -23
  2. package/dist/actions/doctor.js +3 -5
  3. package/dist/actions/review.js +5 -4
  4. package/dist/auth.js +17 -17
  5. package/dist/config.js +14 -9
  6. package/dist/env.js +1 -2
  7. package/dist/file-store.js +1 -1
  8. package/dist/terminal.js +0 -7
  9. package/package.json +5 -5
  10. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +7 -21
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -72
  12. package/sandbox/dist/core/bot-logins.js +0 -13
  13. package/sandbox/dist/core/datadog-metrics.js +0 -2
  14. package/sandbox/dist/core/pi.js +16 -13
  15. package/sandbox/dist/providers/config.js +1 -1
  16. package/sandbox/dist/providers/credentials.js +30 -0
  17. package/sandbox/dist/providers/gemini.js +4 -10
  18. package/sandbox/dist/providers/helpers.js +6 -21
  19. package/sandbox/dist/providers/index.js +0 -3
  20. package/sandbox/dist/providers/openai.js +1 -8
  21. package/sandbox/dist/providers/pi-review.js +16 -54
  22. package/sandbox/dist/providers/schemas.js +0 -39
  23. package/sandbox/dist/tasks/chat/chat.js +2 -1
  24. package/sandbox/dist/tasks/issue-summarize/context.js +1 -1
  25. package/sandbox/dist/tasks/issue-summarize/model.js +3 -1
  26. package/sandbox/dist/tasks/issue-summarize/summarize.js +1 -1
  27. package/sandbox/dist/tasks/issue-triage/context.js +1 -2
  28. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +3 -3
  29. package/sandbox/dist/tasks/persist-logs/dd-proxy.js +2 -2
  30. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +1 -1
  31. package/sandbox/dist/tasks/persist-logs/persist-logs.js +1 -1
  32. package/sandbox/dist/tasks/review/conversation-context.js +13 -7
  33. package/sandbox/dist/tasks/review/engines/dedupe.js +18 -9
  34. package/sandbox/dist/tasks/review/engines/multi-focus.js +26 -31
  35. package/sandbox/dist/tasks/review/engines/shared.js +11 -59
  36. package/sandbox/dist/tasks/review/local.js +4 -29
  37. package/sandbox/dist/tasks/review/prompt.js +2 -49
  38. package/sandbox/dist/tasks/review/review-summary.js +82 -0
  39. package/sandbox/dist/tasks/review/review.js +23 -57
  40. package/sandbox/dist/tasks/review/runner.js +35 -51
  41. package/sandbox/dist/tasks/review/summary-model.js +11 -2
  42. package/sandbox/dist/tasks/review/thinking-level.js +0 -2
  43. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +7 -21
  44. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -72
  45. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  46. package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  47. package/sandbox/dist/providers/anthropic.js +0 -30
  48. package/sandbox/dist/tasks/review/author-format.js +0 -14
  49. package/sandbox/dist/tasks/review/author-profile.js +0 -67
  50. package/sandbox/dist/tasks/review/council/normalizer.js +0 -104
  51. package/sandbox/dist/tasks/review/council/orchestrator.js +0 -194
  52. package/sandbox/dist/tasks/review/council/provider-health.js +0 -31
  53. package/sandbox/dist/tasks/review/council/summary.js +0 -192
  54. package/sandbox/dist/tasks/review/council/types.js +0 -4
  55. package/sandbox/dist/tasks/review/council/voter.js +0 -384
  56. package/sandbox/dist/tasks/review/engines/council.js +0 -126
  57. package/sandbox/dist/tasks/review/engines/index.js +0 -12
  58. package/sandbox/dist/tasks/review/review-engine.js +0 -5
  59. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
  60. package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
  61. package/sandbox/review-prompt.md +0 -209
  62. package/sandbox/vote-prompt.md +0 -69
@@ -1,10 +1,11 @@
1
1
  import fs from 'fs';
2
2
  import { logger } from '../../core/logging.js';
3
- import { getReviewEngineRunner } from './engines/index.js';
3
+ import { hasReviewProviderCredential } from '../../providers/credentials.js';
4
+ import { runMultiFocusReviewEngine } from './engines/multi-focus.js';
4
5
  import {} from './engines/types.js';
5
6
  import { buildAnnotatedDiff, buildDiffSection } from './diff.js';
6
7
  import { buildMultiFocusSharedPrompt } from './multi-focus-prompt.js';
7
- import { buildPrompt, loadStandardsSection } from './prompt.js';
8
+ import { loadStandardsSection } from './prompt.js';
8
9
  const REVIEW_GUIDANCE_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'];
9
10
  const MASKED_PR_AUTHOR_LABEL = 'PR author';
10
11
  function getGuidanceFilePresence(workspaceDir) {
@@ -42,25 +43,15 @@ function buildEnginePrompt({ input, diffSection, truncated, standardsSection, pr
42
43
  truncated,
43
44
  standardsSection,
44
45
  };
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
46
  return {
59
47
  prompt: buildMultiFocusSharedPrompt(sharedPromptArgs),
60
48
  };
61
49
  }
62
- function getEnabledReviewModels({ apiKeys, councilModelsEnabled, }) {
63
- return councilModelsEnabled.filter((model) => apiKeys[model]);
50
+ function getEnabledReviewModels({ apiKeys, reviewModelsEnabled, }) {
51
+ return reviewModelsEnabled.filter((model) => hasReviewProviderCredential(apiKeys[model]));
52
+ }
53
+ function getMissingRequiredReviewModels(availableModels, requiredReviewModels = []) {
54
+ return requiredReviewModels.filter((model) => !availableModels.includes(model));
64
55
  }
65
56
  export async function runSharedReview(input) {
66
57
  const { annotatedFiles, lineLookup } = buildAnnotatedDiff(input.files);
@@ -85,35 +76,40 @@ export async function runSharedReview(input) {
85
76
  providerFailures: [],
86
77
  };
87
78
  }
88
- if (getEnabledReviewModels(input).length === 0) {
79
+ const configuredAvailableModels = getEnabledReviewModels(input);
80
+ if (configuredAvailableModels.length === 0) {
89
81
  logger.error('No enabled review models with API keys are available, skipping review', {
90
- review_engine: input.reviewEngine,
91
- council_models_enabled: input.councilModelsEnabled,
82
+ review_models_enabled: input.reviewModelsEnabled,
92
83
  });
93
84
  return {
94
85
  status: 'skipped',
95
86
  reason: 'no_models',
96
87
  availableModels: [],
97
- metricModels: [...input.councilModelsEnabled],
98
- mode: input.reviewEngine === 'multi_focus' ? 'multi_focus' : 'single',
88
+ metricModels: [...input.reviewModelsEnabled],
89
+ mode: 'multi_focus',
99
90
  providerFailures: [],
100
91
  };
101
92
  }
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),
93
+ const missingRequiredModels = getMissingRequiredReviewModels(configuredAvailableModels, input.requiredReviewModels);
94
+ if (missingRequiredModels.length > 0) {
95
+ logger.error('Required review models with API keys are unavailable, skipping review', {
96
+ review_models_enabled: input.reviewModelsEnabled,
97
+ available_models: configuredAvailableModels,
98
+ missing_required_models: missingRequiredModels,
110
99
  });
111
- return undefined;
112
- });
100
+ return {
101
+ status: 'skipped',
102
+ reason: 'no_models',
103
+ availableModels: configuredAvailableModels,
104
+ metricModels: [...input.reviewModelsEnabled],
105
+ mode: 'multi_focus',
106
+ providerFailures: [],
107
+ };
108
+ }
113
109
  const prConversationPromise = input.loadPrConversation?.() ?? Promise.resolve(undefined);
114
110
  const prConversation = maskPrAuthorInConversation(await prConversationPromise, input.prAuthor);
115
111
  const standardsSection = loadStandardsSection();
116
- const { prompt, promptTemplateLength } = buildEnginePrompt({
112
+ const { prompt } = buildEnginePrompt({
117
113
  input,
118
114
  diffSection,
119
115
  truncated,
@@ -121,9 +117,8 @@ export async function runSharedReview(input) {
121
117
  prConversation,
122
118
  });
123
119
  logger.info('Review prompt prepared', {
124
- engine: input.reviewEngine,
120
+ engine: 'multi_focus',
125
121
  prompt_length: prompt.length,
126
- prompt_template_length: promptTemplateLength,
127
122
  pr_body_length: input.prBody.trim().slice(0, 4000).length,
128
123
  pr_conversation_length: prConversation?.promptText.trim().length ?? 0,
129
124
  diff_length: diffSection.length,
@@ -134,39 +129,28 @@ export async function runSharedReview(input) {
134
129
  guidance_files: getGuidanceFilePresence(input.workspaceDir),
135
130
  });
136
131
  input.onProgress?.({ type: 'prompt_prepared', fileCount: annotatedFiles.length });
137
- const runReviewEngine = getReviewEngineRunner(input.reviewEngine);
138
132
  const engineContext = {
139
133
  owner: input.owner,
140
134
  repo: input.repo,
141
135
  prNumber: input.prNumber,
142
136
  workspaceDir: input.workspaceDir,
143
- geminiApiKey: input.apiKeys.gemini,
137
+ geminiCredential: input.apiKeys.gemini,
144
138
  openaiApiKey: input.apiKeys.openai,
145
- anthropicApiKey: input.apiKeys.anthropic,
146
- reviewEngine: input.reviewEngine,
147
- councilEnabled: input.councilEnabled,
148
- councilModelsEnabled: input.councilModelsEnabled,
139
+ reviewModelsEnabled: input.reviewModelsEnabled,
149
140
  };
150
- const { review, availableModels, metricModels, mode, providerFailures } = await runReviewEngine({
141
+ const { review, availableModels, metricModels, mode, providerFailures } = await runMultiFocusReviewEngine({
151
142
  ctx: engineContext,
152
143
  prompt,
153
144
  standardsSection,
154
145
  lineLookup,
155
146
  prTitle: input.prTitle,
156
147
  prBody: input.prBody,
157
- prAuthor: input.prAuthor,
158
- prAuthorGreetingName: input.prAuthorGreetingName,
159
- resolvePrAuthorGreetingName: prAuthorGreetingNamePromise
160
- ? () => prAuthorGreetingNamePromise
161
- : undefined,
162
- excludePrAuthorFromSummary: input.excludePrAuthorFromSummary,
163
148
  lowPriorityFindingPlacement: input.lowPriorityFindingPlacement,
164
149
  onProgress: input.onProgress,
165
150
  });
166
151
  if (availableModels.length === 0) {
167
152
  logger.error('No enabled review models with API keys are available, skipping review', {
168
- review_engine: input.reviewEngine,
169
- council_models_enabled: input.councilModelsEnabled,
153
+ review_models_enabled: input.reviewModelsEnabled,
170
154
  });
171
155
  return {
172
156
  status: 'skipped',
@@ -200,7 +184,7 @@ export async function runSharedReview(input) {
200
184
  };
201
185
  }
202
186
  logger.info('Review completed', {
203
- engine: input.reviewEngine,
187
+ engine: 'multi_focus',
204
188
  mode,
205
189
  comments_count: review.comments.length,
206
190
  });
@@ -1,6 +1,7 @@
1
1
  import { logger } from '../../core/logging.js';
2
2
  import { invokePiPrompt } from '../../core/pi.js';
3
3
  import { WORKSPACE_DIR } from '../../core/shared.js';
4
+ import { resolveReviewProviderCredential } from '../../providers/credentials.js';
4
5
  const REVIEW_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
5
6
  const DIFF_SIDE = {
6
7
  LEFT: 'LEFT',
@@ -9,11 +10,19 @@ const DIFF_SIDE = {
9
10
  function getWorkspaceDir() {
10
11
  return WORKSPACE_DIR;
11
12
  }
12
- export async function requestReviewSummary({ apiKey, prompt, workspaceDir, }) {
13
+ export async function requestReviewSummary({ credential, prompt, workspaceDir, }) {
13
14
  try {
15
+ const resolvedCredential = resolveReviewProviderCredential(credential);
16
+ if (!resolvedCredential) {
17
+ return null;
18
+ }
14
19
  const result = await invokePiPrompt({
15
20
  provider: 'gemini',
16
- apiKey,
21
+ apiKey: resolvedCredential.apiKey,
22
+ ...(resolvedCredential.piProviderId
23
+ ? { piProviderId: resolvedCredential.piProviderId }
24
+ : {}),
25
+ ...(resolvedCredential.modelName ? { modelName: resolvedCredential.modelName } : {}),
17
26
  prompt,
18
27
  cwd: workspaceDir ?? getWorkspaceDir(),
19
28
  timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
@@ -4,7 +4,5 @@ export function getReviewThinkingLevel(model) {
4
4
  return 'high';
5
5
  case 'gemini':
6
6
  return 'high';
7
- case 'anthropic':
8
- return 'xhigh';
9
7
  }
10
8
  }
@@ -1,9 +1,11 @@
1
1
  import type { Logger } from 'doistbot-logging';
2
2
  import type { Octokit } from 'octokit';
3
3
  export declare const CONFIG_FILE_PATH = ".github/doistbot.yml";
4
- export declare const DEFAULT_COUNCIL_MODELS: readonly ["gemini", "openai"];
5
- type ParseCouncilModelsEnabledOptions = {
4
+ export declare const DEFAULT_REVIEW_MODELS: readonly ["gemini", "openai"];
5
+ export declare const REQUIRED_REVIEW_MODELS: readonly ["gemini", "openai"];
6
+ type ParseReviewModelsEnabledOptions = {
6
7
  defaultModels?: readonly string[];
8
+ requiredModels?: readonly string[];
7
9
  };
8
10
  export type RepoConfig = {
9
11
  review?: {
@@ -11,11 +13,6 @@ export type RepoConfig = {
11
13
  skip_drafts?: boolean;
12
14
  skip_title_pattern?: string;
13
15
  skip_branch_pattern?: string;
14
- prompt_file?: string;
15
- engine?: 'council' | 'multi_focus';
16
- council?: {
17
- enabled: boolean;
18
- };
19
16
  /**
20
17
  * @deprecated Ignored as of #274. Doistbot now posts a GitHub Check
21
18
  * Run for every `/review` (always-on; no opt-in) when `HEAD_SHA` is
@@ -53,17 +50,6 @@ export declare function getLocalRepoConfig({ workspaceDir, logger, }: {
53
50
  workspaceDir: string;
54
51
  logger: Logger;
55
52
  }): RepoConfig | null;
56
- /**
57
- * Fetches a custom prompt file from the repository.
58
- * Returns null if the file doesn't exist.
59
- */
60
- export declare function getCustomPrompt({ octokit, owner, repo, path, ref, logger, }: FetchFileParams): Promise<string | null>;
61
- export declare function getLocalCustomPrompt({ workspaceDir, path: promptPath, logger, }: {
62
- workspaceDir: string;
63
- path: string;
64
- logger: Logger;
65
- }): string | null;
66
- export declare function parseCouncilModelsEnabled(raw: string | undefined, { defaultModels }?: ParseCouncilModelsEnabledOptions): string[];
67
- export declare function getDefaultCouncilModels(): string[];
68
- export type { ReviewEngine } from './review-engine.js';
69
- export { DEFAULT_REVIEW_ENGINE, parseReviewEngine, resolveReviewEngine } from './review-engine.js';
53
+ export declare function parseReviewModelsEnabled(raw: string | undefined, { defaultModels, requiredModels, }?: ParseReviewModelsEnabledOptions): string[];
54
+ export declare function getDefaultReviewModels(): string[];
55
+ export {};
@@ -2,19 +2,11 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { load as yamlLoad } from 'js-yaml';
4
4
  export const CONFIG_FILE_PATH = '.github/doistbot.yml';
5
- export const DEFAULT_COUNCIL_MODELS = ['gemini', 'openai'];
5
+ export const DEFAULT_REVIEW_MODELS = ['gemini', 'openai'];
6
+ export const REQUIRED_REVIEW_MODELS = DEFAULT_REVIEW_MODELS;
6
7
  function parseRepoConfigContent(content) {
7
8
  return yamlLoad(content);
8
9
  }
9
- function resolveWorkspacePath(workspaceDir, repoPath) {
10
- const root = path.resolve(workspaceDir);
11
- const resolved = path.resolve(root, repoPath);
12
- const relative = path.relative(root, resolved);
13
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
14
- return null;
15
- }
16
- return resolved;
17
- }
18
10
  async function fetchFileContent({ octokit, owner, repo, path, ref, logger, }) {
19
11
  const params = {
20
12
  owner,
@@ -99,67 +91,11 @@ export function getLocalRepoConfig({ workspaceDir, logger, }) {
99
91
  return null;
100
92
  }
101
93
  }
102
- /**
103
- * Fetches a custom prompt file from the repository.
104
- * Returns null if the file doesn't exist.
105
- */
106
- export async function getCustomPrompt({ octokit, owner, repo, path, ref, logger, }) {
107
- let content;
108
- try {
109
- content = await fetchFileContent({ octokit, owner, repo, path, ref, logger });
110
- }
111
- catch (error) {
112
- logger.warn('Error fetching custom prompt', {
113
- component: 'repo-config',
114
- repository: `${owner}/${repo}`,
115
- path,
116
- error: error instanceof Error ? error.message : String(error),
117
- });
118
- return null;
119
- }
120
- if (content !== null) {
121
- logger.info('Loaded custom prompt', {
122
- component: 'repo-config',
123
- repository: `${owner}/${repo}`,
124
- path,
125
- });
126
- }
127
- return content;
128
- }
129
- export function getLocalCustomPrompt({ workspaceDir, path: promptPath, logger, }) {
130
- const resolvedPath = resolveWorkspacePath(workspaceDir, promptPath);
131
- if (resolvedPath === null) {
132
- logger.warn('Custom prompt path escapes workspace', {
133
- component: 'repo-config',
134
- path: promptPath,
135
- });
136
- return null;
137
- }
138
- if (!fs.existsSync(resolvedPath)) {
139
- return null;
140
- }
141
- try {
142
- const content = fs.readFileSync(resolvedPath, 'utf8');
143
- logger.info('Loaded local custom prompt', {
144
- component: 'repo-config',
145
- path: promptPath,
146
- });
147
- return content;
148
- }
149
- catch (error) {
150
- logger.warn('Failed to load local custom prompt', {
151
- component: 'repo-config',
152
- path: promptPath,
153
- error: error instanceof Error ? error.message : String(error),
154
- });
155
- return null;
156
- }
157
- }
158
- export function parseCouncilModelsEnabled(raw, { defaultModels = DEFAULT_COUNCIL_MODELS } = {}) {
94
+ export function parseReviewModelsEnabled(raw, { defaultModels = DEFAULT_REVIEW_MODELS, requiredModels = REQUIRED_REVIEW_MODELS, } = {}) {
159
95
  if (!raw?.trim()) {
160
96
  return [...defaultModels];
161
97
  }
162
- const allowedModels = new Set(DEFAULT_COUNCIL_MODELS);
98
+ const allowedModels = new Set(DEFAULT_REVIEW_MODELS);
163
99
  const models = [];
164
100
  for (const token of raw.split(',')) {
165
101
  const model = token.trim().toLowerCase();
@@ -170,9 +106,9 @@ export function parseCouncilModelsEnabled(raw, { defaultModels = DEFAULT_COUNCIL
170
106
  models.push(model);
171
107
  }
172
108
  }
173
- return models.length > 0 ? models : [...defaultModels];
109
+ const hasRequiredModels = requiredModels.every((model) => models.includes(model));
110
+ return models.length > 0 && hasRequiredModels ? models : [...defaultModels];
174
111
  }
175
- export function getDefaultCouncilModels() {
176
- return [...DEFAULT_COUNCIL_MODELS];
112
+ export function getDefaultReviewModels() {
113
+ return [...DEFAULT_REVIEW_MODELS];
177
114
  }
178
- export { DEFAULT_REVIEW_ENGINE, parseReviewEngine, resolveReviewEngine } from './review-engine.js';
@@ -1,11 +0,0 @@
1
- export declare const DEFAULT_REVIEW_ENGINE: "multi_focus";
2
- export type ReviewEngine = 'council' | 'multi_focus';
3
- export declare function parseReviewEngine(raw: unknown): ReviewEngine | null;
4
- export declare function resolveReviewEngine({ configuredEngine, envEngine, logger, repository, }: {
5
- configuredEngine?: unknown;
6
- envEngine?: unknown;
7
- logger?: {
8
- warn: (message: string, context?: Record<string, unknown>) => void;
9
- };
10
- repository?: string;
11
- }): ReviewEngine;
@@ -1,41 +0,0 @@
1
- export const DEFAULT_REVIEW_ENGINE = 'multi_focus';
2
- function isReviewEngine(value) {
3
- return value === 'council' || value === 'multi_focus';
4
- }
5
- export function parseReviewEngine(raw) {
6
- if (typeof raw !== 'string') {
7
- return null;
8
- }
9
- const normalized = raw.trim().toLowerCase();
10
- if (!normalized) {
11
- return null;
12
- }
13
- return isReviewEngine(normalized) ? normalized : null;
14
- }
15
- export function resolveReviewEngine({ configuredEngine, envEngine, logger, repository, }) {
16
- const configured = parseReviewEngine(configuredEngine);
17
- if (configured) {
18
- return configured;
19
- }
20
- if (configuredEngine != null) {
21
- logger?.warn('Invalid configured review engine, checking environment override next', {
22
- component: 'review-engine',
23
- repository,
24
- configured_engine: configuredEngine,
25
- next_source: 'env',
26
- });
27
- }
28
- const fromEnv = parseReviewEngine(envEngine);
29
- if (fromEnv) {
30
- return fromEnv;
31
- }
32
- if (envEngine != null) {
33
- logger?.warn('Invalid env review engine, falling back', {
34
- component: 'review-engine',
35
- repository,
36
- env_engine: envEngine,
37
- fallback: DEFAULT_REVIEW_ENGINE,
38
- });
39
- }
40
- return DEFAULT_REVIEW_ENGINE;
41
- }
@@ -1,30 +0,0 @@
1
- /**
2
- * Anthropic provider for the multi-model code review system.
3
- * Uses the Pi harness instead of the Claude CLI.
4
- */
5
- import { DEFAULT_PI_MODELS } from '../core/pi.js';
6
- import { resolveProviderTimeoutMs } from './config.js';
7
- import { extractJsonFromResponse } from './helpers.js';
8
- import { runPiReviewProvider } from './pi-review.js';
9
- export class AnthropicProvider {
10
- name = 'anthropic';
11
- async review(prompt, config, logOptions) {
12
- return await runPiReviewProvider({
13
- provider: this.name,
14
- modelName: config.modelName ?? DEFAULT_PI_MODELS.anthropic,
15
- prompt,
16
- config: {
17
- ...config,
18
- timeout: resolveProviderTimeoutMs(config.timeout),
19
- },
20
- prompts: config.jsonSchema
21
- ? [
22
- prompt,
23
- `${prompt}\n\nReturn only JSON that matches the provided schema. Do not include analysis text.`,
24
- ]
25
- : [prompt],
26
- parseOutput: (output) => JSON.parse(extractJsonFromResponse(output)),
27
- logOptions,
28
- });
29
- }
30
- }
@@ -1,14 +0,0 @@
1
- export const REVIEW_AUTHOR_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2
- export function normalizePrAuthor(prAuthor) {
3
- return (prAuthor ?? '').trim().replace(/^@+/, '');
4
- }
5
- export function formatPrAuthorForGreeting(prAuthor) {
6
- const normalizedAuthor = normalizePrAuthor(prAuthor);
7
- if (!normalizedAuthor || normalizedAuthor.toLowerCase() === 'unknown') {
8
- return null;
9
- }
10
- if (REVIEW_AUTHOR_EMAIL_PATTERN.test(normalizedAuthor)) {
11
- return null;
12
- }
13
- return normalizedAuthor;
14
- }
@@ -1,67 +0,0 @@
1
- import { isKnownAutomationLogin } from '../../core/bot-logins.js';
2
- import { logger } from '../../core/logging.js';
3
- import { formatPrAuthorForGreeting, REVIEW_AUTHOR_EMAIL_PATTERN } from './author-format.js';
4
- const URL_PATTERN = /^https?:\/\//i;
5
- const UNSAFE_GREETING_CHARS = new Set([
6
- '@',
7
- '<',
8
- '>',
9
- '{',
10
- '}',
11
- '[',
12
- ']',
13
- '(',
14
- ')',
15
- '`',
16
- '*',
17
- '_',
18
- '#',
19
- '|',
20
- '\\',
21
- ]);
22
- const MAX_GREETING_NAME_CHARS = 40;
23
- function hasUnsafeGreetingChar(value) {
24
- return Array.from(value).some((char) => UNSAFE_GREETING_CHARS.has(char));
25
- }
26
- export function shouldExcludePrAuthorFromReviewSummary(login) {
27
- const normalizedLogin = login?.trim().toLowerCase();
28
- return isKnownAutomationLogin(normalizedLogin);
29
- }
30
- export function extractFirstNameFromProfileName(profileName) {
31
- const normalized = profileName?.replace(/\s+/g, ' ').trim() ?? '';
32
- if (!normalized) {
33
- return null;
34
- }
35
- const firstToken = normalized.split(' ')[0]?.replace(/[.,;:!]+$/g, '') ?? '';
36
- if (!firstToken ||
37
- firstToken.length > MAX_GREETING_NAME_CHARS ||
38
- REVIEW_AUTHOR_EMAIL_PATTERN.test(firstToken) ||
39
- URL_PATTERN.test(firstToken) ||
40
- hasUnsafeGreetingChar(firstToken) ||
41
- !/\p{L}/u.test(firstToken)) {
42
- return null;
43
- }
44
- return firstToken;
45
- }
46
- export async function resolvePrAuthorGreetingName({ octokit, login, }) {
47
- const normalizedLogin = formatPrAuthorForGreeting(login);
48
- if (!normalizedLogin || shouldExcludePrAuthorFromReviewSummary(normalizedLogin)) {
49
- return undefined;
50
- }
51
- try {
52
- const response = await octokit.rest.users.getByUsername({ username: normalizedLogin });
53
- const greetingName = extractFirstNameFromProfileName(response.data.name);
54
- if (!greetingName) {
55
- logger.info('PR author GitHub profile name unavailable for greeting', {
56
- profile_name_available: Boolean(response.data.name?.trim()),
57
- });
58
- }
59
- return greetingName ?? undefined;
60
- }
61
- catch (error) {
62
- logger.warn('Failed to resolve PR author GitHub profile name', {
63
- error: error instanceof Error ? error.message : String(error),
64
- });
65
- return undefined;
66
- }
67
- }
@@ -1,104 +0,0 @@
1
- /**
2
- * Normalizer module for deduplicating findings across multiple models.
3
- * Groups similar findings together based on location and content similarity.
4
- */
5
- import { randomUUID } from 'crypto';
6
- import { logger } from '../../../core/logging.js';
7
- const FUZZY_LINE_TOLERANCE = 3; // Consider findings within +-3 lines as potentially related
8
- /**
9
- * Creates a location key for exact matching.
10
- */
11
- function getLocationKey(finding) {
12
- return `${finding.path}:${finding.line}:${finding.side}`;
13
- }
14
- /**
15
- * Creates location keys for fuzzy matching (within tolerance).
16
- */
17
- function getFuzzyLocationKeys(finding) {
18
- const keys = [];
19
- for (let offset = -FUZZY_LINE_TOLERANCE; offset <= FUZZY_LINE_TOLERANCE; offset++) {
20
- const fuzzyLine = finding.line + offset;
21
- if (fuzzyLine > 0) {
22
- keys.push(`${finding.path}:${fuzzyLine}:${finding.side}`);
23
- }
24
- }
25
- return keys;
26
- }
27
- /**
28
- * Normalizes and deduplicates findings from multiple model results.
29
- * Findings are grouped by:
30
- * 1. Exact location match (path:line:side)
31
- * 2. Fuzzy location match (same path, within +-3 lines, same side)
32
- */
33
- export function normalizeFindings(allFindings) {
34
- if (allFindings.length === 0) {
35
- return [];
36
- }
37
- const groups = [];
38
- const findingToGroup = new Map(); // finding.id -> group
39
- const locationToGroup = new Map(); // location -> group
40
- for (const finding of allFindings) {
41
- const exactKey = getLocationKey(finding);
42
- // Check for exact match first
43
- const existingGroup = locationToGroup.get(exactKey);
44
- if (existingGroup) {
45
- existingGroup.relatedFindings.push(finding);
46
- if (!existingGroup.sourceModels.includes(finding.sourceModel)) {
47
- existingGroup.sourceModels.push(finding.sourceModel);
48
- }
49
- findingToGroup.set(finding.id, existingGroup);
50
- continue;
51
- }
52
- // Check for fuzzy match
53
- const fuzzyKeys = getFuzzyLocationKeys(finding);
54
- let fuzzyMatchGroup;
55
- for (const fuzzyKey of fuzzyKeys) {
56
- const group = locationToGroup.get(fuzzyKey);
57
- if (group) {
58
- // Only fuzzy match if it's from a different model
59
- if (group.primaryFinding.sourceModel !== finding.sourceModel) {
60
- fuzzyMatchGroup = group;
61
- break;
62
- }
63
- }
64
- }
65
- if (fuzzyMatchGroup) {
66
- fuzzyMatchGroup.relatedFindings.push(finding);
67
- if (!fuzzyMatchGroup.sourceModels.includes(finding.sourceModel)) {
68
- fuzzyMatchGroup.sourceModels.push(finding.sourceModel);
69
- }
70
- findingToGroup.set(finding.id, fuzzyMatchGroup);
71
- // Also register this finding's exact location to the group
72
- locationToGroup.set(exactKey, fuzzyMatchGroup);
73
- continue;
74
- }
75
- // No match found, create a new group
76
- const newGroup = {
77
- id: randomUUID(),
78
- primaryFinding: finding,
79
- relatedFindings: [],
80
- sourceModels: [finding.sourceModel],
81
- };
82
- groups.push(newGroup);
83
- findingToGroup.set(finding.id, newGroup);
84
- locationToGroup.set(exactKey, newGroup);
85
- }
86
- logger.info('Normalized council findings', {
87
- originalCount: allFindings.length,
88
- deduplicatedCount: groups.length,
89
- groups,
90
- });
91
- return groups;
92
- }
93
- /**
94
- * Gets all findings from a group (primary + related).
95
- */
96
- export function getAllFindingsFromGroup(group) {
97
- return [group.primaryFinding, ...group.relatedFindings];
98
- }
99
- /**
100
- * Gets the number of unique models that found something in this group.
101
- */
102
- export function getModelCountForGroup(group) {
103
- return group.sourceModels.length;
104
- }