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