@doist/doistbot-cli 1.0.0 → 1.0.2

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 (39) hide show
  1. package/dist/actions/auth.js +135 -28
  2. package/dist/actions/doctor.js +12 -9
  3. package/dist/actions/review-usage.js +20 -0
  4. package/dist/actions/review.js +2 -1
  5. package/dist/actions/setup.js +2 -1
  6. package/dist/auth.js +30 -9
  7. package/dist/config.js +1 -1
  8. package/dist/env.js +1 -1
  9. package/dist/file-store.js +112 -0
  10. package/dist/index.js +76 -25
  11. package/dist/input.js +22 -48
  12. package/dist/secure-store.js +105 -16
  13. package/dist/terminal.js +4 -1
  14. package/dist/test-utils/secure-store-mocks.js +29 -0
  15. package/dist/update-cache-entrypoint.js +16 -0
  16. package/dist/update-check.js +235 -0
  17. package/package.json +10 -7
  18. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +5 -2
  19. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +34 -23
  20. package/sandbox/dist/core/pi.js +0 -4
  21. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +1 -7
  22. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +148 -100
  23. package/sandbox/dist/tasks/issue-triage/pr-creator.js +16 -9
  24. package/sandbox/dist/tasks/issue-triage/repo-config.js +28 -2
  25. package/sandbox/dist/tasks/issue-triage/triage.js +31 -30
  26. package/sandbox/dist/tasks/review/check-run.js +134 -14
  27. package/sandbox/dist/tasks/review/conversation-context.js +316 -0
  28. package/sandbox/dist/tasks/review/engines/dedupe.js +82 -63
  29. package/sandbox/dist/tasks/review/local-git.js +7 -0
  30. package/sandbox/dist/tasks/review/multi-focus-prompt.js +2 -1
  31. package/sandbox/dist/tasks/review/prompt.js +15 -2
  32. package/sandbox/dist/tasks/review/review-feedback.js +4 -0
  33. package/sandbox/dist/tasks/review/review.js +39 -1
  34. package/sandbox/dist/tasks/review/runner.js +5 -1
  35. package/sandbox/dist/tasks/review/severity.js +5 -0
  36. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +5 -2
  37. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +34 -23
  38. package/sandbox/review-prompt.md +8 -2
  39. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +7 -2
@@ -1,6 +1,24 @@
1
1
  import { isAnotherDeliveryInProgress, postCheckRun } from '../../core/check-run.js';
2
+ import { logger } from '../../core/logging.js';
2
3
  import { appendReviewFeedbackLink } from './review-feedback.js';
4
+ import { parseSeverity } from './severity.js';
3
5
  const CHECK_RUN_NAME = 'doistbot/review';
6
+ /**
7
+ * Stale-pointer Check Run encoding — mirrors
8
+ * `apps/webhook-handler/src/tasks/review/stale-check-run.ts` so the two paths
9
+ * interoperate: a webhook-handler synchronize that lands after the sandbox
10
+ * posts a pointer here will find this pointer on `payload.before` and unwrap
11
+ * to the same `reviewedSha`. Kept in sync by convention; if we ever extract
12
+ * the upsert mechanics into a shared package, the constants move with it.
13
+ */
14
+ const STALE_POINTER_EXTERNAL_ID_PREFIX = 'stale-pointer:';
15
+ const STALE_TITLE_PREFIX = 'Stale: ';
16
+ const MAX_TITLE_LENGTH = 100;
17
+ function truncateTitle(value) {
18
+ if (value.length <= MAX_TITLE_LENGTH)
19
+ return value;
20
+ return `${value.slice(0, MAX_TITLE_LENGTH - 1)}…`;
21
+ }
4
22
  /**
5
23
  * Concurrency lock for the review task: returns `true` if a different
6
24
  * delivery's `in_progress` Check Run is already running on this SHA. Skips
@@ -9,24 +27,31 @@ const CHECK_RUN_NAME = 'doistbot/review';
9
27
  export function isAnotherReviewInProgress(params) {
10
28
  return isAnotherDeliveryInProgress({ ...params, name: CHECK_RUN_NAME });
11
29
  }
30
+ // Mapped type (not `Record<Severity, Lowercase<Severity>>`) so each key is
31
+ // tied to its own lowercase value. `{ P0: 'p1', ... }` would type-check
32
+ // under the looser `Record` form; this one rejects it.
33
+ const SEVERITY_TO_COUNTER_KEY = {
34
+ P0: 'p0',
35
+ P1: 'p1',
36
+ P2: 'p2',
37
+ P3: 'p3',
38
+ };
12
39
  /**
13
40
  * Counts findings by their `[Pn]` severity tag, looking at both the inline
14
- * comments and any summary-only findings the bot included.
15
- *
16
- * Uses the same regex pattern Doistbot's prompts emit (`[P0]`–`[P3]`).
17
- * Findings without a recognizable tag are bucketed as `untagged`.
41
+ * comments and any summary-only findings the bot included. Findings without
42
+ * a recognizable tag are bucketed as `untagged`.
18
43
  */
19
44
  export function countFindingsBySeverity(review) {
20
45
  const counts = { p0: 0, p1: 0, p2: 0, p3: 0, untagged: 0, total: 0 };
21
- const tagPattern = /\[P([0-3])\]/i;
22
- function incrementByTag(text) {
23
- const match = text?.match(tagPattern);
24
- const tag = match ? `p${match[1]}` : 'untagged';
25
- counts[tag] += 1;
46
+ function increment(sev) {
47
+ if (sev)
48
+ counts[SEVERITY_TO_COUNTER_KEY[sev]] += 1;
49
+ else
50
+ counts.untagged += 1;
26
51
  counts.total += 1;
27
52
  }
28
53
  for (const comment of review.comments ?? []) {
29
- incrementByTag(comment.body);
54
+ increment(comment.body ? parseSeverity(comment.body) : null);
30
55
  }
31
56
  // Summary may also contain `[Pn]`-tagged findings — the prompt's fallback
32
57
  // tells the model to surface findings in the summary when inline placement
@@ -34,9 +59,9 @@ export function countFindingsBySeverity(review) {
34
59
  // double-count tags repeated within the same sentence; only increment for
35
60
  // lines that match.
36
61
  for (const line of review.summary?.split('\n') ?? []) {
37
- if (tagPattern.test(line)) {
38
- incrementByTag(line);
39
- }
62
+ const sev = parseSeverity(line);
63
+ if (sev)
64
+ increment(sev);
40
65
  }
41
66
  return counts;
42
67
  }
@@ -170,6 +195,20 @@ export async function postReviewSuccessCheckRun({ octokit, owner, repo, sha, pul
170
195
  summaryBody: buildSuccessSummary(review, pullRequestUrl, counts),
171
196
  });
172
197
  }
198
+ /** Shared title used by both the failure Check Run and stale-pointer recovery. */
199
+ const FAILURE_TITLE = 'Doistbot review unavailable';
200
+ /**
201
+ * Recovers the title that the terminal Check Run posted to `reviewedSha`,
202
+ * given the same input shape `postFinalCheckRun` already has. Lets the
203
+ * stale-pointer follow-up cite "Original result: …" without duplicating
204
+ * the title-building logic in two places.
205
+ */
206
+ export function getReviewCheckRunTitle(input) {
207
+ if (input.conclusion === 'success') {
208
+ return buildSuccessTitle(countFindingsBySeverity(input.review));
209
+ }
210
+ return FAILURE_TITLE;
211
+ }
173
212
  /**
174
213
  * Posts a `neutral` check run with a "review unavailable" title so PR authors
175
214
  * aren't left wondering whether the bot ran. `neutral` (gray −) visually
@@ -186,7 +225,7 @@ export async function postReviewFailureCheckRun({ octokit, owner, repo, sha, pul
186
225
  pullNumber,
187
226
  deliveryId,
188
227
  conclusion: 'neutral',
189
- title: 'Doistbot review unavailable',
228
+ title: FAILURE_TITLE,
190
229
  summaryBody: [
191
230
  "Doistbot's review didn't complete (provider outage, rate limit, or other failure).",
192
231
  '',
@@ -196,3 +235,84 @@ export async function postReviewFailureCheckRun({ octokit, owner, repo, sha, pul
196
235
  ].join('\n'),
197
236
  });
198
237
  }
238
+ /**
239
+ * After the terminal Check Run lands on `reviewedSha`, look up the PR's
240
+ * current head SHA. If it has moved during the review (i.e. the user pushed
241
+ * at least one commit while the review was in flight), post a neutral
242
+ * stale-pointer Check Run on the current head that links back to
243
+ * `reviewedSha`. Closes the in-progress-during-push gap that the webhook-side
244
+ * fix in [#311](https://github.com/Doist/doistbot-app/pull/311) can't cover
245
+ * on its own: that path only fires on `pull_request.synchronize` and only
246
+ * surfaces a pointer when a *completed* run sits on `payload.before`, so any
247
+ * commits pushed before the review terminated stayed empty.
248
+ *
249
+ * Uses the same `stale-pointer:<sha>` `external_id` format and same summary
250
+ * copy as `apps/webhook-handler/src/tasks/review/stale-check-run.ts` so the
251
+ * two paths interoperate: a subsequent synchronize-driven post finds this
252
+ * pointer on the prior head and unwraps to `reviewedSha`, keeping the
253
+ * "reference original" semantics across chained pushes.
254
+ *
255
+ * Skips quietly when:
256
+ * - Current head equals `reviewedSha` (no movement — the common case).
257
+ * - `pulls.get` fails (warn + return; restoring visibility is degraded UX,
258
+ * not a correctness issue).
259
+ *
260
+ * The `postCheckRun` call itself swallows write failures with a warn, same
261
+ * pattern as the other terminal-write helpers.
262
+ *
263
+ * TOCTOU caveat: a push between the `pulls.get` here and the `postCheckRun`
264
+ * write can leave the new pointer on a SHA that's no longer the head. Same
265
+ * race shape and same bound as the existing TOCTOU notes elsewhere; the
266
+ * webhook-side handler will paper over it on the next synchronize.
267
+ */
268
+ export async function postStaleReviewPointerIfHeadMoved({ octokit, owner, repo, pullNumber, reviewedSha, reviewedTitle, }) {
269
+ let currentHeadSha;
270
+ try {
271
+ const { data } = await octokit.rest.pulls.get({
272
+ owner,
273
+ repo,
274
+ pull_number: pullNumber,
275
+ });
276
+ currentHeadSha = data.head.sha;
277
+ }
278
+ catch (error) {
279
+ logger.warn('Failed to fetch PR current head; skipping stale pointer', {
280
+ component: 'stale-check-run',
281
+ pr_number: pullNumber,
282
+ reviewed_sha: reviewedSha,
283
+ error: error instanceof Error ? error.message : String(error),
284
+ });
285
+ return;
286
+ }
287
+ if (currentHeadSha === reviewedSha) {
288
+ return;
289
+ }
290
+ const reviewedShaShort = reviewedSha.slice(0, 7);
291
+ const commitUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}/commits/${reviewedSha}`;
292
+ const summary = [
293
+ `⚠️ Doistbot reviewed an earlier commit ([\`${reviewedShaShort}\`](${commitUrl})).`,
294
+ '',
295
+ `**Original result:** ${reviewedTitle}`,
296
+ '',
297
+ 'Inline review comments from the original run are still on this PR; ' +
298
+ 'they were against the prior commit and may not reflect the current diff. ' +
299
+ 'Comment `@doistbot /review` to re-run against the latest commit.',
300
+ ].join('\n');
301
+ await postCheckRun({
302
+ octokit,
303
+ owner,
304
+ repo,
305
+ sha: currentHeadSha,
306
+ name: CHECK_RUN_NAME,
307
+ title: truncateTitle(`${STALE_TITLE_PREFIX}${reviewedTitle}`),
308
+ summary,
309
+ conclusion: 'neutral',
310
+ externalId: `${STALE_POINTER_EXTERNAL_ID_PREFIX}${reviewedSha}`,
311
+ });
312
+ logger.info('Posted stale-pointer Check Run on current head (head moved during review)', {
313
+ component: 'stale-check-run',
314
+ pr_number: pullNumber,
315
+ reviewed_sha: reviewedSha,
316
+ current_head_sha: currentHeadSha,
317
+ });
318
+ }
@@ -0,0 +1,316 @@
1
+ import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
2
+ import { fetchIssueComments } from '../../core/github-issues.js';
3
+ import { logger } from '../../core/logging.js';
4
+ import { invokePiPrompt } from '../../core/pi.js';
5
+ import { WORKSPACE_DIR } from '../../core/shared.js';
6
+ import { stripReviewFeedbackFooter } from './review-feedback.js';
7
+ // Roughly 6k tokens: large enough for substantive PR discussions while still
8
+ // keeping conversation context secondary to the diff.
9
+ export const RAW_CONVERSATION_CONTEXT_THRESHOLD_CHARS = 25_000;
10
+ const CONVERSATION_SUMMARY_TIMEOUT_MS = 5 * 60 * 1000;
11
+ const CONVERSATION_SUMMARY_TARGET_CHARS = 4_000;
12
+ const REVIEW_COMMAND_REGEX = /^@doistbot \/review$/i;
13
+ const GITHUB_ACTIONS_BOT_LOGIN = 'github-actions[bot]';
14
+ function getSummaryProviders(apiKeys) {
15
+ return [
16
+ ...(apiKeys?.gemini ? [{ provider: 'gemini', apiKey: apiKeys.gemini }] : []),
17
+ ...(apiKeys?.openai ? [{ provider: 'openai', apiKey: apiKeys.openai }] : []),
18
+ ...(apiKeys?.anthropic
19
+ ? [{ provider: 'anthropic', apiKey: apiKeys.anthropic }]
20
+ : []),
21
+ ];
22
+ }
23
+ function isRelevantBotLogin(login) {
24
+ const normalized = login.toLowerCase();
25
+ return isKnownDoistbotLogin(normalized) || normalized === GITHUB_ACTIONS_BOT_LOGIN;
26
+ }
27
+ function shouldIncludeEntry(login, body) {
28
+ if (!body || REVIEW_COMMAND_REGEX.test(body)) {
29
+ return false;
30
+ }
31
+ const normalized = login.toLowerCase();
32
+ return !normalized.endsWith('[bot]') || isRelevantBotLogin(normalized);
33
+ }
34
+ function cleanBody(body) {
35
+ return stripReviewFeedbackFooter(body);
36
+ }
37
+ function toIssueCommentEntry(comment) {
38
+ const author = comment.user?.login ?? 'unknown';
39
+ const body = cleanBody(comment.body ?? '');
40
+ if (!shouldIncludeEntry(author, body)) {
41
+ return null;
42
+ }
43
+ return {
44
+ source: 'issue_comment',
45
+ author,
46
+ createdAt: comment.created_at ?? 'unknown',
47
+ url: comment.html_url ?? undefined,
48
+ body,
49
+ };
50
+ }
51
+ function toReviewSummaryEntry(review) {
52
+ const author = review.user?.login ?? 'unknown';
53
+ const body = cleanBody(review.body ?? '');
54
+ if (!shouldIncludeEntry(author, body)) {
55
+ return null;
56
+ }
57
+ return {
58
+ source: 'review_summary',
59
+ author,
60
+ createdAt: review.submitted_at ?? 'unknown',
61
+ url: review.html_url ?? undefined,
62
+ state: review.state ?? undefined,
63
+ body,
64
+ };
65
+ }
66
+ function toInlineCommentEntry(comment) {
67
+ const author = comment.user?.login ?? 'unknown';
68
+ const body = cleanBody(comment.body ?? '');
69
+ if (!shouldIncludeEntry(author, body)) {
70
+ return null;
71
+ }
72
+ return {
73
+ source: 'inline_comment',
74
+ author,
75
+ createdAt: comment.created_at ?? 'unknown',
76
+ url: comment.html_url ?? undefined,
77
+ path: comment.path ?? undefined,
78
+ line: comment.line ?? comment.original_line ?? undefined,
79
+ side: comment.side ?? undefined,
80
+ replyToId: comment.in_reply_to_id ?? undefined,
81
+ body,
82
+ };
83
+ }
84
+ function byCreatedAtAscending(a, b) {
85
+ if (a.createdAt === b.createdAt) {
86
+ return 0;
87
+ }
88
+ if (a.createdAt === 'unknown') {
89
+ return 1;
90
+ }
91
+ if (b.createdAt === 'unknown') {
92
+ return -1;
93
+ }
94
+ return a.createdAt.localeCompare(b.createdAt);
95
+ }
96
+ function formatEntry(entry, index) {
97
+ const lines = [`### Entry ${index + 1}: ${entry.source.replace('_', ' ')}`];
98
+ lines.push(`- Author: ${entry.author}`);
99
+ lines.push(`- Created: ${entry.createdAt}`);
100
+ if (entry.state) {
101
+ lines.push(`- Review state: ${entry.state}`);
102
+ }
103
+ 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}`);
107
+ }
108
+ if (entry.replyToId != null) {
109
+ lines.push(`- Reply to inline comment ID: ${entry.replyToId}`);
110
+ }
111
+ if (entry.url) {
112
+ lines.push(`- URL: ${entry.url}`);
113
+ }
114
+ lines.push('Body:');
115
+ lines.push(entry.body);
116
+ return lines.join('\n');
117
+ }
118
+ function buildStats(baseStats, { summarized = false, summarizationFailed = false, } = {}) {
119
+ return {
120
+ ...baseStats,
121
+ summarized,
122
+ summarizationFailed,
123
+ };
124
+ }
125
+ function emptyContext(baseStats, summarizationFailed = false) {
126
+ return {
127
+ promptText: '',
128
+ stats: buildStats(baseStats, { summarizationFailed }),
129
+ };
130
+ }
131
+ function buildSummaryPrompt(rawContext) {
132
+ return `You are summarizing GitHub pull request conversation context for a code review model.
133
+
134
+ Summarize the conversation so the reviewer can account for author clarifications, line-specific discussions, and previous review rounds without rereading every comment.
135
+
136
+ Rules:
137
+ - Use only facts present in the conversation.
138
+ - Preserve line-specific context with file paths and line numbers when available.
139
+ - Distinguish resolved prior review feedback from unresolved or still-relevant feedback when the conversation makes that clear.
140
+ - Capture author intent, decisions, open questions, and previous findings that may affect a re-review.
141
+ - Do not invent status or resolution when it is not stated.
142
+ - Keep the summary under ${CONVERSATION_SUMMARY_TARGET_CHARS} characters.
143
+ - Return markdown only; do not wrap the answer in code fences.
144
+
145
+ Use this structure:
146
+
147
+ ## PR conversation context
148
+
149
+ Coverage: <one sentence with the kinds of comments summarized>
150
+
151
+ ### Author intent / clarifications
152
+ - ...
153
+
154
+ ### Line-specific context
155
+ - ...
156
+
157
+ ### Previous review rounds
158
+ - ...
159
+
160
+ ### Open questions / unresolved discussion
161
+ - ...
162
+
163
+ If a section has no relevant information, use "- None."
164
+
165
+ <pr_conversation>
166
+ ${rawContext}
167
+ </pr_conversation>`;
168
+ }
169
+ async function summarizeConversationContextWithModel({ provider, apiKey, rawContext, workspaceDir, }) {
170
+ const startedAt = Date.now();
171
+ logger.info('PR conversation summarization started', {
172
+ task: 'review',
173
+ provider,
174
+ raw_chars: rawContext.length,
175
+ });
176
+ try {
177
+ const result = await invokePiPrompt({
178
+ provider,
179
+ apiKey,
180
+ prompt: buildSummaryPrompt(rawContext),
181
+ cwd: workspaceDir ?? WORKSPACE_DIR,
182
+ timeoutMs: CONVERSATION_SUMMARY_TIMEOUT_MS,
183
+ capabilityPreset: 'read-only',
184
+ thinkingLevel: 'medium',
185
+ });
186
+ const durationMs = Date.now() - startedAt;
187
+ if (!result.ok) {
188
+ logger.warn('PR conversation context summarization failed', {
189
+ task: 'review',
190
+ provider,
191
+ raw_chars: rawContext.length,
192
+ duration_ms: durationMs,
193
+ timed_out: result.timedOut,
194
+ error: result.error,
195
+ });
196
+ return null;
197
+ }
198
+ const output = result.output.trim();
199
+ logger.info('PR conversation summarization finished', {
200
+ task: 'review',
201
+ provider,
202
+ raw_chars: rawContext.length,
203
+ output_chars: output.length,
204
+ duration_ms: durationMs,
205
+ outcome: output ? 'success' : 'empty',
206
+ });
207
+ return output || null;
208
+ }
209
+ catch (error) {
210
+ logger.warn('PR conversation context summarization errored', {
211
+ task: 'review',
212
+ provider,
213
+ raw_chars: rawContext.length,
214
+ duration_ms: Date.now() - startedAt,
215
+ error: error instanceof Error ? error.message : String(error),
216
+ });
217
+ return null;
218
+ }
219
+ }
220
+ function formatConversationEntries(entries) {
221
+ return entries.map((entry, index) => formatEntry(entry, index)).join('\n\n---\n\n');
222
+ }
223
+ export async function fetchReviewConversationContext({ octokit, owner, repo, pullNumber, summaryApiKeys, workspaceDir, rawContextThresholdChars = RAW_CONVERSATION_CONTEXT_THRESHOLD_CHARS, summarizeConversationContext: summarize = summarizeConversationContextWithModel, }) {
224
+ try {
225
+ const [issueComments, reviews, reviewComments] = await Promise.all([
226
+ fetchIssueComments(octokit, {
227
+ owner,
228
+ repo,
229
+ issueNumber: pullNumber,
230
+ }),
231
+ octokit.paginate(octokit.rest.pulls.listReviews, {
232
+ owner,
233
+ repo,
234
+ pull_number: pullNumber,
235
+ per_page: 100,
236
+ }),
237
+ octokit.paginate(octokit.rest.pulls.listReviewComments, {
238
+ owner,
239
+ repo,
240
+ pull_number: pullNumber,
241
+ per_page: 100,
242
+ }),
243
+ ]);
244
+ const issueCommentEntries = issueComments.flatMap((comment) => {
245
+ const entry = toIssueCommentEntry(comment);
246
+ return entry ? [entry] : [];
247
+ });
248
+ const reviewSummaryEntries = reviews.flatMap((review) => {
249
+ const entry = toReviewSummaryEntry(review);
250
+ return entry ? [entry] : [];
251
+ });
252
+ const inlineCommentEntries = reviewComments.flatMap((comment) => {
253
+ const entry = toInlineCommentEntry(comment);
254
+ return entry ? [entry] : [];
255
+ });
256
+ const entries = [
257
+ ...issueCommentEntries,
258
+ ...reviewSummaryEntries,
259
+ ...inlineCommentEntries,
260
+ ].sort(byCreatedAtAscending);
261
+ const rawContext = formatConversationEntries(entries);
262
+ const rawChars = rawContext.length;
263
+ const baseStats = {
264
+ issueComments: issueCommentEntries.length,
265
+ reviewSummaries: reviewSummaryEntries.length,
266
+ inlineComments: inlineCommentEntries.length,
267
+ rawChars,
268
+ };
269
+ if (entries.length === 0) {
270
+ return emptyContext(baseStats);
271
+ }
272
+ if (rawChars <= rawContextThresholdChars) {
273
+ return {
274
+ promptText: rawContext,
275
+ stats: buildStats(baseStats),
276
+ };
277
+ }
278
+ const summaryProviders = getSummaryProviders(summaryApiKeys);
279
+ if (summaryProviders.length === 0) {
280
+ logger.warn('PR conversation context omitted because summarization requires a review provider', {
281
+ rawChars,
282
+ rawContextThresholdChars,
283
+ });
284
+ return emptyContext(baseStats, true);
285
+ }
286
+ for (const summaryProvider of summaryProviders) {
287
+ const summarizedContext = await summarize({
288
+ ...summaryProvider,
289
+ rawContext,
290
+ workspaceDir,
291
+ });
292
+ if (summarizedContext) {
293
+ return {
294
+ promptText: summarizedContext,
295
+ stats: buildStats(baseStats, { summarized: true }),
296
+ };
297
+ }
298
+ }
299
+ logger.warn('PR conversation context omitted because summarization returned no output', {
300
+ rawChars,
301
+ rawContextThresholdChars,
302
+ });
303
+ return emptyContext(baseStats, true);
304
+ }
305
+ catch (error) {
306
+ logger.warn('Failed to fetch PR conversation context', {
307
+ error: error instanceof Error ? error.message : String(error),
308
+ });
309
+ return emptyContext({
310
+ issueComments: 0,
311
+ reviewSummaries: 0,
312
+ inlineComments: 0,
313
+ rawChars: 0,
314
+ });
315
+ }
316
+ }