@doist/doistbot-cli 1.0.2 → 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.js +124 -9
- package/dist/actions/setup.js +1 -1
- package/dist/index.js +18 -8
- package/dist/runtime.js +3 -3
- package/dist/update-check.js +1 -1
- package/package.json +2 -2
- 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-triage/fix-attempt.js +140 -13
- 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 +30 -3
- 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
|
@@ -32,9 +32,15 @@ export function findPRTemplate(repoLocalPath) {
|
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
+
// TODO: extract into a shared module consumed by both `apps/sandbox` and
|
|
36
|
+
// `apps/webhook-handler`. The webhook handler hardcodes the same prefix in
|
|
37
|
+
// `apps/webhook-handler/src/tasks/promotion-ci-gate/index.ts`
|
|
38
|
+
// (`DOISTBOT_BRANCH_PREFIX`) to fast-path filter `check_suite.completed`
|
|
39
|
+
// events before any API call. If this value changes, update both sites.
|
|
40
|
+
const DOISTBOT_BRANCH_PREFIX = 'doistbot/';
|
|
35
41
|
function generateBranchName(issueNumber) {
|
|
36
42
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
37
|
-
return
|
|
43
|
+
return `${DOISTBOT_BRANCH_PREFIX}auto-fix/issue-${issueNumber}-${timestamp}`;
|
|
38
44
|
}
|
|
39
45
|
export async function resolveInstallationIdForRepo({ app, owner, repo, fallbackInstallationId, }) {
|
|
40
46
|
try {
|
|
@@ -61,19 +67,6 @@ const GIT_IDENTITY_ENV = {
|
|
|
61
67
|
GIT_COMMITTER_NAME: 'Doistbot',
|
|
62
68
|
GIT_COMMITTER_EMAIL: 'doistbot@users.noreply.github.com',
|
|
63
69
|
};
|
|
64
|
-
function createBranchAndCommit({ repoLocalPath, branch, issueNumber, issueTitle, prTitle, }) {
|
|
65
|
-
const titleLine = prTitle ?? `fix: ${issueTitle}`;
|
|
66
|
-
const commitMessage = `${titleLine} (#${issueNumber})\n\nAutomated fix generated by Doistbot based on triage analysis.\nIssue: #${issueNumber}`;
|
|
67
|
-
const execOptions = {
|
|
68
|
-
cwd: repoLocalPath,
|
|
69
|
-
encoding: 'utf8',
|
|
70
|
-
stdio: 'pipe',
|
|
71
|
-
env: { ...process.env, ...GIT_IDENTITY_ENV },
|
|
72
|
-
};
|
|
73
|
-
execFileSync('git', ['checkout', '-b', branch], execOptions);
|
|
74
|
-
// Files are already staged by captureGitDiff in fix-attempt.ts
|
|
75
|
-
execFileSync('git', ['commit', '-m', commitMessage], execOptions);
|
|
76
|
-
}
|
|
77
70
|
function pushBranch({ repoLocalPath, branch, token, owner, repo, }) {
|
|
78
71
|
const credentials = Buffer.from(`x-access-token:${token}`).toString('base64');
|
|
79
72
|
const remoteUrl = `https://github.com/${owner}/${repo}.git`;
|
|
@@ -90,6 +83,29 @@ function pushBranch({ repoLocalPath, branch, token, owner, repo, }) {
|
|
|
90
83
|
},
|
|
91
84
|
});
|
|
92
85
|
}
|
|
86
|
+
function commitAndPushBranch({ repoLocalPath, checkoutArgs, commitMessage, branch, githubToken, repoOwner, repoName, }) {
|
|
87
|
+
const execOptions = {
|
|
88
|
+
cwd: repoLocalPath,
|
|
89
|
+
encoding: 'utf8',
|
|
90
|
+
stdio: 'pipe',
|
|
91
|
+
env: { ...process.env, ...GIT_IDENTITY_ENV },
|
|
92
|
+
};
|
|
93
|
+
execFileSync('git', checkoutArgs, execOptions);
|
|
94
|
+
// Files are already staged by captureGitDiff in fix-attempt.ts
|
|
95
|
+
execFileSync('git', ['commit', '-m', commitMessage], execOptions);
|
|
96
|
+
pushBranch({
|
|
97
|
+
repoLocalPath,
|
|
98
|
+
branch,
|
|
99
|
+
token: githubToken,
|
|
100
|
+
owner: repoOwner,
|
|
101
|
+
repo: repoName,
|
|
102
|
+
});
|
|
103
|
+
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
104
|
+
cwd: repoLocalPath,
|
|
105
|
+
encoding: 'utf8',
|
|
106
|
+
stdio: 'pipe',
|
|
107
|
+
}).trim();
|
|
108
|
+
}
|
|
93
109
|
/**
|
|
94
110
|
* Strips markdown sections whose body contains no meaningful content.
|
|
95
111
|
* A section is considered empty when its body (between this heading and the
|
|
@@ -197,31 +213,23 @@ function buildPRBody({ issueNumber, issueUrl, triageAnalysis, fixDescription, fi
|
|
|
197
213
|
}
|
|
198
214
|
export async function commitAndPushFix(input) {
|
|
199
215
|
const branch = generateBranchName(input.issueNumber);
|
|
216
|
+
const titleLine = input.prTitle ?? `fix: ${input.issueTitle}`;
|
|
217
|
+
const commitMessage = `${titleLine} (#${input.issueNumber})\n\nAutomated fix generated by Doistbot based on triage analysis.\nIssue: #${input.issueNumber}`;
|
|
200
218
|
logger.info('Creating fix PR', {
|
|
201
219
|
task: 'issue-triage',
|
|
202
220
|
repository: `${input.repoOwner}/${input.repoName}`,
|
|
203
221
|
issueNumber: input.issueNumber,
|
|
204
222
|
branch,
|
|
205
223
|
});
|
|
206
|
-
|
|
207
|
-
repoLocalPath: input.repoLocalPath,
|
|
208
|
-
branch,
|
|
209
|
-
issueNumber: input.issueNumber,
|
|
210
|
-
issueTitle: input.issueTitle,
|
|
211
|
-
prTitle: input.prTitle,
|
|
212
|
-
});
|
|
213
|
-
pushBranch({
|
|
224
|
+
const headSha = commitAndPushBranch({
|
|
214
225
|
repoLocalPath: input.repoLocalPath,
|
|
226
|
+
checkoutArgs: ['checkout', '-b', branch],
|
|
227
|
+
commitMessage,
|
|
215
228
|
branch,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
229
|
+
githubToken: input.githubToken,
|
|
230
|
+
repoOwner: input.repoOwner,
|
|
231
|
+
repoName: input.repoName,
|
|
219
232
|
});
|
|
220
|
-
const headSha = execFileSync('git', ['rev-parse', 'HEAD'], {
|
|
221
|
-
cwd: input.repoLocalPath,
|
|
222
|
-
encoding: 'utf8',
|
|
223
|
-
stdio: 'pipe',
|
|
224
|
-
}).trim();
|
|
225
233
|
logger.info('Fix branch pushed, creating draft PR', {
|
|
226
234
|
task: 'issue-triage',
|
|
227
235
|
repository: `${input.repoOwner}/${input.repoName}`,
|
|
@@ -229,6 +237,40 @@ export async function commitAndPushFix(input) {
|
|
|
229
237
|
});
|
|
230
238
|
return { branch, headSha };
|
|
231
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Commits + pushes a second fix attempt on the EXISTING iter-0 branch. Unlike
|
|
242
|
+
* commitAndPushFix, this does not create a new branch — callers (the fix loop's
|
|
243
|
+
* iter-1 path) need the existing draft PR to pick up the new commit, which only
|
|
244
|
+
* works if we push to the same branch the PR was opened from.
|
|
245
|
+
*
|
|
246
|
+
* Assumes the working tree already has the iter-1 changes staged (typically by
|
|
247
|
+
* runFixCore's captureGitDiff `git add -A`) and that the repo is currently on
|
|
248
|
+
* `branch` (the case after iter-0 push + iter-1 `git reset --hard <iter0Sha>`).
|
|
249
|
+
*/
|
|
250
|
+
export async function commitAndPushIteration(input) {
|
|
251
|
+
const titleLine = input.prTitle ?? `fix: ${input.issueTitle}`;
|
|
252
|
+
const commitMessage = `${titleLine} (iteration) (#${input.issueNumber})\n\nFollow-up fix addressing prior review findings.\nIssue: #${input.issueNumber}`;
|
|
253
|
+
// Defensive: make sure we are on the iter-0 branch before committing. The
|
|
254
|
+
// iter-1 codex run does `git reset --hard <iter0Sha>` which preserves the
|
|
255
|
+
// current branch, so this should be a no-op, but a checkout makes the
|
|
256
|
+
// invariant explicit.
|
|
257
|
+
const headSha = commitAndPushBranch({
|
|
258
|
+
repoLocalPath: input.repoLocalPath,
|
|
259
|
+
checkoutArgs: ['checkout', input.branch],
|
|
260
|
+
commitMessage,
|
|
261
|
+
branch: input.branch,
|
|
262
|
+
githubToken: input.githubToken,
|
|
263
|
+
repoOwner: input.repoOwner,
|
|
264
|
+
repoName: input.repoName,
|
|
265
|
+
});
|
|
266
|
+
logger.info('Fix iteration pushed to existing branch', {
|
|
267
|
+
task: 'issue-triage',
|
|
268
|
+
repository: `${input.repoOwner}/${input.repoName}`,
|
|
269
|
+
branch: input.branch,
|
|
270
|
+
issueNumber: input.issueNumber,
|
|
271
|
+
});
|
|
272
|
+
return { headSha };
|
|
273
|
+
}
|
|
232
274
|
export async function openDraftFixPR(input) {
|
|
233
275
|
const prBody = input.prBody
|
|
234
276
|
? stripEmptySections(input.prBody)
|
|
@@ -5,6 +5,19 @@ export function isAutoFixEnabled(config) {
|
|
|
5
5
|
export function isAutoTriageEnabled(config) {
|
|
6
6
|
return config?.issue_triage?.enable_auto_triage !== false;
|
|
7
7
|
}
|
|
8
|
+
export function getAutoFixMode(config, enableAutoFixOverride) {
|
|
9
|
+
if (enableAutoFixOverride === false) {
|
|
10
|
+
return 'disabled';
|
|
11
|
+
}
|
|
12
|
+
if (enableAutoFixOverride !== true && !isAutoFixEnabled(config)) {
|
|
13
|
+
return 'disabled';
|
|
14
|
+
}
|
|
15
|
+
const useFixLoop = config?.issue_triage?.use_fix_loop;
|
|
16
|
+
if (useFixLoop !== undefined && useFixLoop !== true) {
|
|
17
|
+
return 'one-pass';
|
|
18
|
+
}
|
|
19
|
+
return 'fix-loop';
|
|
20
|
+
}
|
|
8
21
|
export function createRepoConfigLoader({ octokit, logger, }) {
|
|
9
22
|
const cache = new Map();
|
|
10
23
|
return async function loadRepoConfig(repository) {
|
|
@@ -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,7 +437,7 @@ 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,
|
|
@@ -445,6 +446,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
445
446
|
issueTitle: issue.title ?? 'Untitled issue',
|
|
446
447
|
issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
|
|
447
448
|
ghToken: issueInstallationAuth.token,
|
|
449
|
+
loadRepoConfig: getCachedRepoConfig,
|
|
448
450
|
});
|
|
449
451
|
logger.info('triage.fix_attempted', {
|
|
450
452
|
task: 'issue-triage',
|
|
@@ -459,6 +461,7 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
459
461
|
prCreated: fixResult.attempted && fixResult.prCreated,
|
|
460
462
|
reason: 'reason' in fixResult ? fixResult.reason : undefined,
|
|
461
463
|
prUrl: 'prUrl' in fixResult ? fixResult.prUrl : undefined,
|
|
464
|
+
pr_number: 'prNumber' in fixResult ? fixResult.prNumber : undefined,
|
|
462
465
|
durationMs: performance.now() - startedAt,
|
|
463
466
|
});
|
|
464
467
|
pendingFixMetric = {
|
|
@@ -466,6 +469,30 @@ async function runIssueTriagePipeline(ctx, mode) {
|
|
|
466
469
|
startedAt: pendingFixMetric.startedAt,
|
|
467
470
|
outcome: fixResult.attempted && fixResult.prCreated ? 'success' : 'failure',
|
|
468
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
|
+
}
|
|
469
496
|
}
|
|
470
497
|
else {
|
|
471
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) {
|