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