@doist/doistbot-cli 1.0.1 → 1.0.3
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/review-usage.js +20 -0
- package/dist/actions/review.js +126 -10
- package/dist/actions/setup.js +3 -2
- package/dist/index.js +18 -8
- package/dist/runtime.js +3 -3
- package/dist/update-check.js +1 -1
- package/package.json +6 -5
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
- package/sandbox/dist/core/bot-logins.js +13 -0
- package/sandbox/dist/core/retry.js +29 -0
- package/sandbox/dist/core/shared.js +2 -2
- package/sandbox/dist/main.js +3 -0
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -7
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +157 -28
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
- package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
- package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
- package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
- package/sandbox/dist/tasks/issue-triage/output.js +24 -3
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
- package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +31 -10
- package/sandbox/dist/tasks/issue-triage/types.js +1 -0
- package/sandbox/dist/tasks/review/author-format.js +14 -0
- package/sandbox/dist/tasks/review/author-profile.js +67 -0
- package/sandbox/dist/tasks/review/check-run.js +8 -1
- package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
- package/sandbox/dist/tasks/review/conversation-context.js +174 -20
- package/sandbox/dist/tasks/review/council/summary.js +141 -12
- package/sandbox/dist/tasks/review/engines/council.js +19 -3
- package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
- package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
- package/sandbox/dist/tasks/review/engines/shared.js +28 -5
- package/sandbox/dist/tasks/review/finding-output.js +18 -0
- package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
- package/sandbox/dist/tasks/review/local.js +1 -0
- package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
- package/sandbox/dist/tasks/review/review.js +12 -2
- package/sandbox/dist/tasks/review/runner.js +36 -2
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
- package/sandbox/review-prompt.md +18 -1
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
- package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +0 -13
|
@@ -1,8 +1,104 @@
|
|
|
1
1
|
import { logger } from '../../../core/logging.js';
|
|
2
|
+
import { formatPrAuthorForGreeting, normalizePrAuthor } from '../author-format.js';
|
|
2
3
|
import { requestReviewSummary } from '../summary-model.js';
|
|
3
4
|
export const MAX_SUMMARY_BODY_CHARS = 2000;
|
|
4
|
-
|
|
5
|
+
const MAX_REVIEW_OBSERVATION_CHARS = 500;
|
|
6
|
+
const REVIEW_SUMMARY_EMOJIS = ['😊', '😇', '🤗', '😄 🙌', '😎 👊'];
|
|
7
|
+
const REVIEW_AUTHOR_PLACEHOLDER = 'PR_AUTHOR';
|
|
8
|
+
const GENERIC_REVIEW_SUMMARY_GREETING = 'Thanks for your contribution.';
|
|
9
|
+
function pickSummaryEmoji(prAuthor, seedContext) {
|
|
10
|
+
const normalizedAuthor = normalizePrAuthor(prAuthor);
|
|
11
|
+
const normalizedContext = seedContext?.trim();
|
|
12
|
+
const seed = [normalizedAuthor || 'doistbot-review', normalizedContext]
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join(':');
|
|
15
|
+
const hash = Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
16
|
+
return REVIEW_SUMMARY_EMOJIS[hash % REVIEW_SUMMARY_EMOJIS.length];
|
|
17
|
+
}
|
|
18
|
+
function escapeRegExp(value) {
|
|
19
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
20
|
+
}
|
|
21
|
+
function resolveAuthorGreetingName(prAuthor, prAuthorGreetingName) {
|
|
22
|
+
return formatPrAuthorForGreeting(prAuthorGreetingName) ?? formatPrAuthorForGreeting(prAuthor);
|
|
23
|
+
}
|
|
24
|
+
export function buildReviewSummaryGreeting(prAuthor, seedContext, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
|
|
25
|
+
if (excludePrAuthorFromSummary) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
const authorForGreeting = resolveAuthorGreetingName(prAuthor, prAuthorGreetingName);
|
|
29
|
+
const emoji = pickSummaryEmoji(prAuthor, seedContext);
|
|
30
|
+
if (!authorForGreeting) {
|
|
31
|
+
return `Thanks for your contribution ${emoji}.`;
|
|
32
|
+
}
|
|
33
|
+
return `Thanks ${authorForGreeting} for your contribution ${emoji}.`;
|
|
34
|
+
}
|
|
35
|
+
export function withReviewSummaryGreeting(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
|
|
36
|
+
const trimmedSummary = summary.trim();
|
|
37
|
+
if (excludePrAuthorFromSummary) {
|
|
38
|
+
return trimmedSummary;
|
|
39
|
+
}
|
|
40
|
+
const greeting = buildReviewSummaryGreeting(prAuthor, trimmedSummary, prAuthorGreetingName, excludePrAuthorFromSummary);
|
|
41
|
+
return trimmedSummary ? `${greeting} ${trimmedSummary}` : greeting;
|
|
42
|
+
}
|
|
43
|
+
function splitOpeningLine(summary) {
|
|
44
|
+
const firstLineBreak = summary.indexOf('\n');
|
|
45
|
+
if (firstLineBreak === -1) {
|
|
46
|
+
return { openingLine: summary, remainingText: '' };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
openingLine: summary.slice(0, firstLineBreak),
|
|
50
|
+
remainingText: summary.slice(firstLineBreak),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function normalizeSummarySpacing(summary) {
|
|
54
|
+
return summary
|
|
55
|
+
.replace(/[ \t]{2,}/g, ' ')
|
|
56
|
+
.replace(/\s+([.!?,;:])/g, '$1')
|
|
57
|
+
.trimEnd();
|
|
58
|
+
}
|
|
59
|
+
function replaceUnsafeAuthorPlaceholder(summary) {
|
|
60
|
+
const placeholderPattern = escapeRegExp(REVIEW_AUTHOR_PLACEHOLDER);
|
|
61
|
+
const genericGreeting = GENERIC_REVIEW_SUMMARY_GREETING.replace(/[.!?]+$/, '');
|
|
62
|
+
return normalizeSummarySpacing(summary
|
|
63
|
+
.replace(new RegExp(`\\bThanks\\s+${placeholderPattern}(?=\\s+for\\b)`, 'g'), 'Thanks')
|
|
64
|
+
.replace(new RegExp(`\\bThanks\\s+${placeholderPattern}(?=[.!?])`, 'g'), genericGreeting)
|
|
65
|
+
.replace(new RegExp(`\\bThank you\\s+${placeholderPattern}(?=\\s+for\\b)`, 'g'), 'Thank you')
|
|
66
|
+
.replace(new RegExp(`\\bThank you\\s+${placeholderPattern}(?=[.!?])`, 'g'), 'Thank you for your contribution')
|
|
67
|
+
.split(REVIEW_AUTHOR_PLACEHOLDER)
|
|
68
|
+
.join('the contributor'));
|
|
69
|
+
}
|
|
70
|
+
function replaceAuthorPlaceholders(summary, greetingName) {
|
|
71
|
+
if (!summary.includes(REVIEW_AUTHOR_PLACEHOLDER)) {
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
if (!greetingName) {
|
|
75
|
+
return replaceUnsafeAuthorPlaceholder(summary);
|
|
76
|
+
}
|
|
77
|
+
return summary.split(REVIEW_AUTHOR_PLACEHOLDER).join(greetingName);
|
|
78
|
+
}
|
|
79
|
+
function addEmojiToOpeningSentence(openingLine, emoji) {
|
|
80
|
+
const sentenceEndMatch = /[.!?](?=\s|$)/.exec(openingLine);
|
|
81
|
+
if (!sentenceEndMatch) {
|
|
82
|
+
return `${openingLine} ${emoji}`;
|
|
83
|
+
}
|
|
84
|
+
const sentenceEndIndex = sentenceEndMatch.index;
|
|
85
|
+
return `${openingLine.slice(0, sentenceEndIndex).trimEnd()} ${emoji}${openingLine.slice(sentenceEndIndex)}`;
|
|
86
|
+
}
|
|
87
|
+
export function ensureReviewSummaryApprovedEmoji(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary = false) {
|
|
88
|
+
const trimmedSummary = summary.trim();
|
|
89
|
+
if (!trimmedSummary || excludePrAuthorFromSummary) {
|
|
90
|
+
return trimmedSummary;
|
|
91
|
+
}
|
|
92
|
+
const emoji = pickSummaryEmoji(prAuthor, trimmedSummary);
|
|
93
|
+
const greetingName = resolveAuthorGreetingName(prAuthor, prAuthorGreetingName);
|
|
94
|
+
const summaryWithAuthor = replaceAuthorPlaceholders(trimmedSummary, greetingName);
|
|
95
|
+
const { openingLine: firstLineWithAuthor, remainingText: finalRemainingText } = splitOpeningLine(summaryWithAuthor);
|
|
96
|
+
const firstLineWithEmoji = addEmojiToOpeningSentence(firstLineWithAuthor, emoji);
|
|
97
|
+
return firstLineWithEmoji + finalRemainingText;
|
|
98
|
+
}
|
|
99
|
+
export function buildCouncilSummaryPrompt({ prTitle, prBody, findings, excludePrAuthorFromSummary = false, reviewObservations = [], }) {
|
|
5
100
|
const safeTitle = prTitle.trim() || 'Untitled';
|
|
101
|
+
const safeAuthor = excludePrAuthorFromSummary ? 'unknown' : REVIEW_AUTHOR_PLACEHOLDER;
|
|
6
102
|
const safeBody = prBody.trim();
|
|
7
103
|
const condensedBody = safeBody
|
|
8
104
|
? safeBody.slice(0, MAX_SUMMARY_BODY_CHARS)
|
|
@@ -12,8 +108,24 @@ export function buildCouncilSummaryPrompt({ prTitle, prBody, findings, }) {
|
|
|
12
108
|
.map((finding) => `<review-finding>\n${finding.trim()}\n</review-finding>`)
|
|
13
109
|
.join('\n\n')
|
|
14
110
|
: 'No issues were flagged in the inline comments.';
|
|
111
|
+
const observationsBlock = reviewObservations.length > 0
|
|
112
|
+
? reviewObservations
|
|
113
|
+
.map((observation) => observation.trim())
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.slice(0, 8)
|
|
116
|
+
.map((observation) => `<review-observation>\n${observation.slice(0, MAX_REVIEW_OBSERVATION_CHARS)}\n</review-observation>`)
|
|
117
|
+
.join('\n\n') || 'No additional review observations were captured.'
|
|
118
|
+
: 'No additional review observations were captured.';
|
|
15
119
|
return `You are an expert code review summarizer for the Doist engineering team.
|
|
16
120
|
|
|
121
|
+
<pr_author>
|
|
122
|
+
${safeAuthor}
|
|
123
|
+
</pr_author>
|
|
124
|
+
|
|
125
|
+
<pr_author_excluded_from_summary>
|
|
126
|
+
${excludePrAuthorFromSummary ? 'true' : 'false'}
|
|
127
|
+
</pr_author_excluded_from_summary>
|
|
128
|
+
|
|
17
129
|
<pr_title>
|
|
18
130
|
${safeTitle}
|
|
19
131
|
</pr_title>
|
|
@@ -26,22 +138,39 @@ ${condensedBody}
|
|
|
26
138
|
${findingsBlock}
|
|
27
139
|
</review_findings>
|
|
28
140
|
|
|
29
|
-
|
|
141
|
+
<review_observations>
|
|
142
|
+
${observationsBlock}
|
|
143
|
+
</review_observations>
|
|
144
|
+
|
|
145
|
+
Write the complete GitHub review summary body for the PR author. Synthesize the findings into grouped themes—do not mirror every inline comment one-for-one or reference the reviewers, models, council, focus passes, or review observations. Do not mention issues not present in the <review-finding> blocks. If there are no <review-finding> blocks, state that no issues were flagged.
|
|
146
|
+
|
|
147
|
+
If <pr_author_excluded_from_summary> is "true", do not thank, address, or name the PR author. Otherwise, start with a natural, concise thanks to the author using the literal placeholder ${REVIEW_AUTHOR_PLACEHOLDER}. Do not use a real user name, first name, login, handle, or @ mention. Vary the wording instead of always using the fixed template "Thanks ${REVIEW_AUTHOR_PLACEHOLDER} for your contribution".
|
|
148
|
+
|
|
149
|
+
Do not include emojis in the summary. The caller will replace ${REVIEW_AUTHOR_PLACEHOLDER} locally and add one approved emoji after the author name for non-excluded PR authors.
|
|
30
150
|
|
|
31
151
|
Structure:
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
- If issues exist,
|
|
152
|
+
- Start with one short sentence. When the PR author is not excluded, include the thanks and ${REVIEW_AUTHOR_PLACEHOLDER} in that sentence. When the author is excluded, start with a concise overview of what changed.
|
|
153
|
+
- If issues exist, add a blank line, then the exact heading "Few things worth tightening:", then 1-4 concise bullets grouping what to improve.
|
|
154
|
+
- If no issues exist, do not add a heading or bullets.
|
|
35
155
|
|
|
36
|
-
Keep it
|
|
156
|
+
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, constructive, appreciative tone without generic praise. Bullets should be short fragments or single sentences, not paragraphs. Do not include any <review-finding> or <review-observation> tags in the output.
|
|
37
157
|
|
|
38
158
|
Respond with only valid JSON in this exact shape:
|
|
39
159
|
{"summary":"<your summary>","comments":[]}`;
|
|
40
160
|
}
|
|
41
|
-
export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, findings, workspaceDir, requestReview = requestReviewSummary, }) {
|
|
42
|
-
const prompt = buildCouncilSummaryPrompt({
|
|
161
|
+
export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, findings, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
|
|
162
|
+
const prompt = buildCouncilSummaryPrompt({
|
|
163
|
+
prTitle,
|
|
164
|
+
prBody,
|
|
165
|
+
findings,
|
|
166
|
+
prAuthor,
|
|
167
|
+
prAuthorGreetingName,
|
|
168
|
+
excludePrAuthorFromSummary,
|
|
169
|
+
reviewObservations,
|
|
170
|
+
});
|
|
43
171
|
logger.info('Summarizing council reviews with the review summary model', {
|
|
44
172
|
findingsCount: findings.length,
|
|
173
|
+
reviewObservationsCount: reviewObservations?.length ?? 0,
|
|
45
174
|
});
|
|
46
175
|
const result = await requestReview({ apiKey, prompt, workspaceDir });
|
|
47
176
|
const summary = result?.summary?.trim() ?? '';
|
|
@@ -51,13 +180,13 @@ export async function summarizeCouncilReviews({ apiKey, prTitle, prBody, finding
|
|
|
51
180
|
});
|
|
52
181
|
return null;
|
|
53
182
|
}
|
|
54
|
-
return summary;
|
|
183
|
+
return ensureReviewSummaryApprovedEmoji(summary, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
|
|
55
184
|
}
|
|
56
|
-
export function buildFallbackSummary({ prTitle, findings, }) {
|
|
185
|
+
export function buildFallbackSummary({ prTitle, findings, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary, }) {
|
|
57
186
|
const safeTitle = prTitle.trim();
|
|
58
187
|
const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
|
|
59
188
|
if (findings.length === 0) {
|
|
60
|
-
return `${base} No issues were flagged in the reviewed diff
|
|
189
|
+
return withReviewSummaryGreeting(`${base} No issues were flagged in the reviewed diff.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
|
|
61
190
|
}
|
|
62
|
-
return `${base}
|
|
191
|
+
return withReviewSummaryGreeting(`${base}\n\nFew things worth tightening:\n- See the inline comments for the issues found in the review.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
|
|
63
192
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runCouncilReview } from '../council/orchestrator.js';
|
|
2
2
|
import { buildReviewOutputFromFindings, getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
|
|
3
|
-
async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, workspaceDir, apiKeys, onProgress, }) {
|
|
3
|
+
async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, workspaceDir, apiKeys, onProgress, }) {
|
|
4
4
|
const availableModels = getAvailableModels(apiKeys);
|
|
5
5
|
const model = availableModels[0];
|
|
6
6
|
if (!model) {
|
|
@@ -27,6 +27,10 @@ async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, works
|
|
|
27
27
|
lineLookup,
|
|
28
28
|
prTitle,
|
|
29
29
|
prBody,
|
|
30
|
+
prAuthor,
|
|
31
|
+
prAuthorGreetingName,
|
|
32
|
+
resolvePrAuthorGreetingName,
|
|
33
|
+
excludePrAuthorFromSummary,
|
|
30
34
|
summaryApiKey: apiKeys.gemini,
|
|
31
35
|
workspaceDir,
|
|
32
36
|
preferredSummary: reviewResult.summary,
|
|
@@ -34,7 +38,7 @@ async function runSingleModelReview({ prompt, lineLookup, prTitle, prBody, works
|
|
|
34
38
|
providerFailures: [],
|
|
35
39
|
};
|
|
36
40
|
}
|
|
37
|
-
async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, apiKeys, onProgress, }) {
|
|
41
|
+
async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, apiKeys, onProgress, }) {
|
|
38
42
|
const result = await runCouncilReview({
|
|
39
43
|
prompt,
|
|
40
44
|
apiKeys,
|
|
@@ -52,13 +56,17 @@ async function runMultiModelReview({ ctx, prompt, lineLookup, prTitle, prBody, a
|
|
|
52
56
|
lineLookup,
|
|
53
57
|
prTitle,
|
|
54
58
|
prBody,
|
|
59
|
+
prAuthor,
|
|
60
|
+
prAuthorGreetingName,
|
|
61
|
+
resolvePrAuthorGreetingName,
|
|
62
|
+
excludePrAuthorFromSummary,
|
|
55
63
|
summaryApiKey: apiKeys.gemini,
|
|
56
64
|
workspaceDir: ctx.workspaceDir,
|
|
57
65
|
}),
|
|
58
66
|
providerFailures: result.errors,
|
|
59
67
|
};
|
|
60
68
|
}
|
|
61
|
-
export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, onProgress, standardsSection: _standardsSection, }) {
|
|
69
|
+
export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, onProgress, standardsSection: _standardsSection, }) {
|
|
62
70
|
const apiKeys = getApiKeys(ctx);
|
|
63
71
|
const availableModels = getAvailableModels(apiKeys);
|
|
64
72
|
let metricModels = [];
|
|
@@ -88,6 +96,10 @@ export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle,
|
|
|
88
96
|
lineLookup,
|
|
89
97
|
prTitle,
|
|
90
98
|
prBody,
|
|
99
|
+
prAuthor,
|
|
100
|
+
prAuthorGreetingName,
|
|
101
|
+
resolvePrAuthorGreetingName,
|
|
102
|
+
excludePrAuthorFromSummary,
|
|
91
103
|
apiKeys,
|
|
92
104
|
onProgress,
|
|
93
105
|
})
|
|
@@ -96,6 +108,10 @@ export async function runCouncilReviewEngine({ ctx, prompt, lineLookup, prTitle,
|
|
|
96
108
|
lineLookup,
|
|
97
109
|
prTitle,
|
|
98
110
|
prBody,
|
|
111
|
+
prAuthor,
|
|
112
|
+
prAuthorGreetingName,
|
|
113
|
+
resolvePrAuthorGreetingName,
|
|
114
|
+
excludePrAuthorFromSummary,
|
|
99
115
|
workspaceDir: ctx.workspaceDir,
|
|
100
116
|
apiKeys,
|
|
101
117
|
onProgress,
|
|
@@ -55,7 +55,7 @@ function parseDedupeResponse(raw) {
|
|
|
55
55
|
.filter((group) => group.ids.length >= 2),
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
-
function logDedupeProgress(event) {
|
|
58
|
+
function logDedupeProgress(providerConfig, event) {
|
|
59
59
|
logPiProgressEvent({
|
|
60
60
|
event,
|
|
61
61
|
messages: {
|
|
@@ -67,7 +67,7 @@ function logDedupeProgress(event) {
|
|
|
67
67
|
toolUpdate: 'Multi-focus dedupe tool still running',
|
|
68
68
|
toolEnd: 'Multi-focus dedupe tool finished',
|
|
69
69
|
},
|
|
70
|
-
meta: { provider:
|
|
70
|
+
meta: { provider: providerConfig.provider, model: providerConfig.modelName },
|
|
71
71
|
logResponding: true,
|
|
72
72
|
logTools: true,
|
|
73
73
|
toolLogLevel: 'warn',
|
|
@@ -111,30 +111,39 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
|
|
|
111
111
|
return findings;
|
|
112
112
|
}
|
|
113
113
|
if (!availableModels.includes(DEDUPE_PROVIDER)) {
|
|
114
|
-
|
|
114
|
+
logger.error('Multi-focus dedupe unavailable because Gemini is not enabled', {
|
|
115
|
+
provider: DEDUPE_PROVIDER,
|
|
116
|
+
model: DEDUPE_MODEL_NAME,
|
|
117
|
+
});
|
|
118
|
+
throw new Error('Multi-focus dedupe requires Gemini to be enabled');
|
|
115
119
|
}
|
|
116
120
|
if (!geminiApiKey) {
|
|
117
|
-
logger.
|
|
121
|
+
logger.error('Multi-focus dedupe unavailable because Gemini API key is unavailable', {
|
|
118
122
|
provider: DEDUPE_PROVIDER,
|
|
119
123
|
model: DEDUPE_MODEL_NAME,
|
|
120
124
|
});
|
|
121
|
-
|
|
125
|
+
throw new Error('Multi-focus dedupe requires a Gemini API key');
|
|
122
126
|
}
|
|
127
|
+
const providerConfig = {
|
|
128
|
+
provider: DEDUPE_PROVIDER,
|
|
129
|
+
apiKey: geminiApiKey,
|
|
130
|
+
modelName: DEDUPE_MODEL_NAME,
|
|
131
|
+
};
|
|
123
132
|
for (let attempt = 1; attempt <= DEDUPE_MAX_ATTEMPTS; attempt += 1) {
|
|
124
133
|
let result;
|
|
125
134
|
try {
|
|
126
135
|
result = await invokePiPrompt({
|
|
127
|
-
provider:
|
|
128
|
-
apiKey:
|
|
136
|
+
provider: providerConfig.provider,
|
|
137
|
+
apiKey: providerConfig.apiKey,
|
|
129
138
|
prompt: buildDedupePrompt(findings),
|
|
130
139
|
cwd: workspaceDir,
|
|
131
140
|
timeoutMs: DEDUPE_TIMEOUT_MS,
|
|
132
141
|
capabilityPreset: 'read-only',
|
|
133
|
-
modelName:
|
|
142
|
+
modelName: providerConfig.modelName,
|
|
134
143
|
thinkingLevel: DEDUPE_THINKING_LEVEL,
|
|
135
144
|
systemPrompt: DEDUPE_SYSTEM_PROMPT,
|
|
136
145
|
onProgress: (event) => {
|
|
137
|
-
logDedupeProgress(event);
|
|
146
|
+
logDedupeProgress(providerConfig, event);
|
|
138
147
|
},
|
|
139
148
|
});
|
|
140
149
|
}
|
|
@@ -142,9 +151,9 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
|
|
|
142
151
|
const shouldRetry = attempt < DEDUPE_MAX_ATTEMPTS;
|
|
143
152
|
logger.warn(shouldRetry
|
|
144
153
|
? 'Multi-focus dedupe request threw, retrying'
|
|
145
|
-
: 'Multi-focus dedupe request threw
|
|
146
|
-
provider:
|
|
147
|
-
model:
|
|
154
|
+
: 'Multi-focus dedupe request threw', {
|
|
155
|
+
provider: providerConfig.provider,
|
|
156
|
+
model: providerConfig.modelName,
|
|
148
157
|
attempt,
|
|
149
158
|
attempts_total: DEDUPE_MAX_ATTEMPTS,
|
|
150
159
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -153,15 +162,15 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
|
|
|
153
162
|
await waitForDedupeRetry(attempt);
|
|
154
163
|
continue;
|
|
155
164
|
}
|
|
156
|
-
|
|
165
|
+
throw new Error(`Multi-focus dedupe request threw: ${error instanceof Error ? error.message : String(error)}`);
|
|
157
166
|
}
|
|
158
167
|
if (!result.ok) {
|
|
159
168
|
const shouldRetry = attempt < DEDUPE_MAX_ATTEMPTS;
|
|
160
169
|
logger.warn(shouldRetry
|
|
161
170
|
? 'Multi-focus dedupe request failed, retrying'
|
|
162
|
-
: 'Multi-focus dedupe request failed
|
|
163
|
-
provider:
|
|
164
|
-
model:
|
|
171
|
+
: 'Multi-focus dedupe request failed', {
|
|
172
|
+
provider: providerConfig.provider,
|
|
173
|
+
model: providerConfig.modelName,
|
|
165
174
|
attempt,
|
|
166
175
|
attempts_total: DEDUPE_MAX_ATTEMPTS,
|
|
167
176
|
error: result.error,
|
|
@@ -171,37 +180,37 @@ export async function dedupeMultiFocusFindings({ findings, availableModels, work
|
|
|
171
180
|
await waitForDedupeRetry(attempt);
|
|
172
181
|
continue;
|
|
173
182
|
}
|
|
174
|
-
|
|
183
|
+
throw new Error(`Multi-focus dedupe request failed: ${result.error}`);
|
|
175
184
|
}
|
|
176
185
|
if (hasToolUsage(result.toolUsage)) {
|
|
177
|
-
logger.
|
|
178
|
-
provider:
|
|
179
|
-
model:
|
|
186
|
+
logger.error('Multi-focus dedupe used tools', {
|
|
187
|
+
provider: providerConfig.provider,
|
|
188
|
+
model: providerConfig.modelName,
|
|
180
189
|
tool_usage: result.toolUsage,
|
|
181
190
|
});
|
|
182
|
-
|
|
191
|
+
throw new Error('Multi-focus dedupe used tools unexpectedly');
|
|
183
192
|
}
|
|
184
193
|
let parsed;
|
|
185
194
|
try {
|
|
186
195
|
parsed = parseDedupeResponse(result.output);
|
|
187
196
|
}
|
|
188
197
|
catch (error) {
|
|
189
|
-
logger.
|
|
190
|
-
provider:
|
|
191
|
-
model:
|
|
198
|
+
logger.error('Multi-focus dedupe parsing failed', {
|
|
199
|
+
provider: providerConfig.provider,
|
|
200
|
+
model: providerConfig.modelName,
|
|
192
201
|
error: error instanceof Error ? error.message : String(error),
|
|
193
202
|
});
|
|
194
|
-
|
|
203
|
+
throw new Error(`Multi-focus dedupe parsing failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
204
|
}
|
|
196
205
|
const deduped = applyDedupeGroups(findings, parsed.groups);
|
|
197
206
|
logger.info('Multi-focus dedupe completed', {
|
|
198
|
-
provider:
|
|
199
|
-
model:
|
|
207
|
+
provider: providerConfig.provider,
|
|
208
|
+
model: providerConfig.modelName,
|
|
200
209
|
originalCount: findings.length,
|
|
201
210
|
dedupedCount: deduped.length,
|
|
202
211
|
groupsCount: parsed.groups.length,
|
|
203
212
|
});
|
|
204
213
|
return deduped;
|
|
205
214
|
}
|
|
206
|
-
|
|
215
|
+
throw new Error('Multi-focus dedupe exhausted attempts without a result');
|
|
207
216
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { logger } from '../../../core/logging.js';
|
|
2
|
+
import { convertFindingsToComments } from '../comment-mapper.js';
|
|
3
|
+
import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
|
|
4
|
+
import { buildReviewOutputFindings } from '../finding-output.js';
|
|
2
5
|
import { buildMultiFocusPrompt, REVIEW_FOCUS_DEFINITIONS, } from '../multi-focus-prompt.js';
|
|
6
|
+
import { parseSeverity } from '../severity.js';
|
|
3
7
|
import { dedupeMultiFocusFindings } from './dedupe.js';
|
|
4
|
-
import {
|
|
8
|
+
import { getApiKeys, getAvailableModels, getProviderFailures, requestModelReview, } from './shared.js';
|
|
9
|
+
const LOW_PRIORITY_SUMMARY_NOTICE = 'I also included a few optional follow-up notes in the details below.';
|
|
5
10
|
async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKeys, workspaceDir, onProgress, }) {
|
|
6
11
|
const prompt = buildMultiFocusPrompt(basePrompt, focus, standardsSection);
|
|
7
12
|
const result = await requestModelReview({
|
|
@@ -41,13 +46,116 @@ async function runFocusPass({ focus, model, basePrompt, standardsSection, apiKey
|
|
|
41
46
|
status: 'succeeded',
|
|
42
47
|
focus: focus.name,
|
|
43
48
|
model,
|
|
49
|
+
summary: result.summary.trim(),
|
|
44
50
|
findings: result.findings.map((finding) => ({
|
|
45
51
|
...finding,
|
|
46
52
|
focus: focus.name,
|
|
47
53
|
})),
|
|
48
54
|
};
|
|
49
55
|
}
|
|
50
|
-
|
|
56
|
+
function routeLowPriorityFindings(findings, placement = 'summary') {
|
|
57
|
+
if (placement === 'inline') {
|
|
58
|
+
return { inlineFindings: findings, summaryFindings: [] };
|
|
59
|
+
}
|
|
60
|
+
const inlineFindings = [];
|
|
61
|
+
const summaryFindings = [];
|
|
62
|
+
for (const finding of findings) {
|
|
63
|
+
const severity = parseSeverity(finding.body);
|
|
64
|
+
if (severity === 'P3') {
|
|
65
|
+
summaryFindings.push(finding);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
inlineFindings.push(finding);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { inlineFindings, summaryFindings };
|
|
72
|
+
}
|
|
73
|
+
function buildNoInlineSummaryWithNotes(prTitle, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary) {
|
|
74
|
+
const safeTitle = prTitle.trim();
|
|
75
|
+
const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
|
|
76
|
+
return withReviewSummaryGreeting(`${base} No inline issues were flagged.`, prAuthor, prAuthorGreetingName, excludePrAuthorFromSummary);
|
|
77
|
+
}
|
|
78
|
+
function collectReviewObservations(focusRuns) {
|
|
79
|
+
const observations = [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
for (const run of focusRuns) {
|
|
82
|
+
if (run.status !== 'succeeded') {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const summary = run.summary.replace(/\s+/g, ' ').trim();
|
|
86
|
+
if (!summary) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const key = summary.toLowerCase();
|
|
90
|
+
if (seen.has(key)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
seen.add(key);
|
|
94
|
+
observations.push(`${run.focus}: ${summary}`);
|
|
95
|
+
}
|
|
96
|
+
return observations.slice(0, 8);
|
|
97
|
+
}
|
|
98
|
+
function formatSummaryOnlyFinding(finding) {
|
|
99
|
+
const severity = parseSeverity(finding.body);
|
|
100
|
+
const bodyWithoutSeverity = finding.body.replace(/^\s*\[P\d\]\s*/i, '').trim();
|
|
101
|
+
const normalizedBody = (bodyWithoutSeverity || finding.body).replace(/\s+/g, ' ').trim();
|
|
102
|
+
const location = finding.side === 'LEFT'
|
|
103
|
+
? `${finding.path}:${finding.line} (removed line)`
|
|
104
|
+
: `${finding.path}:${finding.line}`;
|
|
105
|
+
const label = severity ? `[${severity}] ${location}` : location;
|
|
106
|
+
return `- **${label}**: ${normalizedBody}`;
|
|
107
|
+
}
|
|
108
|
+
function appendSummaryOnlyFindings(summary, summaryFindings) {
|
|
109
|
+
if (summaryFindings.length === 0) {
|
|
110
|
+
return summary;
|
|
111
|
+
}
|
|
112
|
+
const notes = Array.from(new Set(summaryFindings.map((finding) => formatSummaryOnlyFinding(finding)).filter(Boolean)));
|
|
113
|
+
if (notes.length === 0) {
|
|
114
|
+
return summary;
|
|
115
|
+
}
|
|
116
|
+
const title = `Optional follow-up note${notes.length === 1 ? '' : 's'} (${notes.length})`;
|
|
117
|
+
return `${summary.trim()}\n\n${LOW_PRIORITY_SUMMARY_NOTICE}\n\n<details>\n<summary>${title}</summary>\n\n${notes.join('\n')}\n\n</details>`;
|
|
118
|
+
}
|
|
119
|
+
async function buildMultiFocusReviewOutput({ inlineFindings, summaryFindings, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, reviewObservations, summaryApiKey, workspaceDir, }) {
|
|
120
|
+
const comments = convertFindingsToComments(inlineFindings, lineLookup);
|
|
121
|
+
const findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
|
|
122
|
+
const resolvedPrAuthorGreetingName = excludePrAuthorFromSummary === true
|
|
123
|
+
? undefined
|
|
124
|
+
: (prAuthorGreetingName ?? (await resolvePrAuthorGreetingName?.()));
|
|
125
|
+
const synthesizedSummary = summaryApiKey == null
|
|
126
|
+
? null
|
|
127
|
+
: await summarizeCouncilReviews({
|
|
128
|
+
apiKey: summaryApiKey,
|
|
129
|
+
workspaceDir,
|
|
130
|
+
prTitle,
|
|
131
|
+
prBody,
|
|
132
|
+
prAuthor,
|
|
133
|
+
prAuthorGreetingName: resolvedPrAuthorGreetingName,
|
|
134
|
+
excludePrAuthorFromSummary,
|
|
135
|
+
findings,
|
|
136
|
+
reviewObservations,
|
|
137
|
+
});
|
|
138
|
+
const summaryWithoutNotes = synthesizedSummary ||
|
|
139
|
+
(comments.length === 0 && summaryFindings.length > 0
|
|
140
|
+
? buildNoInlineSummaryWithNotes(prTitle, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
|
|
141
|
+
: buildFallbackSummary({
|
|
142
|
+
prTitle,
|
|
143
|
+
findings,
|
|
144
|
+
prAuthor,
|
|
145
|
+
prAuthorGreetingName: resolvedPrAuthorGreetingName,
|
|
146
|
+
excludePrAuthorFromSummary,
|
|
147
|
+
}));
|
|
148
|
+
return {
|
|
149
|
+
summary: appendSummaryOnlyFindings(summaryWithoutNotes, summaryFindings),
|
|
150
|
+
comments,
|
|
151
|
+
findings: buildReviewOutputFindings({
|
|
152
|
+
inlineFindings,
|
|
153
|
+
summaryFindings,
|
|
154
|
+
lineLookup,
|
|
155
|
+
}),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, lowPriorityFindingPlacement, standardsSection, onProgress, }) {
|
|
51
159
|
const apiKeys = getApiKeys(ctx);
|
|
52
160
|
const availableModels = getAvailableModels(apiKeys);
|
|
53
161
|
const metricModels = availableModels.length > 0 ? [...availableModels] : [...ctx.councilModelsEnabled];
|
|
@@ -89,19 +197,25 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
89
197
|
};
|
|
90
198
|
}
|
|
91
199
|
const allFindings = successfulRuns.flatMap((run) => run.findings);
|
|
200
|
+
const reviewObservations = collectReviewObservations(successfulRuns);
|
|
92
201
|
const dedupedFindings = await dedupeMultiFocusFindings({
|
|
93
202
|
findings: allFindings,
|
|
94
203
|
availableModels,
|
|
95
204
|
workspaceDir: ctx.workspaceDir,
|
|
96
205
|
geminiApiKey: ctx.geminiApiKey,
|
|
97
|
-
openaiApiKey: ctx.openaiApiKey,
|
|
98
|
-
anthropicApiKey: ctx.anthropicApiKey,
|
|
99
206
|
});
|
|
100
|
-
const
|
|
101
|
-
|
|
207
|
+
const routedFindings = routeLowPriorityFindings(dedupedFindings, lowPriorityFindingPlacement);
|
|
208
|
+
const review = await buildMultiFocusReviewOutput({
|
|
209
|
+
inlineFindings: routedFindings.inlineFindings,
|
|
210
|
+
summaryFindings: routedFindings.summaryFindings,
|
|
102
211
|
lineLookup,
|
|
103
212
|
prTitle,
|
|
104
213
|
prBody,
|
|
214
|
+
prAuthor,
|
|
215
|
+
prAuthorGreetingName,
|
|
216
|
+
resolvePrAuthorGreetingName,
|
|
217
|
+
excludePrAuthorFromSummary,
|
|
218
|
+
reviewObservations,
|
|
105
219
|
summaryApiKey: apiKeys.gemini,
|
|
106
220
|
workspaceDir: ctx.workspaceDir,
|
|
107
221
|
});
|
|
@@ -111,6 +225,9 @@ export async function runMultiFocusReviewEngine({ ctx, prompt, lineLookup, prTit
|
|
|
111
225
|
successfulRuns: successfulRuns.length,
|
|
112
226
|
findingsCount: allFindings.length,
|
|
113
227
|
dedupedFindingsCount: dedupedFindings.length,
|
|
228
|
+
inlineFindingsCount: routedFindings.inlineFindings.length,
|
|
229
|
+
summaryOnlyFindingsCount: routedFindings.summaryFindings.length,
|
|
230
|
+
reviewObservationsCount: reviewObservations.length,
|
|
114
231
|
commentsCount: review.comments.length,
|
|
115
232
|
});
|
|
116
233
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getProvider } from '../../../providers/index.js';
|
|
2
2
|
import { convertFindingsToComments } from '../comment-mapper.js';
|
|
3
|
-
import { buildFallbackSummary, summarizeCouncilReviews } from '../council/summary.js';
|
|
3
|
+
import { buildFallbackSummary, summarizeCouncilReviews, withReviewSummaryGreeting, } from '../council/summary.js';
|
|
4
|
+
import { buildReviewOutputFindings } from '../finding-output.js';
|
|
4
5
|
import { getReviewThinkingLevel } from '../thinking-level.js';
|
|
5
6
|
export function getApiKeys(ctx) {
|
|
6
7
|
const apiKeys = {};
|
|
@@ -52,22 +53,44 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
|
|
|
52
53
|
return null;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
export async function buildReviewOutputFromFindings({ commentsSource, lineLookup, prTitle, prBody, summaryApiKey, workspaceDir, preferredSummary, }) {
|
|
56
|
+
export async function buildReviewOutputFromFindings({ commentsSource, lineLookup, prTitle, prBody, prAuthor, prAuthorGreetingName, resolvePrAuthorGreetingName, excludePrAuthorFromSummary, summaryApiKey, workspaceDir, preferredSummary, reviewObservations, }) {
|
|
56
57
|
const comments = convertFindingsToComments(commentsSource, lineLookup);
|
|
57
58
|
const findings = Array.from(new Set(comments.map((comment) => comment.body.trim()).filter(Boolean)));
|
|
58
59
|
const preferredSummaryText = preferredSummary?.trim();
|
|
59
|
-
const
|
|
60
|
+
const hasPreferredSummary = Boolean(preferredSummaryText);
|
|
61
|
+
const resolvedPrAuthorGreetingName = excludePrAuthorFromSummary === true
|
|
62
|
+
? undefined
|
|
63
|
+
: (prAuthorGreetingName ?? (await resolvePrAuthorGreetingName?.()));
|
|
64
|
+
const synthesizedSummary = hasPreferredSummary || summaryApiKey == null
|
|
60
65
|
? null
|
|
61
66
|
: await summarizeCouncilReviews({
|
|
62
67
|
apiKey: summaryApiKey,
|
|
63
68
|
workspaceDir,
|
|
64
69
|
prTitle,
|
|
65
70
|
prBody,
|
|
71
|
+
prAuthor,
|
|
72
|
+
prAuthorGreetingName: resolvedPrAuthorGreetingName,
|
|
73
|
+
excludePrAuthorFromSummary,
|
|
66
74
|
findings,
|
|
75
|
+
reviewObservations,
|
|
76
|
+
});
|
|
77
|
+
const summary = preferredSummaryText ||
|
|
78
|
+
synthesizedSummary ||
|
|
79
|
+
buildFallbackSummary({
|
|
80
|
+
prTitle,
|
|
81
|
+
findings,
|
|
82
|
+
prAuthor,
|
|
83
|
+
prAuthorGreetingName: resolvedPrAuthorGreetingName,
|
|
84
|
+
excludePrAuthorFromSummary,
|
|
67
85
|
});
|
|
68
|
-
const summary = preferredSummaryText || synthesizedSummary || buildFallbackSummary({ prTitle, findings });
|
|
69
86
|
return {
|
|
70
|
-
summary
|
|
87
|
+
summary: hasPreferredSummary
|
|
88
|
+
? withReviewSummaryGreeting(summary, prAuthor, resolvedPrAuthorGreetingName, excludePrAuthorFromSummary)
|
|
89
|
+
: summary,
|
|
71
90
|
comments,
|
|
91
|
+
findings: buildReviewOutputFindings({
|
|
92
|
+
inlineFindings: commentsSource,
|
|
93
|
+
lineLookup,
|
|
94
|
+
}),
|
|
72
95
|
};
|
|
73
96
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { canMapFindingToComment } from './comment-mapper.js';
|
|
2
|
+
import { parseSeverity } from './severity.js';
|
|
3
|
+
function convertFindingToOutputFinding(finding, placement) {
|
|
4
|
+
return {
|
|
5
|
+
body: finding.body,
|
|
6
|
+
severity: parseSeverity(finding.body),
|
|
7
|
+
placement,
|
|
8
|
+
path: finding.path,
|
|
9
|
+
line: finding.line,
|
|
10
|
+
side: finding.side,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function buildReviewOutputFindings({ inlineFindings, summaryFindings = [], lineLookup, }) {
|
|
14
|
+
return [
|
|
15
|
+
...inlineFindings.map((finding) => convertFindingToOutputFinding(finding, canMapFindingToComment(finding, lineLookup) ? 'inline' : 'summary')),
|
|
16
|
+
...summaryFindings.map((finding) => convertFindingToOutputFinding(finding, 'summary')),
|
|
17
|
+
];
|
|
18
|
+
}
|