@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,7 +1,14 @@
1
1
  import { logger } from '../../core/logging.js';
2
- import { invokePiPrompt } from '../../core/pi.js';
2
+ import { DEFAULT_OPENROUTER_MODELS, DEFAULT_PI_MODELS, invokePiPrompt, PI_PROVIDER_BY_DOISTBOT_PROVIDER, } from '../../core/pi.js';
3
3
  import { WORKSPACE_DIR } from '../../core/shared.js';
4
+ import { resolveReviewProviderCredential } from '../../providers/credentials.js';
5
+ import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
4
6
  const REVIEW_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
7
+ const REVIEW_SUMMARY_THINKING_LEVEL = 'medium';
8
+ const GEMINI_FLASH_SUMMARY_MODEL_BY_PI_PROVIDER = {
9
+ google: 'gemini-3-flash-preview',
10
+ openrouter: 'google/gemini-3-flash-preview',
11
+ };
5
12
  const DIFF_SIDE = {
6
13
  LEFT: 'LEFT',
7
14
  RIGHT: 'RIGHT',
@@ -9,45 +16,194 @@ const DIFF_SIDE = {
9
16
  function getWorkspaceDir() {
10
17
  return WORKSPACE_DIR;
11
18
  }
12
- export async function requestReviewSummary({ apiKey, prompt, workspaceDir, }) {
19
+ function getReviewSummaryModelNameForLog({ provider, piProviderId, modelName, }) {
20
+ if (modelName) {
21
+ return modelName;
22
+ }
23
+ const resolvedPiProviderId = piProviderId ?? PI_PROVIDER_BY_DOISTBOT_PROVIDER[provider];
24
+ return ((resolvedPiProviderId === 'openrouter' ? DEFAULT_OPENROUTER_MODELS[provider] : undefined) ??
25
+ DEFAULT_PI_MODELS[provider]);
26
+ }
27
+ export function buildGeminiFlashReviewSummaryModel(credential) {
28
+ if (credential === undefined) {
29
+ return undefined;
30
+ }
31
+ const resolvedCredential = resolveReviewProviderCredential(credential);
32
+ if (!resolvedCredential) {
33
+ return undefined;
34
+ }
35
+ const piProviderId = resolvedCredential.piProviderId ?? 'google';
36
+ const modelName = GEMINI_FLASH_SUMMARY_MODEL_BY_PI_PROVIDER[piProviderId] ??
37
+ GEMINI_FLASH_SUMMARY_MODEL_BY_PI_PROVIDER.google;
38
+ return {
39
+ provider: 'gemini',
40
+ credential,
41
+ modelName,
42
+ };
43
+ }
44
+ function failedReviewSummaryRequest({ reason, error, timedOut, durationMs, outputChars, }) {
45
+ return {
46
+ status: 'failed',
47
+ reason,
48
+ ...(error ? { error } : {}),
49
+ ...(timedOut !== undefined ? { timedOut } : {}),
50
+ ...(durationMs !== undefined ? { durationMs } : {}),
51
+ ...(outputChars !== undefined ? { outputChars } : {}),
52
+ };
53
+ }
54
+ function getRequestFailureReason(result) {
55
+ if (result.timedOut) {
56
+ return 'timeout';
57
+ }
58
+ return result.error.toLowerCase().includes('did not produce an assistant response')
59
+ ? 'no_assistant_response'
60
+ : 'request_failed';
61
+ }
62
+ export async function requestReviewSummary({ credential, provider = 'zai', modelName, prompt, workspaceDir, }) {
13
63
  try {
64
+ const resolvedCredential = resolveReviewProviderCredential(credential);
65
+ if (!resolvedCredential) {
66
+ return failedReviewSummaryRequest({ reason: 'missing_credential' });
67
+ }
68
+ const resolvedModelName = modelName ?? resolvedCredential.modelName;
69
+ const logModelName = getReviewSummaryModelNameForLog({
70
+ provider,
71
+ piProviderId: resolvedCredential.piProviderId,
72
+ modelName: resolvedModelName,
73
+ });
74
+ logger.info('Review summary model request', {
75
+ provider,
76
+ modelName: logModelName,
77
+ promptLength: prompt.length,
78
+ timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
79
+ thinkingLevel: REVIEW_SUMMARY_THINKING_LEVEL,
80
+ });
14
81
  const result = await invokePiPrompt({
15
- provider: 'gemini',
16
- apiKey,
82
+ provider,
83
+ apiKey: resolvedCredential.apiKey,
84
+ ...(resolvedCredential.piProviderId
85
+ ? { piProviderId: resolvedCredential.piProviderId }
86
+ : {}),
87
+ ...(resolvedModelName ? { modelName: resolvedModelName } : {}),
17
88
  prompt,
18
89
  cwd: workspaceDir ?? getWorkspaceDir(),
19
90
  timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
20
91
  capabilityPreset: 'read-only',
92
+ thinkingLevel: REVIEW_SUMMARY_THINKING_LEVEL,
21
93
  });
22
94
  if (!result.ok) {
95
+ const reason = getRequestFailureReason(result);
23
96
  logger.warn('Review summary model request failed', {
97
+ reason,
98
+ provider,
99
+ modelName: logModelName,
100
+ error: result.error,
101
+ timedOut: result.timedOut,
102
+ durationMs: result.durationMs,
103
+ promptLength: prompt.length,
104
+ });
105
+ return failedReviewSummaryRequest({
106
+ reason,
24
107
  error: result.error,
25
108
  timedOut: result.timedOut,
109
+ durationMs: result.durationMs,
26
110
  });
27
- return null;
28
111
  }
29
112
  const output = result.output;
30
113
  if (!output?.trim()) {
31
- return null;
114
+ return failedReviewSummaryRequest({
115
+ reason: 'empty_output',
116
+ durationMs: result.durationMs,
117
+ outputChars: output?.length ?? 0,
118
+ });
119
+ }
120
+ const jsonCandidates = extractJsonCandidatesFromResponse(output);
121
+ if (jsonCandidates.length === 0) {
122
+ logger.warn('Review summary model output missing JSON', {
123
+ provider,
124
+ modelName: logModelName,
125
+ durationMs: result.durationMs,
126
+ outputChars: output.length,
127
+ });
128
+ return failedReviewSummaryRequest({
129
+ reason: 'missing_json',
130
+ durationMs: result.durationMs,
131
+ outputChars: output.length,
132
+ });
133
+ }
134
+ let parsed;
135
+ let parsedSuccessfully = false;
136
+ let parseError;
137
+ for (const jsonCandidate of jsonCandidates) {
138
+ try {
139
+ parsed = JSON.parse(jsonCandidate);
140
+ parsedSuccessfully = true;
141
+ break;
142
+ }
143
+ catch (error) {
144
+ parseError = error;
145
+ }
146
+ }
147
+ if (!parsedSuccessfully) {
148
+ logger.warn('Review summary model output contained invalid JSON', {
149
+ provider,
150
+ modelName: logModelName,
151
+ error: parseError instanceof Error ? parseError.message : String(parseError),
152
+ durationMs: result.durationMs,
153
+ outputChars: output.length,
154
+ });
155
+ return failedReviewSummaryRequest({
156
+ reason: 'invalid_json',
157
+ error: parseError instanceof Error ? parseError.message : String(parseError),
158
+ durationMs: result.durationMs,
159
+ outputChars: output.length,
160
+ });
32
161
  }
33
- const start = output.indexOf('{');
34
- const end = output.lastIndexOf('}');
35
- if (start === -1 || end === -1 || end <= start) {
36
- logger.warn('Review summary model output missing JSON delimiters');
37
- return null;
162
+ if (!parsed || typeof parsed !== 'object') {
163
+ return failedReviewSummaryRequest({
164
+ reason: 'invalid_schema',
165
+ durationMs: result.durationMs,
166
+ outputChars: output.length,
167
+ });
168
+ }
169
+ const summaryResult = parsed;
170
+ if (typeof summaryResult.summary !== 'string') {
171
+ return failedReviewSummaryRequest({
172
+ reason: 'invalid_schema',
173
+ durationMs: result.durationMs,
174
+ outputChars: output.length,
175
+ });
176
+ }
177
+ if (!summaryResult.summary.trim()) {
178
+ return failedReviewSummaryRequest({
179
+ reason: 'empty_summary',
180
+ durationMs: result.durationMs,
181
+ outputChars: output.length,
182
+ });
38
183
  }
39
- const jsonText = output.slice(start, end + 1);
40
- const parsed = JSON.parse(jsonText);
184
+ logger.info('Review summary model response', {
185
+ provider,
186
+ modelName: logModelName,
187
+ durationMs: result.durationMs,
188
+ outputChars: output.length,
189
+ summaryChars: summaryResult.summary.trim().length,
190
+ });
41
191
  return {
42
- summary: typeof parsed.summary === 'string' ? parsed.summary : '',
43
- comments: Array.isArray(parsed.comments) ? parsed.comments : [],
192
+ status: 'succeeded',
193
+ summary: summaryResult.summary,
194
+ comments: Array.isArray(summaryResult.comments) ? summaryResult.comments : [],
195
+ durationMs: result.durationMs,
196
+ outputChars: output.length,
44
197
  };
45
198
  }
46
199
  catch (error) {
47
200
  logger.error('Review summary model request or parsing failed', {
48
201
  error: error instanceof Error ? error.message : String(error),
49
202
  });
50
- return null;
203
+ return failedReviewSummaryRequest({
204
+ reason: 'request_failed',
205
+ error: error instanceof Error ? error.message : String(error),
206
+ });
51
207
  }
52
208
  }
53
209
  export function sanitizeInlineComments(comments, lineLookup) {
@@ -1,10 +1,12 @@
1
1
  export function getReviewThinkingLevel(model) {
2
2
  switch (model) {
3
+ case 'deepseek':
4
+ return 'high';
3
5
  case 'openai':
4
6
  return 'high';
7
+ case 'zai':
8
+ return 'high';
5
9
  case 'gemini':
6
10
  return 'high';
7
- case 'anthropic':
8
- return 'xhigh';
9
11
  }
10
12
  }
@@ -1,21 +1,14 @@
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 = {
6
- defaultModels?: readonly string[];
7
- };
4
+ export declare const DEFAULT_REVIEW_MODELS: readonly ["gemini", "openai", "deepseek", "zai"];
5
+ export type DefaultReviewModel = (typeof DEFAULT_REVIEW_MODELS)[number];
8
6
  export type RepoConfig = {
9
7
  review?: {
10
8
  skip_forks?: boolean;
11
9
  skip_drafts?: boolean;
12
10
  skip_title_pattern?: string;
13
11
  skip_branch_pattern?: string;
14
- prompt_file?: string;
15
- engine?: 'council' | 'multi_focus';
16
- council?: {
17
- enabled: boolean;
18
- };
19
12
  /**
20
13
  * @deprecated Ignored as of #274. Doistbot now posts a GitHub Check
21
14
  * Run for every `/review` (always-on; no opt-in) when `HEAD_SHA` is
@@ -53,17 +46,6 @@ export declare function getLocalRepoConfig({ workspaceDir, logger, }: {
53
46
  workspaceDir: string;
54
47
  logger: Logger;
55
48
  }): 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';
49
+ export declare function getDefaultReviewModels(): DefaultReviewModel[];
50
+ export declare function parseReviewModelsEnabled(value: string | undefined): string[];
51
+ export {};
@@ -2,19 +2,10 @@ 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', 'deepseek', 'zai'];
6
6
  function parseRepoConfigContent(content) {
7
7
  return yamlLoad(content);
8
8
  }
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
9
  async function fetchFileContent({ octokit, owner, repo, path, ref, logger, }) {
19
10
  const params = {
20
11
  owner,
@@ -99,80 +90,13 @@ export function getLocalRepoConfig({ workspaceDir, logger, }) {
99
90
  return null;
100
91
  }
101
92
  }
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 } = {}) {
159
- if (!raw?.trim()) {
160
- return [...defaultModels];
161
- }
162
- const allowedModels = new Set(DEFAULT_COUNCIL_MODELS);
163
- const models = [];
164
- for (const token of raw.split(',')) {
165
- const model = token.trim().toLowerCase();
166
- if (!model || !allowedModels.has(model)) {
167
- continue;
168
- }
169
- if (!models.includes(model)) {
170
- models.push(model);
171
- }
172
- }
173
- return models.length > 0 ? models : [...defaultModels];
93
+ export function getDefaultReviewModels() {
94
+ return [...DEFAULT_REVIEW_MODELS];
174
95
  }
175
- export function getDefaultCouncilModels() {
176
- return [...DEFAULT_COUNCIL_MODELS];
96
+ export function parseReviewModelsEnabled(value) {
97
+ const models = value
98
+ ?.split(',')
99
+ .map((model) => model.trim())
100
+ .filter(Boolean) ?? [];
101
+ return models.length > 0 ? [...new Set(models)] : getDefaultReviewModels();
177
102
  }
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
- }