@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.
Files changed (54) hide show
  1. package/dist/actions/review-usage.js +20 -0
  2. package/dist/actions/review.js +126 -10
  3. package/dist/actions/setup.js +3 -2
  4. package/dist/index.js +18 -8
  5. package/dist/runtime.js +3 -3
  6. package/dist/update-check.js +1 -1
  7. package/package.json +6 -5
  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-fix-retry/fix-retry.js +1 -7
  14. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +157 -28
  15. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +190 -0
  16. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  17. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +464 -0
  18. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  19. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  20. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +110 -0
  21. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +227 -0
  22. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +165 -0
  23. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +32 -0
  24. package/sandbox/dist/tasks/issue-triage/markers.js +2 -0
  25. package/sandbox/dist/tasks/issue-triage/output.js +24 -3
  26. package/sandbox/dist/tasks/issue-triage/pr-creator.js +72 -30
  27. package/sandbox/dist/tasks/issue-triage/repo-config.js +13 -0
  28. package/sandbox/dist/tasks/issue-triage/triage.js +31 -10
  29. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  30. package/sandbox/dist/tasks/review/author-format.js +14 -0
  31. package/sandbox/dist/tasks/review/author-profile.js +67 -0
  32. package/sandbox/dist/tasks/review/check-run.js +8 -1
  33. package/sandbox/dist/tasks/review/comment-mapper.js +17 -12
  34. package/sandbox/dist/tasks/review/conversation-context.js +174 -20
  35. package/sandbox/dist/tasks/review/council/summary.js +141 -12
  36. package/sandbox/dist/tasks/review/engines/council.js +19 -3
  37. package/sandbox/dist/tasks/review/engines/dedupe.js +37 -28
  38. package/sandbox/dist/tasks/review/engines/multi-focus.js +123 -6
  39. package/sandbox/dist/tasks/review/engines/shared.js +28 -5
  40. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  41. package/sandbox/dist/tasks/review/github-comment-format.js +32 -0
  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
@@ -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,21 +437,16 @@ 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,
443
444
  fixAssessment,
444
- clonedRepos: clonedRepositories.map(({ repository, localPath }) => ({
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 = { 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) {