@doist/doistbot-cli 1.0.8 → 1.0.10
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 +6 -4
- package/dist/actions/review.js +1 -0
- package/dist/auth.js +67 -35
- package/dist/config.js +6 -3
- package/dist/input.js +11 -11
- package/dist/terminal.js +1 -0
- package/package.json +5 -4
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/dist/core/datadog-metrics.js +2 -0
- package/sandbox/dist/core/pi.js +13 -3
- package/sandbox/dist/core/shared.js +9 -9
- package/sandbox/dist/core/span-test-helpers.js +13 -0
- package/sandbox/dist/core/tracing.js +1 -1
- package/sandbox/dist/main.js +3 -0
- package/sandbox/dist/providers/index.js +1 -0
- package/sandbox/dist/tasks/issue-summarize/model.js +4 -4
- package/sandbox/dist/tasks/issue-triage/autofix-capabilities.js +22 -0
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +6 -0
- package/sandbox/dist/tasks/issue-triage/routing.js +124 -15
- package/sandbox/dist/tasks/issue-triage/triage.js +2 -0
- package/sandbox/dist/tasks/review/engines/multi-focus.js +23 -15
- package/sandbox/dist/tasks/review/engines/shared.js +2 -0
- package/sandbox/dist/tasks/review/multi-focus-prompt.js +2 -0
- package/sandbox/dist/tasks/review/review-summary.js +2 -2
- package/sandbox/dist/tasks/review/review.js +5 -3
- package/sandbox/dist/tasks/review/summary-model.js +5 -4
- package/sandbox/dist/tasks/review/thinking-level.js +2 -0
- package/sandbox/dist/tasks/review-slice-plan/context.js +69 -0
- package/sandbox/dist/tasks/review-slice-plan/plan.js +209 -0
- package/sandbox/dist/tasks/review-slice-plan/prompt.js +59 -0
- package/sandbox/dist/tasks/review-slice-plan/review-slice-plan.js +470 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -12
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +24 -7
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +14 -22
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +13 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/security.md +18 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +12 -22
|
@@ -15,21 +15,51 @@ function hasProductLabel(labels) {
|
|
|
15
15
|
function stripMarkdownHeading(line) {
|
|
16
16
|
return line.replace(/^#{1,6}\s+/, '').trim();
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
18
|
+
function stripListMarker(line) {
|
|
19
|
+
return line.replace(/^\s*(?:\d+[.)]|[-*])\s+/, '').trim();
|
|
20
|
+
}
|
|
21
|
+
// `undefined` means the heading is absent; `''` means it is present but empty.
|
|
22
|
+
// Callers depend on the difference — an empty `## Summary` has its own fallback.
|
|
23
|
+
function sectionBody(triageOutput, heading) {
|
|
24
|
+
const match = triageOutput.match(new RegExp(`^## ${heading}\\s*$`, 'im'));
|
|
25
|
+
if (match?.index === undefined) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const rest = triageOutput.slice(match.index + match[0].length);
|
|
29
|
+
const nextHeading = rest.search(/^##\s/m);
|
|
30
|
+
return (nextHeading >= 0 ? rest.slice(0, nextHeading) : rest).trim();
|
|
31
|
+
}
|
|
32
|
+
// The single sentence-boundary heuristic. Returns whole sentences or nothing —
|
|
33
|
+
// a half-sentence of context is what made the old routing comment worthless, so
|
|
34
|
+
// detail that doesn't fit is dropped, not clipped. Any complete sentence is worth
|
|
35
|
+
// keeping, however short: the boundary check already rules out fragments.
|
|
36
|
+
function sentencesWithin(text, maxChars) {
|
|
37
|
+
if (text.length <= maxChars) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
const clipped = text.slice(0, maxChars);
|
|
41
|
+
const sentenceEnd = Math.max(clipped.lastIndexOf('. '), clipped.lastIndexOf('! '), clipped.lastIndexOf('? '));
|
|
42
|
+
return sentenceEnd > 0 ? clipped.slice(0, sentenceEnd + 1) : undefined;
|
|
43
|
+
}
|
|
44
|
+
// As above, but for text we would rather shorten than lose. Falls back to a word
|
|
45
|
+
// boundary + ellipsis when the budget holds no complete sentence at all.
|
|
46
|
+
function trimToSentence(text, maxChars) {
|
|
47
|
+
const whole = sentencesWithin(text, maxChars);
|
|
48
|
+
if (whole !== undefined) {
|
|
49
|
+
return whole;
|
|
50
|
+
}
|
|
51
|
+
const capped = text.slice(0, maxChars - 3);
|
|
52
|
+
const wordEnd = capped.lastIndexOf(' ');
|
|
53
|
+
return `${(wordEnd > 0 ? capped.slice(0, wordEnd) : capped).trimEnd()}...`;
|
|
54
|
+
}
|
|
55
|
+
export function summarizeTriageReasoning(triageOutput, maxChars = 320) {
|
|
56
|
+
const candidate = sectionBody(triageOutput, 'Summary') ?? triageOutput;
|
|
25
57
|
const firstParagraph = candidate
|
|
26
58
|
.trim()
|
|
27
59
|
.split(/\n\s*\n/)
|
|
28
60
|
.map((paragraph) => paragraph
|
|
29
61
|
.split('\n')
|
|
30
|
-
.map((line) => stripMarkdownHeading(line)
|
|
31
|
-
.replace(/^[-*]\s+/, '')
|
|
32
|
-
.trim())
|
|
62
|
+
.map((line) => stripListMarker(stripMarkdownHeading(line)))
|
|
33
63
|
.filter(Boolean)
|
|
34
64
|
.join(' '))
|
|
35
65
|
.find(Boolean) ?? '';
|
|
@@ -37,12 +67,89 @@ export function summarizeTriageReasoning(triageOutput, maxChars = 220) {
|
|
|
37
67
|
if (!normalized) {
|
|
38
68
|
return 'See the triage summary above for details.';
|
|
39
69
|
}
|
|
40
|
-
|
|
41
|
-
|
|
70
|
+
return trimToSentence(normalized, maxChars);
|
|
71
|
+
}
|
|
72
|
+
const NEXT_STEP_MAX_CHARS = 300;
|
|
73
|
+
// Anchored on purpose: the verdict is the leading word of the line ("Medium — ...").
|
|
74
|
+
// A loose substring search matches "low" inside "low-volume", "workflow" and "flow",
|
|
75
|
+
// which turned ~25% of agreeing issues into false mismatch alerts.
|
|
76
|
+
const SEVERITY_VERDICT = /^\W*(?:priority[:\s]+)?(critical|high|medium|low)\b/i;
|
|
77
|
+
function severityRank(value) {
|
|
78
|
+
return value.match(SEVERITY_VERDICT)?.[1]?.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
function labelSeverity(labels) {
|
|
81
|
+
const label = labels.find((l) => l.trim().toLowerCase().startsWith('severity:'));
|
|
82
|
+
return label ? severityRank(label.split(':')[1] ?? '') : undefined;
|
|
83
|
+
}
|
|
84
|
+
function firstLineOf(triageOutput, heading) {
|
|
85
|
+
return sectionBody(triageOutput, heading)
|
|
86
|
+
?.split('\n')
|
|
87
|
+
.map((line) => stripListMarker(line))
|
|
88
|
+
.find(Boolean);
|
|
89
|
+
}
|
|
90
|
+
// Triage agrees with the `Severity:*` label ~93% of the time, so restating the
|
|
91
|
+
// assessed priority is noise. The 7% where it disagrees is the part worth reading
|
|
92
|
+
// — and it skews toward triage rating the issue *worse* than the label.
|
|
93
|
+
function buildSeverityMismatchLine(triageOutput, labels, maxChars) {
|
|
94
|
+
const priority = firstLineOf(triageOutput, 'Issue Priority Assessment');
|
|
95
|
+
const assessed = priority ? severityRank(priority) : undefined;
|
|
96
|
+
const labelled = labelSeverity(labels);
|
|
97
|
+
if (!priority || !assessed || !labelled || assessed === labelled) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const alert = `Labelled \`Severity:${labelled}\`, triage assessed ${assessed}`;
|
|
101
|
+
const rationale = sentencesWithin(priority.replace(/^\W*\w+\s*[—–-]\s*/, '').trim(), maxChars);
|
|
102
|
+
return rationale ? `- ⚠️ **${alert}** — ${rationale}` : `- ⚠️ **${alert}.**`;
|
|
103
|
+
}
|
|
104
|
+
// The ping only fires when the fix pipeline declined the issue, but the team can't
|
|
105
|
+
// see that from the outside. Naming the state plus why no PR exists saves them from
|
|
106
|
+
// re-deriving it, and turns `insufficient-context` into an explicit ask.
|
|
107
|
+
function buildStatusLine(assessment, maxChars) {
|
|
108
|
+
if (!assessment) {
|
|
109
|
+
return undefined;
|
|
42
110
|
}
|
|
43
|
-
|
|
111
|
+
const state = assessment.classification === 'insufficient-context'
|
|
112
|
+
? 'Blocked, no PR opened'
|
|
113
|
+
: assessment.classification === 'needs-human'
|
|
114
|
+
? 'Needs a human, no PR opened'
|
|
115
|
+
: `No PR opened (${assessment.confidence} confidence)`;
|
|
116
|
+
const scope = sentencesWithin(assessment.scope.replace(/\s+/g, ' ').trim(), maxChars);
|
|
117
|
+
return scope ? `- **${state}** — ${scope}` : `- **${state}.**`;
|
|
44
118
|
}
|
|
45
|
-
|
|
119
|
+
// ~1 in 6 triages concludes the code lives outside the repos the labels resolved to.
|
|
120
|
+
// Without this the pinged team opens the issue, decides it isn't theirs, and bounces it.
|
|
121
|
+
function buildRepoMismatchLine(assessment, resolvedRepos) {
|
|
122
|
+
const targetRepo = assessment?.targetRepo?.trim();
|
|
123
|
+
if (!targetRepo || resolvedRepos.length === 0) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
const matches = resolvedRepos.some((repo) => repo.toLowerCase() === targetRepo.toLowerCase());
|
|
127
|
+
if (matches) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
return `- ⚠️ **Likely in \`${targetRepo}\`**, not ${resolvedRepos.map((repo) => `\`${repo}\``).join(' / ')} — may belong to another team.`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Compact recap for the hero routing ping. The full triage comment sits directly
|
|
134
|
+
* above it with `## Summary` visible, so this deliberately carries only what that
|
|
135
|
+
* summary doesn't: the exceptions worth alerting on, and the next concrete action.
|
|
136
|
+
*/
|
|
137
|
+
export function buildTriageRecap({ triageOutput, assessment, labels = [], resolvedRepos = [], maxChars = 220, }) {
|
|
138
|
+
const nextStep = firstLineOf(triageOutput, 'Suggested Triage Action');
|
|
139
|
+
const lines = [
|
|
140
|
+
buildSeverityMismatchLine(triageOutput, labels, maxChars),
|
|
141
|
+
buildRepoMismatchLine(assessment, resolvedRepos),
|
|
142
|
+
buildStatusLine(assessment, maxChars),
|
|
143
|
+
// The action is the payload, so it gets a wider budget than supporting
|
|
144
|
+
// detail and keeps ellipsis as a last resort rather than being dropped.
|
|
145
|
+
nextStep && `- **Next step:** ${trimToSentence(nextStep, NEXT_STEP_MAX_CHARS)}`,
|
|
146
|
+
].filter(Boolean);
|
|
147
|
+
if (lines.length === 0) {
|
|
148
|
+
return summarizeTriageReasoning(triageOutput);
|
|
149
|
+
}
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
export function buildHeroRoutingComment({ labels, primaryRepo, triageOutput, assessment, resolvedRepos, }) {
|
|
46
153
|
if (!hasProductLabel(labels)) {
|
|
47
154
|
return CX_NO_PRODUCT_LABEL_COMMENT;
|
|
48
155
|
}
|
|
@@ -52,7 +159,9 @@ export function buildHeroRoutingComment({ labels, primaryRepo, triageOutput, })
|
|
|
52
159
|
}
|
|
53
160
|
return `${HERO_ROUTING_MARKER}
|
|
54
161
|
|
|
55
|
-
Routing to ${heroGroup.mention} for triage.
|
|
162
|
+
Routing to ${heroGroup.mention} for triage.
|
|
163
|
+
|
|
164
|
+
${buildTriageRecap({ triageOutput, assessment, labels, resolvedRepos })}`;
|
|
56
165
|
}
|
|
57
166
|
export function shouldPostHeroRoutingComment(fixAssessment) {
|
|
58
167
|
if (!fixAssessment) {
|
|
@@ -289,6 +289,8 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
289
289
|
? resolution.repositories[0]
|
|
290
290
|
: undefined,
|
|
291
291
|
triageOutput: validated.output,
|
|
292
|
+
assessment: fixAssessment,
|
|
293
|
+
resolvedRepos: resolution.repositories.map((repo) => repo.fullName),
|
|
292
294
|
});
|
|
293
295
|
await postTriageRouting({
|
|
294
296
|
octokit: writeOctokit,
|
|
@@ -10,22 +10,30 @@ import { buildGeminiFlashReviewSummaryModel } from '../summary-model.js';
|
|
|
10
10
|
import { dedupeMultiFocusFindings } from './dedupe.js';
|
|
11
11
|
import { buildGeminiFlashCredential, getApiKeys, getAvailableModels, getEnabledReviewModels, getOpenRouterReviewCredential, getProviderFailures, requestModelReview, } from './shared.js';
|
|
12
12
|
const DEFAULT_REVIEW_MODELS_BY_FOCUS = {
|
|
13
|
-
general: ['deepseek', '
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
general: ['deepseek', 'grok', 'openai'],
|
|
14
|
+
security: ['deepseek', 'grok', 'openai'],
|
|
15
|
+
reuse: ['deepseek', 'grok', 'openai'],
|
|
16
|
+
quality: ['deepseek', 'grok', 'openai'],
|
|
17
|
+
efficiency: ['deepseek', 'grok', 'openai'],
|
|
18
|
+
tests: ['deepseek', 'grok'],
|
|
19
|
+
standards: ['deepseek', 'grok'],
|
|
18
20
|
};
|
|
19
21
|
// Default Gemini-flash pass on failure. tests/standards keep no fallback unless
|
|
20
22
|
// a caller opts into a wider fallback set.
|
|
21
23
|
const DEFAULT_GEMINI_FLASH_FALLBACK_FOCUSES = [
|
|
22
24
|
'general',
|
|
25
|
+
'security',
|
|
26
|
+
'reuse',
|
|
23
27
|
'quality',
|
|
24
28
|
'efficiency',
|
|
25
29
|
];
|
|
26
30
|
// Open models whose failed pass triggers that fallback.
|
|
27
|
-
const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', '
|
|
28
|
-
|
|
31
|
+
const GEMINI_FLASH_FALLBACK_MODELS = ['deepseek', 'grok'];
|
|
32
|
+
function lowPrioritySummaryNotice(count) {
|
|
33
|
+
return count === 1
|
|
34
|
+
? 'I also left one optional follow-up note in the details below.'
|
|
35
|
+
: 'I also included a few optional follow-up notes in the details below.';
|
|
36
|
+
}
|
|
29
37
|
// Workflow phase orchestrating the council fan-out; one model span per pass.
|
|
30
38
|
const MULTI_FOCUS_PHASE = 'review.multi-focus';
|
|
31
39
|
const FOCUS_PASS_PHASE = 'review.focus-pass';
|
|
@@ -203,18 +211,18 @@ function appendSummaryOnlyFindings(summary, summaryFindings) {
|
|
|
203
211
|
return summary;
|
|
204
212
|
}
|
|
205
213
|
const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
|
|
206
|
-
return `${summary.trim()}\n\n${
|
|
214
|
+
return `${summary.trim()}\n\n${lowPrioritySummaryNotice(notes.length)}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
|
|
207
215
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
// Summary-only (P3) findings are deliberately excluded: they already render in
|
|
217
|
+
// the "Optional follow-up note" details block, and feeding them here made the
|
|
218
|
+
// summary restate a single P3 nit under "Few things worth tightening:".
|
|
219
|
+
function buildSummarySynthesisFindings(inlineFindings) {
|
|
220
|
+
return Array.from(new Set(inlineFindings.map((finding) => finding.body.trim()).filter(Boolean)));
|
|
213
221
|
}
|
|
214
222
|
async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, reviewObservations, summaryModel, summaryFallbackModels, workspaceDir, }) {
|
|
215
223
|
const comments = convertFindingsToComments(inlineFindings, lineLookup);
|
|
216
224
|
const inlineCommentFindings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
|
|
217
|
-
const findings = buildSummarySynthesisFindings(
|
|
225
|
+
const findings = buildSummarySynthesisFindings(inlineFindings);
|
|
218
226
|
const hasReviewFindings = inlineFindings.length > 0 || summaryFindings.length > 0;
|
|
219
227
|
if (!hasReviewFindings) {
|
|
220
228
|
logger.info('Review summary synthesis skipped for clean review', {
|
|
@@ -339,7 +347,7 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
339
347
|
const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
|
|
340
348
|
const geminiSummaryFallback = buildGeminiFlashReviewSummaryModel(apiKeys.gemini);
|
|
341
349
|
const summaryModel = ctx.reviewSummaryModel ??
|
|
342
|
-
(apiKeys.
|
|
350
|
+
(apiKeys.grok ? { provider: 'grok', credential: apiKeys.grok } : undefined);
|
|
343
351
|
const review = await buildMultiFocusReviewOutput({
|
|
344
352
|
inlineFindings: routedFindings.inlineFindings,
|
|
345
353
|
summaryFindings: routedFindings.summaryFindings,
|
|
@@ -22,6 +22,8 @@ export function getAvailableModels(apiKeys) {
|
|
|
22
22
|
models.push('openai');
|
|
23
23
|
if (hasReviewProviderCredential(apiKeys.deepseek))
|
|
24
24
|
models.push('deepseek');
|
|
25
|
+
if (hasReviewProviderCredential(apiKeys.grok))
|
|
26
|
+
models.push('grok');
|
|
25
27
|
if (hasReviewProviderCredential(apiKeys.zai))
|
|
26
28
|
models.push('zai');
|
|
27
29
|
return models;
|
|
@@ -14,6 +14,8 @@ const MULTI_FOCUS_BASE_PROMPT_FILE = `${REVIEW_PROMPTS_DIR}/review-multi-focus-b
|
|
|
14
14
|
const REVIEW_FOCUS_PROMPTS_DIR = `${REVIEW_PROMPTS_DIR}/review-focus-prompts`;
|
|
15
15
|
export const REVIEW_FOCUS_DEFINITIONS = [
|
|
16
16
|
{ name: 'general', title: 'General', fileName: 'general.md' },
|
|
17
|
+
{ name: 'security', title: 'Security', fileName: 'security.md' },
|
|
18
|
+
{ name: 'reuse', title: 'Reuse', fileName: 'reuse.md' },
|
|
17
19
|
{ name: 'quality', title: 'Quality', fileName: 'quality.md' },
|
|
18
20
|
{ name: 'efficiency', title: 'Efficiency', fileName: 'efficiency.md' },
|
|
19
21
|
{ name: 'tests', title: 'Tests', fileName: 'tests.md' },
|
|
@@ -39,7 +39,7 @@ ${findingsBlock}
|
|
|
39
39
|
${observationsBlock}
|
|
40
40
|
</review_observations>
|
|
41
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.
|
|
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 inline issues were flagged.
|
|
43
43
|
|
|
44
44
|
Structure:
|
|
45
45
|
- Start with one short sentence giving a concise overview of what changed.
|
|
@@ -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, provider = '
|
|
54
|
+
export async function summarizeReviewFindings({ credential, provider = 'grok', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
|
|
55
55
|
const prompt = buildReviewSummaryPrompt({
|
|
56
56
|
prTitle,
|
|
57
57
|
prBody,
|
|
@@ -15,7 +15,8 @@ import { REVIEW_FOCUS_DEFINITIONS } from './multi-focus-prompt.js';
|
|
|
15
15
|
import { appendReviewFeedbackLink } from './review-feedback.js';
|
|
16
16
|
import { runSharedReview } from './runner.js';
|
|
17
17
|
const REVIEW_EVENT = 'COMMENT';
|
|
18
|
-
const
|
|
18
|
+
const DEFAULT_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
|
|
19
|
+
const SUPPORTED_PRODUCTION_REVIEW_MODELS = ['openai', 'deepseek', 'grok', 'zai'];
|
|
19
20
|
const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
|
|
20
21
|
function optionalEnv(name) {
|
|
21
22
|
const value = process.env[name]?.trim();
|
|
@@ -43,8 +44,8 @@ function parseContext() {
|
|
|
43
44
|
export function parseReviewModelsEnv(value) {
|
|
44
45
|
return parseCsvAllowList({
|
|
45
46
|
value,
|
|
46
|
-
allowedValues:
|
|
47
|
-
fallback:
|
|
47
|
+
allowedValues: SUPPORTED_PRODUCTION_REVIEW_MODELS,
|
|
48
|
+
fallback: DEFAULT_PRODUCTION_REVIEW_MODELS,
|
|
48
49
|
envName: 'REVIEW_MODELS_ENABLED',
|
|
49
50
|
});
|
|
50
51
|
}
|
|
@@ -253,6 +254,7 @@ async function main() {
|
|
|
253
254
|
}
|
|
254
255
|
if (ctx.openRouterApiKey) {
|
|
255
256
|
reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
|
|
257
|
+
reviewApiKeys.grok = createOpenRouterCredential(ctx.openRouterApiKey);
|
|
256
258
|
reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
|
|
257
259
|
}
|
|
258
260
|
if (ctx.openaiApiKey) {
|
|
@@ -6,7 +6,7 @@ import { genAiProviderName, withPhase } from '../../core/tracing.js';
|
|
|
6
6
|
import { resolveReviewProviderCredential } from '../../providers/credentials.js';
|
|
7
7
|
import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
|
|
8
8
|
const REVIEW_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL = 'medium';
|
|
10
10
|
// One model span per summary attempt; a fallback chain emits one span per model.
|
|
11
11
|
const SUMMARY_PHASE = 'review.summary';
|
|
12
12
|
const DIFF_SIDE = {
|
|
@@ -56,7 +56,7 @@ function getRequestFailureReason(result) {
|
|
|
56
56
|
? 'no_assistant_response'
|
|
57
57
|
: 'request_failed';
|
|
58
58
|
}
|
|
59
|
-
export async function requestReviewSummary({ credential, provider = '
|
|
59
|
+
export async function requestReviewSummary({ credential, provider = 'grok', modelName, prompt, workspaceDir, }) {
|
|
60
60
|
const resolvedCredential = resolveReviewProviderCredential(credential);
|
|
61
61
|
if (!resolvedCredential) {
|
|
62
62
|
return failedReviewSummaryRequest({ reason: 'missing_credential' });
|
|
@@ -93,12 +93,13 @@ export async function requestReviewSummary({ credential, provider = 'zai', model
|
|
|
93
93
|
}
|
|
94
94
|
async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedModelName, logModelName, prompt, workspaceDir, }) {
|
|
95
95
|
try {
|
|
96
|
+
const thinkingLevel = provider === 'grok' ? 'high' : DEFAULT_REVIEW_SUMMARY_THINKING_LEVEL;
|
|
96
97
|
logger.info('Review summary model request', {
|
|
97
98
|
provider,
|
|
98
99
|
modelName: logModelName,
|
|
99
100
|
promptLength: prompt.length,
|
|
100
101
|
timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
|
|
101
|
-
thinkingLevel
|
|
102
|
+
thinkingLevel,
|
|
102
103
|
});
|
|
103
104
|
const result = await invokePiPrompt({
|
|
104
105
|
provider,
|
|
@@ -111,7 +112,7 @@ async function runReviewSummaryRequest({ provider, resolvedCredential, resolvedM
|
|
|
111
112
|
cwd: workspaceDir ?? getWorkspaceDir(),
|
|
112
113
|
timeoutMs: REVIEW_SUMMARY_TIMEOUT_MS,
|
|
113
114
|
capabilityPreset: 'read-only',
|
|
114
|
-
thinkingLevel
|
|
115
|
+
thinkingLevel,
|
|
115
116
|
});
|
|
116
117
|
if (!result.ok) {
|
|
117
118
|
const reason = getRequestFailureReason(result);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { ReviewHygieneDecisionKind, ReviewHygieneExclusionReason, ReviewHygieneReasonCode, } from 'doistbot-async-routing-contracts';
|
|
2
|
+
import { optionalPositiveInt, parseBaseContext, requiredEnv, } from '../../core/shared.js';
|
|
3
|
+
export function parseReviewSlicePlanContext(env = process.env) {
|
|
4
|
+
const dryRun = env.DRY_RUN?.toLowerCase() === 'true';
|
|
5
|
+
const commentId = optionalPositiveInt(env.COMMENT_ID);
|
|
6
|
+
if (!dryRun && commentId === undefined) {
|
|
7
|
+
throw new Error('Missing required environment variable: COMMENT_ID');
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
...parseBaseContext(env),
|
|
11
|
+
baseBranch: requiredEnv('BASE_BRANCH', env),
|
|
12
|
+
headSha: requiredEnv('HEAD_SHA', env),
|
|
13
|
+
commentId,
|
|
14
|
+
runId: requiredEnv('SLICE_PLAN_RUN_ID', env),
|
|
15
|
+
reviewHygiene: parseReviewHygieneDecision(requiredEnv('REVIEW_HYGIENE_DECISION', env)),
|
|
16
|
+
geminiApiKey: requiredEnv('GEMINI_API_KEY', env),
|
|
17
|
+
dryRun,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function parseReviewHygieneDecision(serialized) {
|
|
21
|
+
let value;
|
|
22
|
+
try {
|
|
23
|
+
value = JSON.parse(serialized);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new Error(`Invalid REVIEW_HYGIENE_DECISION JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
if (!isRecord(value)) {
|
|
29
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: expected an object');
|
|
30
|
+
}
|
|
31
|
+
if (value.kind !== ReviewHygieneDecisionKind.Warn &&
|
|
32
|
+
value.kind !== ReviewHygieneDecisionKind.Block) {
|
|
33
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: expected warn or block');
|
|
34
|
+
}
|
|
35
|
+
if (!isReviewHygieneStats(value.stats) ||
|
|
36
|
+
!Array.isArray(value.reasons) ||
|
|
37
|
+
!value.reasons.every(isReviewHygieneReason)) {
|
|
38
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: missing stats or reasons');
|
|
39
|
+
}
|
|
40
|
+
if (value.exclusionSummary !== undefined &&
|
|
41
|
+
!isReviewHygieneExclusionSummary(value.exclusionSummary)) {
|
|
42
|
+
throw new Error('Invalid REVIEW_HYGIENE_DECISION: invalid exclusion summary');
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
function isReviewHygieneReason(value) {
|
|
47
|
+
return (isRecord(value) &&
|
|
48
|
+
Object.values(ReviewHygieneReasonCode).includes(value.code) &&
|
|
49
|
+
isFiniteNumber(value.actual) &&
|
|
50
|
+
isFiniteNumber(value.limit));
|
|
51
|
+
}
|
|
52
|
+
function isReviewHygieneExclusionSummary(value) {
|
|
53
|
+
return (isRecord(value) &&
|
|
54
|
+
['files', 'additions', 'deletions', 'changedLines', 'reviewLoadLines'].every((key) => isFiniteNumber(value[key])) &&
|
|
55
|
+
Array.isArray(value.reasons) &&
|
|
56
|
+
value.reasons.every((reason) => Object.values(ReviewHygieneExclusionReason).includes(reason)));
|
|
57
|
+
}
|
|
58
|
+
function isFiniteNumber(value) {
|
|
59
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
60
|
+
}
|
|
61
|
+
function isReviewHygieneStats(value) {
|
|
62
|
+
if (!isRecord(value)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return ['additions', 'deletions', 'changedFiles', 'changedLines', 'reviewLoadLines'].every((key) => isFiniteNumber(value[key]));
|
|
66
|
+
}
|
|
67
|
+
function isRecord(value) {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Ajv } from 'ajv';
|
|
2
|
+
import { extractJsonCandidatesFromResponse } from '../../providers/helpers.js';
|
|
3
|
+
export const ReviewSliceStrategy = {
|
|
4
|
+
Stack: 'stack',
|
|
5
|
+
Independent: 'independent',
|
|
6
|
+
Hybrid: 'hybrid',
|
|
7
|
+
NoSafeSplit: 'no_safe_split',
|
|
8
|
+
};
|
|
9
|
+
const REVIEW_SLICE_PLAN_SCHEMA = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
properties: {
|
|
13
|
+
strategy: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
enum: Object.values(ReviewSliceStrategy),
|
|
16
|
+
},
|
|
17
|
+
summary: { type: 'string', minLength: 1, maxLength: 1_000 },
|
|
18
|
+
slices: {
|
|
19
|
+
type: 'array',
|
|
20
|
+
maxItems: 12,
|
|
21
|
+
items: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
properties: {
|
|
25
|
+
title: { type: 'string', minLength: 1, maxLength: 160 },
|
|
26
|
+
purpose: { type: 'string', minLength: 1, maxLength: 800 },
|
|
27
|
+
files: {
|
|
28
|
+
type: 'array',
|
|
29
|
+
minItems: 1,
|
|
30
|
+
items: { type: 'string', minLength: 1, maxLength: 500 },
|
|
31
|
+
},
|
|
32
|
+
changes: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
minItems: 1,
|
|
35
|
+
maxItems: 8,
|
|
36
|
+
items: { type: 'string', minLength: 1, maxLength: 800 },
|
|
37
|
+
},
|
|
38
|
+
validation: {
|
|
39
|
+
type: 'array',
|
|
40
|
+
minItems: 1,
|
|
41
|
+
maxItems: 8,
|
|
42
|
+
items: { type: 'string', minLength: 1, maxLength: 500 },
|
|
43
|
+
},
|
|
44
|
+
dependsOn: {
|
|
45
|
+
anyOf: [{ type: 'integer', minimum: 1, maximum: 12 }, { type: 'null' }],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ['title', 'purpose', 'files', 'changes', 'validation', 'dependsOn'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
caveats: {
|
|
52
|
+
type: 'array',
|
|
53
|
+
maxItems: 8,
|
|
54
|
+
items: { type: 'string', minLength: 1, maxLength: 800 },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ['strategy', 'summary', 'slices', 'caveats'],
|
|
58
|
+
};
|
|
59
|
+
const ajv = new Ajv();
|
|
60
|
+
const validateReviewSlicePlanSchema = ajv.compile(REVIEW_SLICE_PLAN_SCHEMA);
|
|
61
|
+
const AGENT_SPLIT_SUGGESTION = 'You can use your agent of choice (Codex/Claude etc) to help you split this PR 😊 Just copy the link to this comment and ask them `Can you please create a PR stack based on the suggestions in this comment`';
|
|
62
|
+
export function parseReviewSlicePlanOutput(output, changedFiles) {
|
|
63
|
+
const candidates = extractJsonCandidatesFromResponse(output);
|
|
64
|
+
let lastError = 'Model response did not contain a JSON object';
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(candidate);
|
|
68
|
+
if (!validateReviewSlicePlanSchema(parsed)) {
|
|
69
|
+
lastError = `Model response did not match the slice-plan schema: ${ajv.errorsText(validateReviewSlicePlanSchema.errors)}`;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const plan = parsed;
|
|
73
|
+
validateReviewSlicePlanSemantics(plan, changedFiles);
|
|
74
|
+
return plan;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new Error(lastError);
|
|
81
|
+
}
|
|
82
|
+
export function validateReviewSlicePlanSemantics(plan, changedFiles) {
|
|
83
|
+
if (plan.strategy === ReviewSliceStrategy.NoSafeSplit) {
|
|
84
|
+
if (plan.slices.length !== 0) {
|
|
85
|
+
throw new Error('A no_safe_split plan must not include slices');
|
|
86
|
+
}
|
|
87
|
+
if (plan.caveats.length === 0) {
|
|
88
|
+
throw new Error('A no_safe_split plan must explain the blocking coupling in caveats');
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (plan.slices.length < 2) {
|
|
93
|
+
throw new Error('A slicing plan must contain at least two slices');
|
|
94
|
+
}
|
|
95
|
+
const changedPaths = new Set(changedFiles.map((file) => file.filename));
|
|
96
|
+
const coveredPaths = new Set();
|
|
97
|
+
const titles = new Set();
|
|
98
|
+
for (const [index, slice] of plan.slices.entries()) {
|
|
99
|
+
const sliceNumber = index + 1;
|
|
100
|
+
const normalizedTitle = slice.title.trim().toLowerCase();
|
|
101
|
+
if (titles.has(normalizedTitle)) {
|
|
102
|
+
throw new Error(`Slice ${sliceNumber} duplicates another slice title`);
|
|
103
|
+
}
|
|
104
|
+
titles.add(normalizedTitle);
|
|
105
|
+
const uniqueSlicePaths = new Set(slice.files);
|
|
106
|
+
if (uniqueSlicePaths.size !== slice.files.length) {
|
|
107
|
+
throw new Error(`Slice ${sliceNumber} contains duplicate file paths`);
|
|
108
|
+
}
|
|
109
|
+
for (const file of slice.files) {
|
|
110
|
+
if (!changedPaths.has(file)) {
|
|
111
|
+
throw new Error(`Slice ${sliceNumber} references an unchanged file: ${file}`);
|
|
112
|
+
}
|
|
113
|
+
coveredPaths.add(file);
|
|
114
|
+
}
|
|
115
|
+
if (slice.dependsOn !== null && slice.dependsOn >= sliceNumber) {
|
|
116
|
+
throw new Error(`Slice ${sliceNumber} must only depend on an earlier slice`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const uncoveredPaths = [...changedPaths].filter((path) => !coveredPaths.has(path));
|
|
120
|
+
if (uncoveredPaths.length > 0) {
|
|
121
|
+
throw new Error(`Slice plan does not cover ${uncoveredPaths.length} changed file(s): ${uncoveredPaths
|
|
122
|
+
.slice(0, 10)
|
|
123
|
+
.join(', ')}`);
|
|
124
|
+
}
|
|
125
|
+
if (plan.strategy === ReviewSliceStrategy.Independent &&
|
|
126
|
+
plan.slices.some((slice) => slice.dependsOn !== null)) {
|
|
127
|
+
throw new Error('An independent plan cannot contain slice dependencies');
|
|
128
|
+
}
|
|
129
|
+
if (plan.strategy === ReviewSliceStrategy.Stack) {
|
|
130
|
+
for (const [index, slice] of plan.slices.entries()) {
|
|
131
|
+
const expectedDependency = index === 0 ? null : index;
|
|
132
|
+
if (slice.dependsOn !== expectedDependency) {
|
|
133
|
+
throw new Error('A stack plan must make each slice depend on its immediate predecessor');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function renderReviewSlicePlan(plan, options) {
|
|
139
|
+
const compact = options.compact === true;
|
|
140
|
+
if (plan.strategy === ReviewSliceStrategy.NoSafeSplit) {
|
|
141
|
+
const explanation = [plan.summary, ...plan.caveats].join(' ');
|
|
142
|
+
return [
|
|
143
|
+
'<details>',
|
|
144
|
+
'<summary><h3>🪄 Suggested slicing plan 👇</h3></summary>',
|
|
145
|
+
'',
|
|
146
|
+
sanitizeMarkdown(explanation, compact ? 1_200 : 8_000),
|
|
147
|
+
'',
|
|
148
|
+
'A human familiar with the change should identify an intermediate, buildable boundary before splitting this PR.',
|
|
149
|
+
'',
|
|
150
|
+
'</details>',
|
|
151
|
+
'',
|
|
152
|
+
AGENT_SPLIT_SUGGESTION,
|
|
153
|
+
].join('\n');
|
|
154
|
+
}
|
|
155
|
+
const branchNames = plan.slices.map((slice, index) => buildSuggestedBranchName(index + 1, slice.title));
|
|
156
|
+
const lines = [
|
|
157
|
+
'<details>',
|
|
158
|
+
'<summary><h3>🪄 Suggested slicing plan 👇</h3></summary>',
|
|
159
|
+
'',
|
|
160
|
+
sanitizeMarkdown(plan.summary, compact ? 600 : 1_000),
|
|
161
|
+
'',
|
|
162
|
+
'**PR order**',
|
|
163
|
+
'',
|
|
164
|
+
...plan.slices.map((slice, index) => {
|
|
165
|
+
const base = slice.dependsOn === null ? options.baseBranch : branchNames[slice.dependsOn - 1];
|
|
166
|
+
return `${index + 1}. <code>${escapeHtml(branchNames[index])}</code> → base <code>${escapeHtml(base)}</code>`;
|
|
167
|
+
}),
|
|
168
|
+
'',
|
|
169
|
+
];
|
|
170
|
+
for (const [index, slice] of plan.slices.entries()) {
|
|
171
|
+
lines.push(`#### PR ${index + 1} ${sanitizeMarkdown(slice.title, 160)}`, '', sanitizeMarkdown(slice.purpose, compact ? 300 : 800), '', `**Files (${slice.files.length}):**`, '', ...formatFileList(slice.files, compact ? 8 : slice.files.length), '');
|
|
172
|
+
}
|
|
173
|
+
lines.push('> This plan is based on the current PR head. Keep each slice buildable and move tests with the behavior they cover.', '', '</details>', '', AGENT_SPLIT_SUGGESTION);
|
|
174
|
+
return lines.join('\n').trim();
|
|
175
|
+
}
|
|
176
|
+
function formatFileList(files, limit) {
|
|
177
|
+
const visibleFiles = files.slice(0, limit);
|
|
178
|
+
const lines = visibleFiles.map((file) => `- <code>${escapeHtml(truncate(file, 240))}</code>`);
|
|
179
|
+
if (files.length > visibleFiles.length) {
|
|
180
|
+
lines.push(`- …and ${files.length - visibleFiles.length} more files from this group`);
|
|
181
|
+
}
|
|
182
|
+
return lines;
|
|
183
|
+
}
|
|
184
|
+
function buildSuggestedBranchName(sliceNumber, title) {
|
|
185
|
+
const slug = title
|
|
186
|
+
.normalize('NFKD')
|
|
187
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
188
|
+
.replace(/^-+|-+$/g, '')
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.slice(0, 40);
|
|
191
|
+
return `slice-${sliceNumber}-${slug || 'change'}`;
|
|
192
|
+
}
|
|
193
|
+
function sanitizeMarkdown(value, maxLength) {
|
|
194
|
+
const truncated = truncate(value.replace(/\s+/g, ' ').trim(), maxLength);
|
|
195
|
+
return escapeHtml(truncated.replace(/([\\`*_{}[\]()#+.!|>])/g, '\\$1'));
|
|
196
|
+
}
|
|
197
|
+
function truncate(value, maxLength) {
|
|
198
|
+
if (value.length <= maxLength) {
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
202
|
+
}
|
|
203
|
+
function escapeHtml(value) {
|
|
204
|
+
return value
|
|
205
|
+
.replace(/&/g, '&')
|
|
206
|
+
.replace(/</g, '<')
|
|
207
|
+
.replace(/>/g, '>')
|
|
208
|
+
.replace(/@/g, '@');
|
|
209
|
+
}
|