@doist/doistbot-cli 1.0.5 → 1.0.7

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 (61) hide show
  1. package/dist/actions/auth.js +78 -24
  2. package/dist/actions/doctor.js +14 -7
  3. package/dist/actions/review.js +67 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +77 -5
  6. package/dist/env.js +1 -0
  7. package/dist/runtime.js +1 -1
  8. package/dist/terminal.js +2 -0
  9. package/package.json +4 -4
  10. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
  12. package/sandbox/dist/core/check-run.js +16 -0
  13. package/sandbox/dist/core/datadog-metrics.js +6 -52
  14. package/sandbox/dist/core/logger-span-processor.js +78 -0
  15. package/sandbox/dist/core/pi.js +285 -174
  16. package/sandbox/dist/core/prompt-template.js +45 -3
  17. package/sandbox/dist/core/repository-map.js +24 -0
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +80 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/credentials.js +12 -2
  22. package/sandbox/dist/providers/gemini.js +5 -1
  23. package/sandbox/dist/providers/helpers.js +63 -17
  24. package/sandbox/dist/providers/index.js +4 -0
  25. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  26. package/sandbox/dist/providers/pi-review.js +19 -2
  27. package/sandbox/dist/tasks/chat/chat.js +173 -102
  28. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  29. package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
  30. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  31. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  32. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  33. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  34. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
  35. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  36. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  37. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  38. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  39. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  40. package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
  41. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  42. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  43. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  44. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  45. package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
  46. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  47. package/sandbox/dist/tasks/review/check-run.js +2 -1
  48. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  49. package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
  50. package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
  51. package/sandbox/dist/tasks/review/engines/shared.js +43 -6
  52. package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
  53. package/sandbox/dist/tasks/review/local.js +7 -4
  54. package/sandbox/dist/tasks/review/review-summary.js +48 -7
  55. package/sandbox/dist/tasks/review/review.js +267 -186
  56. package/sandbox/dist/tasks/review/runner.js +33 -7
  57. package/sandbox/dist/tasks/review/summary-model.js +189 -20
  58. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  59. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  60. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
  61. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
@@ -51,7 +51,7 @@ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR
51
51
  Respond with only valid JSON in this exact shape:
52
52
  {"summary":"<your summary>","comments":[]}`;
53
53
  }
54
- export async function summarizeReviewFindings({ credential, prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
54
+ export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
55
55
  const prompt = buildReviewSummaryPrompt({
56
56
  prTitle,
57
57
  prBody,
@@ -62,15 +62,56 @@ export async function summarizeReviewFindings({ credential, prTitle, prBody, fin
62
62
  findingsCount: findings.length,
63
63
  reviewObservationsCount: reviewObservations?.length ?? 0,
64
64
  });
65
- const result = await requestReview({ credential, prompt, workspaceDir });
66
- const summary = result?.summary?.trim() ?? '';
67
- if (!summary) {
68
- logger.warn('Review summary model returned an empty summary', {
65
+ const summaryModels = [
66
+ {
67
+ provider,
68
+ credential,
69
+ ...(modelName ? { modelName } : {}),
70
+ },
71
+ ...fallbackModels,
72
+ ];
73
+ for (const [index, summaryModel] of summaryModels.entries()) {
74
+ // The `review.summary` span lives in requestReviewSummary, around the
75
+ // actual model invoke — so a missing-credential config error returns
76
+ // before any span and never shows a fake model attempt.
77
+ const result = await requestReview({
78
+ credential: summaryModel.credential,
79
+ provider: summaryModel.provider,
80
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
81
+ prompt,
82
+ workspaceDir,
83
+ });
84
+ if (result.status === 'failed') {
85
+ logger.warn('Review summary synthesis failed', {
86
+ findingsCount: findings.length,
87
+ reviewObservationsCount: reviewObservations?.length ?? 0,
88
+ failureReason: result.reason,
89
+ provider: summaryModel.provider,
90
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
91
+ attempt: index + 1,
92
+ attemptsTotal: summaryModels.length,
93
+ ...(result.error ? { error: result.error } : {}),
94
+ ...(result.timedOut !== undefined ? { timedOut: result.timedOut } : {}),
95
+ ...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
96
+ ...(result.outputChars !== undefined ? { outputChars: result.outputChars } : {}),
97
+ });
98
+ continue;
99
+ }
100
+ const summary = result.summary.trim();
101
+ logger.info('Review summary synthesis succeeded', {
69
102
  findingsCount: findings.length,
103
+ reviewObservationsCount: reviewObservations?.length ?? 0,
104
+ provider: summaryModel.provider,
105
+ ...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
106
+ attempt: index + 1,
107
+ attemptsTotal: summaryModels.length,
108
+ durationMs: result.durationMs,
109
+ outputChars: result.outputChars,
110
+ summaryChars: summary.length,
70
111
  });
71
- return null;
112
+ return summary;
72
113
  }
73
- return summary;
114
+ return null;
74
115
  }
75
116
  export function buildFallbackSummary({ prTitle, findings, }) {
76
117
  const safeTitle = prTitle.trim();
@@ -1,23 +1,22 @@
1
1
  import { performance } from 'node:perf_hooks';
2
- import { parseReviewModelsEnabled as parseConfiguredReviewModelsEnabled } from 'doistbot-repo-config';
2
+ import { SpanStatusCode } from '@opentelemetry/api';
3
+ import { getDefaultReviewModels } from 'doistbot-repo-config';
3
4
  import { recordTaskMetrics } from '../../core/datadog-metrics.js';
4
5
  import { logger } from '../../core/logging.js';
5
- import { createOpenRouterCredential } from '../../providers/credentials.js';
6
6
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
7
+ import { withPhase } from '../../core/tracing.js';
8
+ import { createOpenRouterCredential } from '../../providers/credentials.js';
7
9
  import { getReviewCheckRunTitle, isAnotherReviewInProgress, postReviewFailureCheckRun, postReviewInProgressCheckRun, postReviewSuccessCheckRun, postStaleReviewPointerIfHeadMoved, } from './check-run.js';
8
10
  import { fetchReviewConversationContext } from './conversation-context.js';
9
11
  import {} from './diff.js';
10
12
  import { formatReviewForTerminal } from './format.js';
11
- import { formatGithubReviewComments } from './github-comment-format.js';
13
+ import { formatGithubReviewComments, formatGithubReviewSummary } from './github-comment-format.js';
14
+ import { REVIEW_FOCUS_DEFINITIONS } from './multi-focus-prompt.js';
12
15
  import { appendReviewFeedbackLink } from './review-feedback.js';
13
16
  import { runSharedReview } from './runner.js';
14
17
  const REVIEW_EVENT = 'COMMENT';
15
- const REQUIRED_PRODUCTION_REVIEW_MODELS = ['gemini', 'openai'];
16
- function parseReviewModelsEnabled() {
17
- // Parsed/defaulted in webhook-handler before being passed as task env.
18
- const raw = process.env.REVIEW_MODELS_ENABLED;
19
- return parseConfiguredReviewModelsEnabled(raw, { defaultModels: [] });
20
- }
18
+ const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
19
+ const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
21
20
  function optionalEnv(name) {
22
21
  const value = process.env[name]?.trim();
23
22
  return value ? value : undefined;
@@ -29,16 +28,65 @@ function parseContext() {
29
28
  branch: requiredEnv('BRANCH'),
30
29
  baseBranch: requiredEnv('BASE_BRANCH'),
31
30
  headSha: optionalEnv('HEAD_SHA'),
31
+ geminiApiKey: optionalEnv('GEMINI_API_KEY'),
32
32
  openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
33
33
  openaiApiKey: optionalEnv('OPENAI_KEY'),
34
- reviewModelsEnabled: parseReviewModelsEnabled(),
34
+ reviewModelsEnabled: parseReviewModelsEnv(process.env.REVIEW_MODELS_ENABLED),
35
+ reviewFocusesEnabled: parseReviewFocusesEnv(process.env.REVIEW_FOCUSES_ENABLED),
36
+ reviewRoutingCategory: optionalEnv('REVIEW_ROUTING_CATEGORY'),
37
+ reviewRoutingRuleId: optionalEnv('REVIEW_ROUTING_RULE_ID'),
38
+ reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
39
+ reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
35
40
  dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
36
41
  };
37
42
  }
43
+ export function parseReviewModelsEnv(value) {
44
+ return parseCsvAllowList({
45
+ value,
46
+ allowedValues: REQUIRED_PRODUCTION_REVIEW_MODELS,
47
+ fallback: REQUIRED_PRODUCTION_REVIEW_MODELS,
48
+ envName: 'REVIEW_MODELS_ENABLED',
49
+ });
50
+ }
51
+ export function parseReviewFocusesEnv(value) {
52
+ return parseCsvAllowList({
53
+ value,
54
+ allowedValues: REVIEW_FOCUSES,
55
+ fallback: REVIEW_FOCUSES,
56
+ envName: 'REVIEW_FOCUSES_ENABLED',
57
+ });
58
+ }
59
+ function parseCsvAllowList({ value, allowedValues, fallback, envName, }) {
60
+ if (value === undefined) {
61
+ return [...fallback];
62
+ }
63
+ const allowed = new Set(allowedValues);
64
+ const requested = value
65
+ .split(',')
66
+ .map((item) => item.trim())
67
+ .filter((item) => item.length > 0);
68
+ if (requested.length === 0) {
69
+ throw new Error(`${envName} must include at least one supported value when set.`);
70
+ }
71
+ const unsupported = requested.filter((item) => !allowed.has(item));
72
+ if (unsupported.length > 0) {
73
+ throw new Error(`${envName} contains unsupported value(s): ${[...new Set(unsupported)].join(', ')}. ` +
74
+ `Supported values: ${allowedValues.join(', ')}.`);
75
+ }
76
+ return [...new Set(requested)];
77
+ }
78
+ function optionalNumberEnv(name) {
79
+ const value = optionalEnv(name);
80
+ if (value === undefined) {
81
+ return undefined;
82
+ }
83
+ const parsed = Number(value);
84
+ return Number.isFinite(parsed) ? parsed : undefined;
85
+ }
38
86
  function getConversationSummaryApiKeys(ctx) {
39
87
  const apiKeys = {};
40
- if (ctx.reviewModelsEnabled.includes('gemini') && ctx.openRouterApiKey) {
41
- apiKeys.gemini = createOpenRouterCredential(ctx.openRouterApiKey);
88
+ if (ctx.geminiApiKey) {
89
+ apiKeys.gemini = ctx.geminiApiKey;
42
90
  }
43
91
  if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
44
92
  apiKeys.openai = ctx.openaiApiKey;
@@ -57,209 +105,242 @@ async function main() {
57
105
  logger.info('Review task started', {
58
106
  dryRun: ctx.dryRun,
59
107
  reviewModelsEnabled: ctx.reviewModelsEnabled,
108
+ reviewFocusesEnabled: ctx.reviewFocusesEnabled,
109
+ reviewRoutingCategory: ctx.reviewRoutingCategory,
110
+ reviewRoutingRuleId: ctx.reviewRoutingRuleId,
60
111
  });
61
- const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
62
- // GitHub's Check Runs API requires App-installation auth; calling it with
63
- // writeOctokit (the doistbot PAT) returns 403. Centralize the choice here
64
- // so it can't drift as new Check Run call sites get added.
65
- // See: https://docs.github.com/rest/checks/runs#create-a-check-run
66
- const checkRunOctokit = readOctokit;
67
112
  let exitCode = 0;
68
113
  let reviewCompletedSuccessfully = false;
69
114
  let reviewMetricModels = [];
70
- // Centralizes the boilerplate for posting the final Check Run from the
71
- // ~7 cleanup paths in this function. Skips silently when no headSha is
72
- // available (rare; logged at startup) or when running under dryRun.
73
- async function postFinalCheckRun(input) {
74
- if (ctx.dryRun || !ctx.headSha)
75
- return;
76
- const baseArgs = {
77
- octokit: checkRunOctokit,
78
- owner: ctx.owner,
79
- repo: ctx.repo,
80
- sha: ctx.headSha,
81
- pullNumber: ctx.prNumber,
82
- deliveryId: ctx.deliveryId,
83
- };
84
- if (input.conclusion === 'success') {
85
- await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
86
- }
87
- else {
88
- await postReviewFailureCheckRun(baseArgs);
89
- }
90
- // If the PR head moved during the review, also drop a neutral stale-
91
- // pointer Check Run on the current head so reviewers can find their
92
- // way back to the original. Closes the in-progress-during-push gap
93
- // the webhook-side fix (#311) can't cover on its own — see the helper
94
- // doc-comment for the full timeline this handles.
95
- await postStaleReviewPointerIfHeadMoved({
96
- octokit: checkRunOctokit,
97
- owner: ctx.owner,
98
- repo: ctx.repo,
99
- pullNumber: ctx.prNumber,
100
- reviewedSha: ctx.headSha,
101
- reviewedTitle: getReviewCheckRunTitle(input),
102
- });
103
- }
104
- try {
105
- if (!ctx.dryRun && ctx.headSha) {
106
- // Concurrency lock + "review running" signal both come from the
107
- // same Check Run: an `in_progress` run is posted at start (and
108
- // upserted to the final conclusion when the review completes).
109
- //
110
- // Caveat: the lock is keyed by the *triggering* SHA, but the
111
- // review pipeline itself (cloneRepository / pulls.listFiles)
112
- // operates on the PR's current head — they're not pinned to
113
- // ctx.headSha. So a new commit landing during a review can slip
114
- // past this lock and produce a duplicate review of the same final
115
- // PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
116
- // and the case is rare; pinning the pipeline is its own follow-up
117
- // (see PR #274's "Deferred" section, finding C2).
118
- //
119
- // There's also a small TOCTOU window between this check and the
120
- // in_progress post — same race the prior comment-based lock had,
121
- // and same bound (one duplicate review when it fires).
122
- const otherInProgress = await isAnotherReviewInProgress({
115
+ await withPhase('review', {
116
+ task: 'review',
117
+ 'gen_ai.operation.name': 'invoke_agent',
118
+ 'gen_ai.agent.name': 'doistbot.review',
119
+ 'pr.number': ctx.prNumber,
120
+ dry_run: ctx.dryRun,
121
+ }, async (span) => {
122
+ // Inside the span so installation-auth/setup failures are captured
123
+ // by the root `review` trace.
124
+ const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
125
+ // GitHub's Check Runs API requires App-installation auth; calling it
126
+ // with writeOctokit (the doistbot PAT) returns 403. Centralize the
127
+ // choice here so it can't drift as new Check Run call sites get added.
128
+ // See: https://docs.github.com/rest/checks/runs#create-a-check-run
129
+ const checkRunOctokit = readOctokit;
130
+ // Centralizes the boilerplate for posting the final Check Run from the
131
+ // ~7 cleanup paths below. Skips silently when no headSha is available
132
+ // (rare; logged at startup) or when running under dryRun.
133
+ async function postFinalCheckRun(input) {
134
+ if (ctx.dryRun || !ctx.headSha)
135
+ return;
136
+ const baseArgs = {
123
137
  octokit: checkRunOctokit,
124
138
  owner: ctx.owner,
125
139
  repo: ctx.repo,
126
140
  sha: ctx.headSha,
141
+ pullNumber: ctx.prNumber,
127
142
  deliveryId: ctx.deliveryId,
128
- });
129
- if (otherInProgress) {
130
- logger.info('Another review is already in progress on this SHA, skipping');
131
- return;
143
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
144
+ };
145
+ if (input.conclusion === 'success') {
146
+ await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
147
+ }
148
+ else {
149
+ await postReviewFailureCheckRun(baseArgs);
132
150
  }
133
- await postReviewInProgressCheckRun({
151
+ // If the PR head moved during the review, also drop a neutral
152
+ // stale-pointer Check Run on the current head so reviewers can
153
+ // find their way back to the original. Closes the in-progress-
154
+ // during-push gap the webhook-side fix (#311) can't cover on its
155
+ // own — see the helper doc-comment for the full timeline.
156
+ await postStaleReviewPointerIfHeadMoved({
134
157
  octokit: checkRunOctokit,
135
158
  owner: ctx.owner,
136
159
  repo: ctx.repo,
137
- sha: ctx.headSha,
138
160
  pullNumber: ctx.prNumber,
139
- deliveryId: ctx.deliveryId,
161
+ reviewedSha: ctx.headSha,
162
+ reviewedTitle: getReviewCheckRunTitle(input),
140
163
  });
141
164
  }
142
- else if (ctx.dryRun) {
143
- logger.info('Dry run enabled, skipping check-run posting');
144
- }
145
- else {
146
- // No HEAD_SHA available — can't post Check Runs (which need a
147
- // SHA) and can't lock. Reviews proceed unguarded; a rare
148
- // simultaneous /review may double-bill on the same PR. Log so
149
- // this is visible.
150
- logger.warn('HEAD_SHA missing; review will proceed without a Check Run lock or status posting');
151
- }
152
- await cloneRepository({
153
- app,
154
- owner: ctx.owner,
155
- repo: ctx.repo,
156
- prNumber: ctx.prNumber,
157
- installationId: ctx.installationId,
158
- });
159
- const [prResponse, files] = await Promise.all([
160
- readOctokit.rest.pulls.get({
161
- owner: ctx.owner,
162
- repo: ctx.repo,
163
- pull_number: ctx.prNumber,
164
- }),
165
- readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
165
+ try {
166
+ if (!ctx.dryRun && ctx.headSha) {
167
+ // Concurrency lock + "review running" signal both come from the
168
+ // same Check Run: an `in_progress` run is posted at start (and
169
+ // upserted to the final conclusion when the review completes).
170
+ //
171
+ // Caveat: the lock is keyed by the *triggering* SHA, but the
172
+ // review pipeline itself (cloneRepository / pulls.listFiles)
173
+ // operates on the PR's current head — they're not pinned to
174
+ // ctx.headSha. So a new commit landing during a review can slip
175
+ // past this lock and produce a duplicate review of the same final
176
+ // PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
177
+ // and the case is rare; pinning the pipeline is its own follow-up
178
+ // (see PR #274's "Deferred" section, finding C2).
179
+ //
180
+ // There's also a small TOCTOU window between this check and the
181
+ // in_progress post — same race the prior comment-based lock had,
182
+ // and same bound (one duplicate review when it fires).
183
+ if (!ctx.reviewDispatchLockAcquired) {
184
+ const otherInProgress = await isAnotherReviewInProgress({
185
+ octokit: checkRunOctokit,
186
+ owner: ctx.owner,
187
+ repo: ctx.repo,
188
+ sha: ctx.headSha,
189
+ deliveryId: ctx.deliveryId,
190
+ });
191
+ if (otherInProgress) {
192
+ logger.info('Another review is already in progress on this SHA, skipping');
193
+ return;
194
+ }
195
+ }
196
+ await postReviewInProgressCheckRun({
197
+ octokit: checkRunOctokit,
198
+ owner: ctx.owner,
199
+ repo: ctx.repo,
200
+ sha: ctx.headSha,
201
+ pullNumber: ctx.prNumber,
202
+ deliveryId: ctx.deliveryId,
203
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
204
+ });
205
+ }
206
+ else if (ctx.dryRun) {
207
+ logger.info('Dry run enabled, skipping check-run posting');
208
+ }
209
+ else {
210
+ // No HEAD_SHA available — can't post Check Runs (which need a
211
+ // SHA) and can't lock. Reviews proceed unguarded; a rare
212
+ // simultaneous /review may double-bill on the same PR. Log so
213
+ // this is visible.
214
+ logger.warn('HEAD_SHA missing; review will proceed without a Check Run lock or status posting');
215
+ }
216
+ await cloneRepository({
217
+ app,
166
218
  owner: ctx.owner,
167
219
  repo: ctx.repo,
168
- pull_number: ctx.prNumber,
169
- per_page: 100,
170
- }),
171
- ]);
172
- const pr = prResponse.data;
173
- const prAuthor = pr.user?.login ?? 'unknown';
174
- function loadPrConversation() {
175
- return fetchReviewConversationContext({
176
- octokit: readOctokit,
220
+ prNumber: ctx.prNumber,
221
+ installationId: ctx.installationId,
222
+ });
223
+ const [prResponse, files] = await Promise.all([
224
+ readOctokit.rest.pulls.get({
225
+ owner: ctx.owner,
226
+ repo: ctx.repo,
227
+ pull_number: ctx.prNumber,
228
+ }),
229
+ readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
230
+ owner: ctx.owner,
231
+ repo: ctx.repo,
232
+ pull_number: ctx.prNumber,
233
+ per_page: 100,
234
+ }),
235
+ ]);
236
+ const pr = prResponse.data;
237
+ const prAuthor = pr.user?.login ?? 'unknown';
238
+ function loadPrConversation() {
239
+ return fetchReviewConversationContext({
240
+ octokit: readOctokit,
241
+ owner: ctx.owner,
242
+ repo: ctx.repo,
243
+ pullNumber: ctx.prNumber,
244
+ prAuthor: pr.user?.login ?? undefined,
245
+ summaryApiKeys: getConversationSummaryApiKeys(ctx),
246
+ workspaceDir: WORKSPACE_DIR,
247
+ });
248
+ }
249
+ reviewMetricModels = [...ctx.reviewModelsEnabled];
250
+ const reviewApiKeys = {};
251
+ if (ctx.geminiApiKey) {
252
+ reviewApiKeys.gemini = ctx.geminiApiKey;
253
+ }
254
+ if (ctx.openRouterApiKey) {
255
+ reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
256
+ reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
257
+ }
258
+ if (ctx.openaiApiKey) {
259
+ reviewApiKeys.openai = ctx.openaiApiKey;
260
+ }
261
+ const reviewResult = await runSharedReview({
177
262
  owner: ctx.owner,
178
263
  repo: ctx.repo,
179
- pullNumber: ctx.prNumber,
180
- prAuthor: pr.user?.login ?? undefined,
181
- summaryApiKeys: getConversationSummaryApiKeys(ctx),
264
+ prNumber: ctx.prNumber,
265
+ branch: ctx.branch,
266
+ baseBranch: ctx.baseBranch,
267
+ prTitle: pr.title ?? '',
268
+ prBody: pr.body ?? '',
269
+ loadPrConversation,
270
+ prAuthor,
271
+ files: files,
272
+ apiKeys: reviewApiKeys,
273
+ reviewModelsEnabled: ctx.reviewModelsEnabled,
274
+ reviewFocusesEnabled: ctx.reviewFocusesEnabled,
275
+ requiredReviewModels: ctx.reviewModelsEnabled,
182
276
  workspaceDir: WORKSPACE_DIR,
183
277
  });
184
- }
185
- reviewMetricModels = [...ctx.reviewModelsEnabled];
186
- const reviewApiKeys = {};
187
- if (ctx.openRouterApiKey) {
188
- reviewApiKeys.gemini = createOpenRouterCredential(ctx.openRouterApiKey);
189
- }
190
- if (ctx.openaiApiKey) {
191
- reviewApiKeys.openai = ctx.openaiApiKey;
192
- }
193
- const reviewResult = await runSharedReview({
194
- owner: ctx.owner,
195
- repo: ctx.repo,
196
- prNumber: ctx.prNumber,
197
- branch: ctx.branch,
198
- baseBranch: ctx.baseBranch,
199
- prTitle: pr.title ?? '',
200
- prBody: pr.body ?? '',
201
- loadPrConversation,
202
- prAuthor,
203
- files: files,
204
- apiKeys: reviewApiKeys,
205
- reviewModelsEnabled: ctx.reviewModelsEnabled,
206
- requiredReviewModels: REQUIRED_PRODUCTION_REVIEW_MODELS,
207
- workspaceDir: WORKSPACE_DIR,
208
- });
209
- reviewMetricModels = reviewResult.metricModels;
210
- if (reviewResult.status === 'skipped') {
211
- if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
212
- await postFinalCheckRun({
213
- conclusion: 'success',
214
- review: { summary: '', comments: [] },
278
+ reviewMetricModels = reviewResult.metricModels;
279
+ if (reviewResult.status === 'skipped') {
280
+ if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
281
+ await postFinalCheckRun({
282
+ conclusion: 'success',
283
+ review: { summary: '', comments: [] },
284
+ });
285
+ }
286
+ else {
287
+ // no_models / no_review / empty_review post a failing
288
+ // check run, so the span must end ERROR to match — a
289
+ // return-based failure won't trip withPhase's throw path.
290
+ span.setStatus({
291
+ code: SpanStatusCode.ERROR,
292
+ message: `review skipped: ${reviewResult.reason}`,
293
+ });
294
+ await postFinalCheckRun({ conclusion: 'failure' });
295
+ }
296
+ return;
297
+ }
298
+ const { review } = reviewResult;
299
+ if (ctx.dryRun) {
300
+ logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
301
+ commentsCount: review.comments.length,
215
302
  });
303
+ process.stdout.write(`${formatReviewForTerminal({
304
+ review,
305
+ })}\n`);
216
306
  }
217
307
  else {
218
- await postFinalCheckRun({ conclusion: 'failure' });
308
+ await submitReview({
309
+ octokit: writeOctokit,
310
+ owner: ctx.owner,
311
+ repo: ctx.repo,
312
+ pull_number: ctx.prNumber,
313
+ deliveryId: ctx.deliveryId,
314
+ review,
315
+ });
316
+ await postFinalCheckRun({ conclusion: 'success', review });
219
317
  }
220
- return;
221
- }
222
- const { review } = reviewResult;
223
- if (ctx.dryRun) {
224
- logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
225
- commentsCount: review.comments.length,
226
- });
227
- process.stdout.write(`${formatReviewForTerminal({
228
- review,
229
- })}\n`);
318
+ logger.info('Review task completed');
319
+ reviewCompletedSuccessfully = true;
230
320
  }
231
- else {
232
- await submitReview({
233
- octokit: writeOctokit,
234
- owner: ctx.owner,
235
- repo: ctx.repo,
236
- pull_number: ctx.prNumber,
237
- deliveryId: ctx.deliveryId,
238
- review,
321
+ catch (error) {
322
+ const message = error instanceof Error ? error.message : String(error);
323
+ logger.error('Review failed', {
324
+ error: message,
239
325
  });
240
- await postFinalCheckRun({ conclusion: 'success', review });
326
+ await postFinalCheckRun({ conclusion: 'failure' });
327
+ // Set the span status before the run exits; deferring process.exit
328
+ // until after withPhase lets phase.end flush.
329
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
330
+ exitCode = 1;
241
331
  }
242
- logger.info('Review task completed');
243
- reviewCompletedSuccessfully = true;
244
- }
245
- catch (error) {
246
- logger.error('Review failed', {
247
- error: error instanceof Error ? error.message : String(error),
248
- });
249
- await postFinalCheckRun({ conclusion: 'failure' });
250
- exitCode = 1;
251
- }
252
- finally {
253
- if (reviewMetricModels.length > 0) {
254
- await recordTaskMetrics({
255
- type: 'review',
256
- repository: `${ctx.owner}/${ctx.repo}`,
257
- model: reviewMetricModels,
258
- outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
259
- durationMs: performance.now() - startedAt,
260
- });
332
+ finally {
333
+ if (reviewMetricModels.length > 0) {
334
+ await recordTaskMetrics({
335
+ type: 'review',
336
+ repository: `${ctx.owner}/${ctx.repo}`,
337
+ model: reviewMetricModels,
338
+ outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
339
+ durationMs: performance.now() - startedAt,
340
+ });
341
+ }
261
342
  }
262
- }
343
+ });
263
344
  if (exitCode !== 0) {
264
345
  process.exit(exitCode);
265
346
  }
@@ -272,7 +353,7 @@ async function submitReview({ octokit, owner, repo, pull_number, deliveryId, rev
272
353
  event: REVIEW_EVENT,
273
354
  };
274
355
  if (review.summary?.length) {
275
- const summaryWithFeedbackLink = appendReviewFeedbackLink(review.summary, {
356
+ const summaryWithFeedbackLink = appendReviewFeedbackLink(formatGithubReviewSummary(review.summary), {
276
357
  owner,
277
358
  repo,
278
359
  prNumber: pull_number,