@doist/doistbot-cli 1.0.2 → 1.0.4

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.
Files changed (54) hide show
  1. package/dist/actions/review-usage.js +6 -1
  2. package/dist/actions/review.js +121 -11
  3. package/dist/actions/setup.js +1 -1
  4. package/dist/index.js +36 -8
  5. package/dist/runtime.js +3 -3
  6. package/dist/update-check.js +1 -1
  7. package/package.json +2 -2
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  9. package/sandbox/dist/core/bot-logins.js +13 -0
  10. package/sandbox/dist/core/retry.js +29 -0
  11. package/sandbox/dist/core/shared.js +2 -2
  12. package/sandbox/dist/main.js +3 -0
  13. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +140 -13
  14. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  15. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  21. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  22. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  23. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  24. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  25. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  26. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  27. package/sandbox/dist/tasks/issue-triage/triage.js +30 -3
  28. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  29. package/sandbox/dist/tasks/review/author-format.js +14 -0
  30. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  31. package/sandbox/dist/tasks/review/check-run.js +8 -1
  32. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  33. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  34. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  35. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  36. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  37. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  38. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  39. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  40. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  41. package/sandbox/dist/tasks/review/local-git.js +3 -1
  42. package/sandbox/dist/tasks/review/local.js +1 -0
  43. package/sandbox/dist/tasks/review/multi-focus-prompt.js +0 -1
  44. package/sandbox/dist/tasks/review/review.js +12 -2
  45. package/sandbox/dist/tasks/review/runner.js +36 -2
  46. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -0
  47. package/sandbox/review-prompt.md +18 -1
  48. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +10 -6
  49. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +2 -0
  50. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +15 -1
  51. package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +2 -0
  52. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +14 -1
  53. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +31 -4
  54. 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 `doistbot/auto-fix/issue-${issueNumber}-${timestamp}`;
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
- createBranchAndCommit({
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
- token: input.githubToken,
217
- owner: input.repoOwner,
218
- repo: input.repoName,
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 { attemptFix } from './fix-attempt.js';
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.output}`;
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 attemptFix({
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 = { p0: 0, p1: 0, p2: 0, p3: 0, untagged: 0, total: 0 };
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 fileLines = lineLookup.get(finding.path);
10
- if (!fileLines) {
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: 'line not in diff',
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 byCreatedAtAscending(a, b) {
85
- if (a.createdAt === b.createdAt) {
91
+ function compareCreatedAt(a, b) {
92
+ if (a === b) {
86
93
  return 0;
87
94
  }
88
- if (a.createdAt === 'unknown') {
95
+ if (a === 'unknown') {
89
96
  return 1;
90
97
  }
91
- if (b.createdAt === 'unknown') {
98
+ if (b === 'unknown') {
92
99
  return -1;
93
100
  }
94
- return a.createdAt.localeCompare(b.createdAt);
101
+ return a.localeCompare(b);
95
102
  }
96
- function formatEntry(entry, index) {
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
- const line = entry.line == null ? 'unknown' : String(entry.line);
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 formatConversationEntries(entries) {
221
- return entries.map((entry, index) => formatEntry(entry, index)).join('\n\n---\n\n');
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 entries = [
257
- ...issueCommentEntries,
258
- ...reviewSummaryEntries,
259
- ...inlineCommentEntries,
260
- ].sort(byCreatedAtAscending);
261
- const rawContext = formatConversationEntries(entries);
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 (entries.length === 0) {
423
+ if (items.length === 0) {
270
424
  return emptyContext(baseStats);
271
425
  }
272
426
  if (rawChars <= rawContextThresholdChars) {