@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
|
@@ -8,7 +8,8 @@ import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
|
|
|
8
8
|
import { resolveIssueRepositories } from '../../core/repository-resolver.js';
|
|
9
9
|
import { REPOS_SUBDIR, WORKSPACE_DIR } from '../../core/shared.js';
|
|
10
10
|
import { parseIssueTriageContext } from './context.js';
|
|
11
|
-
import {
|
|
11
|
+
import { postAutoFixFailureComment, shouldPostAutoFixFailureComment } from './fix-attempt.js';
|
|
12
|
+
import { runConfiguredFix } from './fix-dispatch.js';
|
|
12
13
|
import { invokeTriageModel } from './model.js';
|
|
13
14
|
import { buildTriageFailureMessage, buildUnresolvedLabelsOutput, ensureStructuredTriageOutput, parseFixAssessment, REQUIRED_HEADINGS, } from './output.js';
|
|
14
15
|
import { fillTemplate, loadPromptTemplate } from './prompt.js';
|
|
@@ -394,7 +395,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
394
395
|
triageDurationMs = performance.now() - startedAt;
|
|
395
396
|
return;
|
|
396
397
|
}
|
|
397
|
-
const triageComment = `${TRIAGE_MARKER}\n\n${normalized.
|
|
398
|
+
const triageComment = `${TRIAGE_MARKER}\n\n${normalized.commentOutput}`;
|
|
398
399
|
await postFinalTriageComment(triageComment);
|
|
399
400
|
logger.info('Issue triage task completed successfully', {
|
|
400
401
|
task: 'issue-triage',
|
|
@@ -436,21 +437,16 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
436
437
|
startedAt: performance.now(),
|
|
437
438
|
outcome: 'failure',
|
|
438
439
|
};
|
|
439
|
-
const fixResult = await
|
|
440
|
+
const fixResult = await runConfiguredFix({
|
|
440
441
|
ctx,
|
|
441
442
|
octokit: writeOctokit,
|
|
442
443
|
triageOutput: normalized.output,
|
|
443
444
|
fixAssessment,
|
|
444
|
-
clonedRepos: clonedRepositories
|
|
445
|
-
owner: repository.owner,
|
|
446
|
-
repo: repository.repo,
|
|
447
|
-
fullName: repository.fullName,
|
|
448
|
-
localPath,
|
|
449
|
-
repository,
|
|
450
|
-
})),
|
|
445
|
+
clonedRepos: clonedRepositories,
|
|
451
446
|
issueTitle: issue.title ?? 'Untitled issue',
|
|
452
447
|
issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
|
|
453
448
|
ghToken: issueInstallationAuth.token,
|
|
449
|
+
loadRepoConfig: getCachedRepoConfig,
|
|
454
450
|
});
|
|
455
451
|
logger.info('triage.fix_attempted', {
|
|
456
452
|
task: 'issue-triage',
|
|
@@ -465,6 +461,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
465
461
|
prCreated: fixResult.attempted && fixResult.prCreated,
|
|
466
462
|
reason: 'reason' in fixResult ? fixResult.reason : undefined,
|
|
467
463
|
prUrl: 'prUrl' in fixResult ? fixResult.prUrl : undefined,
|
|
464
|
+
pr_number: 'prNumber' in fixResult ? fixResult.prNumber : undefined,
|
|
468
465
|
durationMs: performance.now() - startedAt,
|
|
469
466
|
});
|
|
470
467
|
pendingFixMetric = {
|
|
@@ -472,6 +469,30 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
472
469
|
startedAt: pendingFixMetric.startedAt,
|
|
473
470
|
outcome: fixResult.attempted && fixResult.prCreated ? 'success' : 'failure',
|
|
474
471
|
};
|
|
472
|
+
const shouldPostFailureComment = await shouldPostAutoFixFailureComment(fixResult, {
|
|
473
|
+
dryRun: ctx.dryRun,
|
|
474
|
+
octokit: writeOctokit,
|
|
475
|
+
ctx,
|
|
476
|
+
});
|
|
477
|
+
if (shouldPostFailureComment) {
|
|
478
|
+
try {
|
|
479
|
+
await postAutoFixFailureComment({
|
|
480
|
+
octokit: writeOctokit,
|
|
481
|
+
ctx,
|
|
482
|
+
reason: 'reason' in fixResult ? fixResult.reason : 'Unknown reason',
|
|
483
|
+
targetRepoFullName: fixResult.repository,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (commentError) {
|
|
487
|
+
logger.warn('Failed to post auto-fix failure comment', {
|
|
488
|
+
task: 'issue-triage',
|
|
489
|
+
issue_number: ctx.issueNumber,
|
|
490
|
+
error: commentError instanceof Error
|
|
491
|
+
? commentError.message
|
|
492
|
+
: String(commentError),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
}
|
|
475
496
|
}
|
|
476
497
|
else {
|
|
477
498
|
logger.info('triage.fix_attempted', {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const REVIEW_AUTHOR_EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2
|
+
export function normalizePrAuthor(prAuthor) {
|
|
3
|
+
return (prAuthor ?? '').trim().replace(/^@+/, '');
|
|
4
|
+
}
|
|
5
|
+
export function formatPrAuthorForGreeting(prAuthor) {
|
|
6
|
+
const normalizedAuthor = normalizePrAuthor(prAuthor);
|
|
7
|
+
if (!normalizedAuthor || normalizedAuthor.toLowerCase() === 'unknown') {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (REVIEW_AUTHOR_EMAIL_PATTERN.test(normalizedAuthor)) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return normalizedAuthor;
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { isKnownAutomationLogin } from '../../core/bot-logins.js';
|
|
2
|
+
import { logger } from '../../core/logging.js';
|
|
3
|
+
import { formatPrAuthorForGreeting, REVIEW_AUTHOR_EMAIL_PATTERN } from './author-format.js';
|
|
4
|
+
const URL_PATTERN = /^https?:\/\//i;
|
|
5
|
+
const UNSAFE_GREETING_CHARS = new Set([
|
|
6
|
+
'@',
|
|
7
|
+
'<',
|
|
8
|
+
'>',
|
|
9
|
+
'{',
|
|
10
|
+
'}',
|
|
11
|
+
'[',
|
|
12
|
+
']',
|
|
13
|
+
'(',
|
|
14
|
+
')',
|
|
15
|
+
'`',
|
|
16
|
+
'*',
|
|
17
|
+
'_',
|
|
18
|
+
'#',
|
|
19
|
+
'|',
|
|
20
|
+
'\\',
|
|
21
|
+
]);
|
|
22
|
+
const MAX_GREETING_NAME_CHARS = 40;
|
|
23
|
+
function hasUnsafeGreetingChar(value) {
|
|
24
|
+
return Array.from(value).some((char) => UNSAFE_GREETING_CHARS.has(char));
|
|
25
|
+
}
|
|
26
|
+
export function shouldExcludePrAuthorFromReviewSummary(login) {
|
|
27
|
+
const normalizedLogin = login?.trim().toLowerCase();
|
|
28
|
+
return isKnownAutomationLogin(normalizedLogin);
|
|
29
|
+
}
|
|
30
|
+
export function extractFirstNameFromProfileName(profileName) {
|
|
31
|
+
const normalized = profileName?.replace(/\s+/g, ' ').trim() ?? '';
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const firstToken = normalized.split(' ')[0]?.replace(/[.,;:!]+$/g, '') ?? '';
|
|
36
|
+
if (!firstToken ||
|
|
37
|
+
firstToken.length > MAX_GREETING_NAME_CHARS ||
|
|
38
|
+
REVIEW_AUTHOR_EMAIL_PATTERN.test(firstToken) ||
|
|
39
|
+
URL_PATTERN.test(firstToken) ||
|
|
40
|
+
hasUnsafeGreetingChar(firstToken) ||
|
|
41
|
+
!/\p{L}/u.test(firstToken)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return firstToken;
|
|
45
|
+
}
|
|
46
|
+
export async function resolvePrAuthorGreetingName({ octokit, login, }) {
|
|
47
|
+
const normalizedLogin = formatPrAuthorForGreeting(login);
|
|
48
|
+
if (!normalizedLogin || shouldExcludePrAuthorFromReviewSummary(normalizedLogin)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const response = await octokit.rest.users.getByUsername({ username: normalizedLogin });
|
|
53
|
+
const greetingName = extractFirstNameFromProfileName(response.data.name);
|
|
54
|
+
if (!greetingName) {
|
|
55
|
+
logger.info('PR author GitHub profile name unavailable for greeting', {
|
|
56
|
+
profile_name_available: Boolean(response.data.name?.trim()),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return greetingName ?? undefined;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
logger.warn('Failed to resolve PR author GitHub profile name', {
|
|
63
|
+
error: error instanceof Error ? error.message : String(error),
|
|
64
|
+
});
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -42,7 +42,14 @@ const SEVERITY_TO_COUNTER_KEY = {
|
|
|
42
42
|
* a recognizable tag are bucketed as `untagged`.
|
|
43
43
|
*/
|
|
44
44
|
export function countFindingsBySeverity(review) {
|
|
45
|
-
const counts = {
|
|
45
|
+
const counts = {
|
|
46
|
+
p0: 0,
|
|
47
|
+
p1: 0,
|
|
48
|
+
p2: 0,
|
|
49
|
+
p3: 0,
|
|
50
|
+
untagged: 0,
|
|
51
|
+
total: 0,
|
|
52
|
+
};
|
|
46
53
|
function increment(sev) {
|
|
47
54
|
if (sev)
|
|
48
55
|
counts[SEVERITY_TO_COUNTER_KEY[sev]] += 1;
|
|
@@ -2,25 +2,30 @@ import { logger } from '../../core/logging.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Converts model findings to review comments, filtering out invalid diff locations.
|
|
4
4
|
*/
|
|
5
|
+
function getInvalidFindingLocationReason(finding, lineLookup) {
|
|
6
|
+
const fileLines = lineLookup.get(finding.path);
|
|
7
|
+
if (!fileLines) {
|
|
8
|
+
return 'file not in diff';
|
|
9
|
+
}
|
|
10
|
+
const validLines = finding.side === 'LEFT' ? fileLines.old : fileLines.new;
|
|
11
|
+
if (!validLines.has(finding.line)) {
|
|
12
|
+
return 'line not in diff';
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
export function canMapFindingToComment(finding, lineLookup) {
|
|
17
|
+
return getInvalidFindingLocationReason(finding, lineLookup) == null;
|
|
18
|
+
}
|
|
5
19
|
export function convertFindingsToComments(findings, lineLookup) {
|
|
6
20
|
const comments = [];
|
|
7
21
|
const skipped = [];
|
|
8
22
|
for (const finding of findings) {
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
skipped.push({
|
|
12
|
-
path: finding.path,
|
|
13
|
-
line: finding.line,
|
|
14
|
-
reason: 'file not in diff',
|
|
15
|
-
});
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
const validLines = finding.side === 'LEFT' ? fileLines.old : fileLines.new;
|
|
19
|
-
if (!validLines.has(finding.line)) {
|
|
23
|
+
const invalidReason = getInvalidFindingLocationReason(finding, lineLookup);
|
|
24
|
+
if (invalidReason) {
|
|
20
25
|
skipped.push({
|
|
21
26
|
path: finding.path,
|
|
22
27
|
line: finding.line,
|
|
23
|
-
reason:
|
|
28
|
+
reason: invalidReason,
|
|
24
29
|
});
|
|
25
30
|
continue;
|
|
26
31
|
}
|
|
@@ -11,6 +11,7 @@ const CONVERSATION_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
11
11
|
const CONVERSATION_SUMMARY_TARGET_CHARS = 4_000;
|
|
12
12
|
const REVIEW_COMMAND_REGEX = /^@doistbot \/review$/i;
|
|
13
13
|
const GITHUB_ACTIONS_BOT_LOGIN = 'github-actions[bot]';
|
|
14
|
+
const MASKED_PR_AUTHOR_LABEL = 'PR author';
|
|
14
15
|
function getSummaryProviders(apiKeys) {
|
|
15
16
|
return [
|
|
16
17
|
...(apiKeys?.gemini ? [{ provider: 'gemini', apiKey: apiKeys.gemini }] : []),
|
|
@@ -31,6 +32,11 @@ function shouldIncludeEntry(login, body) {
|
|
|
31
32
|
const normalized = login.toLowerCase();
|
|
32
33
|
return !normalized.endsWith('[bot]') || isRelevantBotLogin(normalized);
|
|
33
34
|
}
|
|
35
|
+
function shouldMaskPrAuthor(login, prAuthor) {
|
|
36
|
+
const normalizedLogin = login.trim().toLowerCase();
|
|
37
|
+
const normalizedPrAuthor = prAuthor?.trim().toLowerCase();
|
|
38
|
+
return Boolean(normalizedLogin && normalizedPrAuthor && normalizedLogin === normalizedPrAuthor);
|
|
39
|
+
}
|
|
34
40
|
function cleanBody(body) {
|
|
35
41
|
return stripReviewFeedbackFooter(body);
|
|
36
42
|
}
|
|
@@ -77,33 +83,86 @@ function toInlineCommentEntry(comment) {
|
|
|
77
83
|
path: comment.path ?? undefined,
|
|
78
84
|
line: comment.line ?? comment.original_line ?? undefined,
|
|
79
85
|
side: comment.side ?? undefined,
|
|
86
|
+
id: comment.id ?? undefined,
|
|
80
87
|
replyToId: comment.in_reply_to_id ?? undefined,
|
|
81
88
|
body,
|
|
82
89
|
};
|
|
83
90
|
}
|
|
84
|
-
function
|
|
85
|
-
if (a
|
|
91
|
+
function compareCreatedAt(a, b) {
|
|
92
|
+
if (a === b) {
|
|
86
93
|
return 0;
|
|
87
94
|
}
|
|
88
|
-
if (a
|
|
95
|
+
if (a === 'unknown') {
|
|
89
96
|
return 1;
|
|
90
97
|
}
|
|
91
|
-
if (b
|
|
98
|
+
if (b === 'unknown') {
|
|
92
99
|
return -1;
|
|
93
100
|
}
|
|
94
|
-
return a.
|
|
101
|
+
return a.localeCompare(b);
|
|
95
102
|
}
|
|
96
|
-
|
|
103
|
+
// Groups inline comments into threads (a root finding plus its replies) using
|
|
104
|
+
// in_reply_to_id. Replies whose root is missing fall back to standalone entries.
|
|
105
|
+
function buildInlineThreads(entries, prAuthorThumbsDownRootIds) {
|
|
106
|
+
const roots = entries.filter((entry) => entry.replyToId == null);
|
|
107
|
+
const rootById = new Map();
|
|
108
|
+
for (const root of roots) {
|
|
109
|
+
if (root.id != null) {
|
|
110
|
+
rootById.set(root.id, {
|
|
111
|
+
root,
|
|
112
|
+
replies: [],
|
|
113
|
+
prAuthorReactedThumbsDown: prAuthorThumbsDownRootIds.has(root.id),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const orphans = [];
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
if (entry.replyToId == null) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const thread = rootById.get(entry.replyToId);
|
|
123
|
+
if (thread) {
|
|
124
|
+
thread.replies.push(entry);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
orphans.push(entry);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const threads = roots.flatMap((root) => {
|
|
131
|
+
const thread = root.id == null ? undefined : rootById.get(root.id);
|
|
132
|
+
if (!thread) {
|
|
133
|
+
// Root without a usable id can't anchor replies; render it standalone.
|
|
134
|
+
orphans.push(root);
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
thread.replies.sort((a, b) => compareCreatedAt(a.createdAt, b.createdAt));
|
|
138
|
+
return [thread];
|
|
139
|
+
});
|
|
140
|
+
return { threads, orphans };
|
|
141
|
+
}
|
|
142
|
+
function describeAuthor(author, prAuthor) {
|
|
143
|
+
const normalized = author.toLowerCase();
|
|
144
|
+
if (isKnownDoistbotLogin(normalized)) {
|
|
145
|
+
return `${author} (doistbot)`;
|
|
146
|
+
}
|
|
147
|
+
if (shouldMaskPrAuthor(author, prAuthor)) {
|
|
148
|
+
return MASKED_PR_AUTHOR_LABEL;
|
|
149
|
+
}
|
|
150
|
+
return author;
|
|
151
|
+
}
|
|
152
|
+
function formatLocation(entry) {
|
|
153
|
+
const line = entry.line == null ? 'unknown' : String(entry.line);
|
|
154
|
+
const side = entry.side ? ` ${entry.side}` : '';
|
|
155
|
+
return `${entry.path}:${line}${side}`;
|
|
156
|
+
}
|
|
157
|
+
function formatEntry(entry, index, prAuthor) {
|
|
97
158
|
const lines = [`### Entry ${index + 1}: ${entry.source.replace('_', ' ')}`];
|
|
98
|
-
lines.push(`- Author: ${entry.author}`);
|
|
159
|
+
lines.push(`- Author: ${describeAuthor(entry.author, prAuthor)}`);
|
|
99
160
|
lines.push(`- Created: ${entry.createdAt}`);
|
|
100
161
|
if (entry.state) {
|
|
101
162
|
lines.push(`- Review state: ${entry.state}`);
|
|
102
163
|
}
|
|
103
164
|
if (entry.path) {
|
|
104
|
-
|
|
105
|
-
const side = entry.side ? ` ${entry.side}` : '';
|
|
106
|
-
lines.push(`- Location: ${entry.path}:${line}${side}`);
|
|
165
|
+
lines.push(`- Location: ${formatLocation(entry)}`);
|
|
107
166
|
}
|
|
108
167
|
if (entry.replyToId != null) {
|
|
109
168
|
lines.push(`- Reply to inline comment ID: ${entry.replyToId}`);
|
|
@@ -115,6 +174,30 @@ function formatEntry(entry, index) {
|
|
|
115
174
|
lines.push(entry.body);
|
|
116
175
|
return lines.join('\n');
|
|
117
176
|
}
|
|
177
|
+
function formatThreadMessage(entry, prAuthor) {
|
|
178
|
+
return [`${describeAuthor(entry.author, prAuthor)} — ${entry.createdAt}:`, entry.body].join('\n');
|
|
179
|
+
}
|
|
180
|
+
function formatThread(thread, index, prAuthor) {
|
|
181
|
+
const { root, replies } = thread;
|
|
182
|
+
const location = root.path ? formatLocation(root) : 'unknown location';
|
|
183
|
+
const lines = [`### Entry ${index + 1}: prior inline thread on ${location}`];
|
|
184
|
+
const rootIsDoistbot = isKnownDoistbotLogin(root.author.toLowerCase());
|
|
185
|
+
const prAuthorReplied = prAuthor != null && replies.some((reply) => shouldMaskPrAuthor(reply.author, prAuthor));
|
|
186
|
+
if (rootIsDoistbot && thread.prAuthorReactedThumbsDown) {
|
|
187
|
+
lines.push('- Status: the PR author reacted 👎 to this prior doistbot finding. Treat this as author pushback / declined unless later discussion says otherwise.');
|
|
188
|
+
}
|
|
189
|
+
if (rootIsDoistbot && prAuthorReplied) {
|
|
190
|
+
lines.push('- Status: the PR author replied to this prior doistbot finding. Read their response to decide whether they accepted it or pushed back / declined it.');
|
|
191
|
+
}
|
|
192
|
+
if (root.url) {
|
|
193
|
+
lines.push(`- URL: ${root.url}`);
|
|
194
|
+
}
|
|
195
|
+
for (const message of [root, ...replies]) {
|
|
196
|
+
lines.push('');
|
|
197
|
+
lines.push(formatThreadMessage(message, prAuthor));
|
|
198
|
+
}
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
118
201
|
function buildStats(baseStats, { summarized = false, summarizationFailed = false, } = {}) {
|
|
119
202
|
return {
|
|
120
203
|
...baseStats,
|
|
@@ -138,6 +221,7 @@ Rules:
|
|
|
138
221
|
- Preserve line-specific context with file paths and line numbers when available.
|
|
139
222
|
- Distinguish resolved prior review feedback from unresolved or still-relevant feedback when the conversation makes that clear.
|
|
140
223
|
- Capture author intent, decisions, open questions, and previous findings that may affect a re-review.
|
|
224
|
+
- Explicitly preserve any prior finding the PR author pushed back on or declined (replied disagreeing, reacted 👎, called it intentional/by-design, deferred it as out-of-scope, or said won't-fix). Record the file path, the gist of the finding, and that the author declined it, so a re-review knows not to raise it again.
|
|
141
225
|
- Do not invent status or resolution when it is not stated.
|
|
142
226
|
- Keep the summary under ${CONVERSATION_SUMMARY_TARGET_CHARS} characters.
|
|
143
227
|
- Return markdown only; do not wrap the answer in code fences.
|
|
@@ -157,6 +241,9 @@ Coverage: <one sentence with the kinds of comments summarized>
|
|
|
157
241
|
### Previous review rounds
|
|
158
242
|
- ...
|
|
159
243
|
|
|
244
|
+
### Findings the author declined / pushed back on (do not re-raise)
|
|
245
|
+
- ...
|
|
246
|
+
|
|
160
247
|
### Open questions / unresolved discussion
|
|
161
248
|
- ...
|
|
162
249
|
|
|
@@ -217,10 +304,74 @@ async function summarizeConversationContextWithModel({ provider, apiKey, rawCont
|
|
|
217
304
|
return null;
|
|
218
305
|
}
|
|
219
306
|
}
|
|
220
|
-
function
|
|
221
|
-
|
|
307
|
+
function buildConversationItems(standalone, inlineEntries, prAuthorThumbsDownRootIds) {
|
|
308
|
+
const { threads, orphans } = buildInlineThreads(inlineEntries, prAuthorThumbsDownRootIds);
|
|
309
|
+
const items = [
|
|
310
|
+
...[...standalone, ...orphans].map((entry) => ({
|
|
311
|
+
kind: 'entry',
|
|
312
|
+
entry,
|
|
313
|
+
sortKey: entry.createdAt,
|
|
314
|
+
})),
|
|
315
|
+
...threads.map((thread) => ({
|
|
316
|
+
kind: 'thread',
|
|
317
|
+
thread,
|
|
318
|
+
sortKey: thread.root.createdAt,
|
|
319
|
+
})),
|
|
320
|
+
];
|
|
321
|
+
items.sort((a, b) => compareCreatedAt(a.sortKey, b.sortKey));
|
|
322
|
+
return items;
|
|
323
|
+
}
|
|
324
|
+
function formatConversationItems(items, prAuthor) {
|
|
325
|
+
return items
|
|
326
|
+
.map((item, index) => item.kind === 'thread'
|
|
327
|
+
? formatThread(item.thread, index, prAuthor)
|
|
328
|
+
: formatEntry(item.entry, index, prAuthor))
|
|
329
|
+
.join('\n\n---\n\n');
|
|
330
|
+
}
|
|
331
|
+
async function fetchPrAuthorThumbsDownRootIds({ octokit, owner, repo, prAuthor, inlineEntries, }) {
|
|
332
|
+
if (!prAuthor) {
|
|
333
|
+
return new Set();
|
|
334
|
+
}
|
|
335
|
+
const doistbotRootIds = inlineEntries.flatMap((entry) => {
|
|
336
|
+
if (entry.id == null ||
|
|
337
|
+
entry.replyToId != null ||
|
|
338
|
+
!isKnownDoistbotLogin(entry.author.toLowerCase())) {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
return [entry.id];
|
|
342
|
+
});
|
|
343
|
+
if (doistbotRootIds.length === 0) {
|
|
344
|
+
return new Set();
|
|
345
|
+
}
|
|
346
|
+
const normalizedPrAuthor = prAuthor.trim().toLowerCase();
|
|
347
|
+
const results = await Promise.allSettled(doistbotRootIds.map(async (commentId) => {
|
|
348
|
+
const reactions = (await octokit.paginate(octokit.rest.reactions.listForPullRequestReviewComment, {
|
|
349
|
+
owner,
|
|
350
|
+
repo,
|
|
351
|
+
comment_id: commentId,
|
|
352
|
+
per_page: 100,
|
|
353
|
+
}));
|
|
354
|
+
const hasPrAuthorThumbsDown = reactions.some((reaction) => {
|
|
355
|
+
const reactionAuthor = reaction.user?.login?.trim().toLowerCase();
|
|
356
|
+
return reaction.content === '-1' && reactionAuthor === normalizedPrAuthor;
|
|
357
|
+
});
|
|
358
|
+
return hasPrAuthorThumbsDown ? commentId : null;
|
|
359
|
+
}));
|
|
360
|
+
const thumbsDownRootIds = new Set();
|
|
361
|
+
for (const result of results) {
|
|
362
|
+
if (result.status === 'fulfilled') {
|
|
363
|
+
if (result.value != null) {
|
|
364
|
+
thumbsDownRootIds.add(result.value);
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
logger.warn('Failed to fetch review comment reactions for PR conversation context', {
|
|
369
|
+
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
return thumbsDownRootIds;
|
|
222
373
|
}
|
|
223
|
-
export async function fetchReviewConversationContext({ octokit, owner, repo, pullNumber, summaryApiKeys, workspaceDir, rawContextThresholdChars = RAW_CONVERSATION_CONTEXT_THRESHOLD_CHARS, summarizeConversationContext: summarize = summarizeConversationContextWithModel, }) {
|
|
374
|
+
export async function fetchReviewConversationContext({ octokit, owner, repo, pullNumber, prAuthor, summaryApiKeys, workspaceDir, rawContextThresholdChars = RAW_CONVERSATION_CONTEXT_THRESHOLD_CHARS, summarizeConversationContext: summarize = summarizeConversationContextWithModel, }) {
|
|
224
375
|
try {
|
|
225
376
|
const [issueComments, reviews, reviewComments] = await Promise.all([
|
|
226
377
|
fetchIssueComments(octokit, {
|
|
@@ -253,12 +404,15 @@ export async function fetchReviewConversationContext({ octokit, owner, repo, pul
|
|
|
253
404
|
const entry = toInlineCommentEntry(comment);
|
|
254
405
|
return entry ? [entry] : [];
|
|
255
406
|
});
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
407
|
+
const prAuthorThumbsDownRootIds = await fetchPrAuthorThumbsDownRootIds({
|
|
408
|
+
octokit,
|
|
409
|
+
owner,
|
|
410
|
+
repo,
|
|
411
|
+
prAuthor,
|
|
412
|
+
inlineEntries: inlineCommentEntries,
|
|
413
|
+
});
|
|
414
|
+
const items = buildConversationItems([...issueCommentEntries, ...reviewSummaryEntries], inlineCommentEntries, prAuthorThumbsDownRootIds);
|
|
415
|
+
const rawContext = formatConversationItems(items, prAuthor);
|
|
262
416
|
const rawChars = rawContext.length;
|
|
263
417
|
const baseStats = {
|
|
264
418
|
issueComments: issueCommentEntries.length,
|
|
@@ -266,7 +420,7 @@ export async function fetchReviewConversationContext({ octokit, owner, repo, pul
|
|
|
266
420
|
inlineComments: inlineCommentEntries.length,
|
|
267
421
|
rawChars,
|
|
268
422
|
};
|
|
269
|
-
if (
|
|
423
|
+
if (items.length === 0) {
|
|
270
424
|
return emptyContext(baseStats);
|
|
271
425
|
}
|
|
272
426
|
if (rawChars <= rawContextThresholdChars) {
|