@doist/doistbot-cli 1.0.6 → 1.0.8

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 (36) hide show
  1. package/dist/actions/auth.js +43 -11
  2. package/dist/actions/doctor.js +4 -2
  3. package/dist/actions/review.js +11 -14
  4. package/dist/auth.js +23 -6
  5. package/dist/config.js +58 -12
  6. package/package.json +3 -3
  7. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  8. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
  9. package/sandbox/dist/core/pi.js +31 -1
  10. package/sandbox/dist/core/prompt-template.js +45 -3
  11. package/sandbox/dist/core/repository-map.js +24 -0
  12. package/sandbox/dist/core/tracing.js +9 -0
  13. package/sandbox/dist/providers/helpers.js +51 -0
  14. package/sandbox/dist/providers/pi-review.js +7 -1
  15. package/sandbox/dist/tasks/chat/chat.js +20 -8
  16. package/sandbox/dist/tasks/issue-summarize/model.js +119 -48
  17. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  18. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  19. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +3 -2
  20. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  21. package/sandbox/dist/tasks/issue-triage/pr-creator.js +2 -2
  22. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  23. package/sandbox/dist/tasks/issue-triage/triage.js +2 -2
  24. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  25. package/sandbox/dist/tasks/review/engines/dedupe.js +107 -42
  26. package/sandbox/dist/tasks/review/engines/multi-focus.js +229 -119
  27. package/sandbox/dist/tasks/review/engines/shared.js +36 -1
  28. package/sandbox/dist/tasks/review/github-comment-format.js +12 -4
  29. package/sandbox/dist/tasks/review/local.js +6 -1
  30. package/sandbox/dist/tasks/review/review-summary.js +3 -0
  31. package/sandbox/dist/tasks/review/review.js +253 -186
  32. package/sandbox/dist/tasks/review/runner.js +10 -5
  33. package/sandbox/dist/tasks/review/summary-model.js +40 -18
  34. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
  35. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
  36. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
@@ -1,4 +1,5 @@
1
- import { getReviewProviderCredential, hasReviewProviderCredential, } from '../../../providers/credentials.js';
1
+ import { DEFAULT_GEMINI_FLASH_MODEL } from '../../../core/pi.js';
2
+ import { getReviewProviderCredential, hasReviewProviderCredential, resolveReviewProviderCredential, } from '../../../providers/credentials.js';
2
3
  import { getProvider } from '../../../providers/index.js';
3
4
  import { getReviewThinkingLevel } from '../thinking-level.js';
4
5
  export function getApiKeys(ctx) {
@@ -6,6 +7,11 @@ export function getApiKeys(ctx) {
6
7
  for (const model of ctx.reviewModelsEnabled) {
7
8
  apiKeys[model] = ctx.apiKeys[model];
8
9
  }
10
+ // Gemini is a support credential for fallback, dedupe, and utility work. It
11
+ // does not need to be part of the routed primary model set.
12
+ if (ctx.apiKeys.gemini) {
13
+ apiKeys.gemini = ctx.apiKeys.gemini;
14
+ }
9
15
  return apiKeys;
10
16
  }
11
17
  export function getAvailableModels(apiKeys) {
@@ -20,6 +26,23 @@ export function getAvailableModels(apiKeys) {
20
26
  models.push('zai');
21
27
  return models;
22
28
  }
29
+ export function getEnabledReviewModels(apiKeys, reviewModelsEnabled) {
30
+ return reviewModelsEnabled.filter((model) => hasReviewProviderCredential(apiKeys[model]));
31
+ }
32
+ export function getOpenRouterReviewCredential(...apiKeySets) {
33
+ for (const apiKeys of apiKeySets) {
34
+ if (!apiKeys) {
35
+ continue;
36
+ }
37
+ for (const credential of Object.values(apiKeys)) {
38
+ const resolved = resolveReviewProviderCredential(credential);
39
+ if (resolved?.piProviderId === 'openrouter') {
40
+ return credential;
41
+ }
42
+ }
43
+ }
44
+ return undefined;
45
+ }
23
46
  export function getProviderFailures(results) {
24
47
  return results
25
48
  .filter((result) => result.error)
@@ -48,3 +71,15 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
48
71
  return null;
49
72
  }
50
73
  }
74
+ // Resolves the gemini credential and stamps the flash model id onto it, so the
75
+ // fallback pass runs flash via the existing apiKeys path (no extra param).
76
+ export function buildGeminiFlashCredential(credential) {
77
+ const resolved = resolveReviewProviderCredential(credential);
78
+ if (!resolved)
79
+ return undefined;
80
+ // No piProviderId → always the Google provider (never OpenRouter) for Gemini.
81
+ return {
82
+ apiKey: resolved.apiKey,
83
+ modelName: DEFAULT_GEMINI_FLASH_MODEL,
84
+ };
85
+ }
@@ -8,7 +8,9 @@ export const PRIORITY_BADGE_URLS = {
8
8
  const PRIORITY_PREFIX_PATTERN = /^\s*\[(P[0-3])\]([^\S\r\n]*)(\r?\n)?/i;
9
9
  const PRIORITY_TAG_PATTERN = /\[(P[0-3])\]/gi;
10
10
  const PRIORITY_METADATA_PATTERN = /<!--\s*\[(P[0-3])\]\s*-->\s*(?:\[\1\]\s*)?/gi;
11
- const PRIORITY_BADGE_HTML_PATTERN = /(?:<a\b[^>]*>\s*)?<img\b(?=[^>]*\balt=["']P[0-3]["'])(?=[^>]*\bsrc=["']https:\/\/res\.cloudinary\.com\/dqrcfnzm9\/image\/upload\/v\d+\/p[0-3]_[^"']+\.svg["'])[^>]*>(?:\s*<\/a>)?([^\S\r\n]*)/gi;
11
+ const PRIORITY_BADGE_HTML_SOURCE = String.raw `(?:<a\b[^>]*>\s*)?<img\b(?=[^>]*\balt=["']P[0-3]["'])(?=[^>]*\bsrc=["']https:\/\/res\.cloudinary\.com\/dqrcfnzm9\/image\/upload\/v\d+\/p[0-3]_[^"']+\.svg["'])[^>]*>(?:\s*<\/a>)?`;
12
+ const PRIORITY_BADGE_WITH_METADATA_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}[^\\S\\r\\n]*<!--\\s*\\[(P[0-3])\\]\\s*-->([^\\S\\r\\n]*)`, 'gi');
13
+ const PRIORITY_BADGE_HTML_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}([^\\S\\r\\n]*)`, 'gi');
12
14
  const PRIORITY_BADGE_ALT_PATTERN = /\balt=["'](P[0-3])["']/i;
13
15
  const INLINE_BADGE_SEPARATOR = ' ';
14
16
  export function renderPriorityBadge(severity) {
@@ -20,7 +22,7 @@ function renderPriorityMetadata(severity) {
20
22
  return `<!--[${severity}] -->`;
21
23
  }
22
24
  function renderPriorityBadgeWithMetadata(severity) {
23
- return `${renderPriorityMetadata(severity)}${renderPriorityBadge(severity)}`;
25
+ return `${renderPriorityBadge(severity)}${renderPriorityMetadata(severity)}`;
24
26
  }
25
27
  function getOpeningFence(line) {
26
28
  const fence = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
@@ -70,13 +72,19 @@ export function formatGithubReviewSummary(body) {
70
72
  .join('');
71
73
  }
72
74
  export function normalizeGithubPriorityBadges(body) {
73
- const normalizedBadges = body.replace(PRIORITY_BADGE_HTML_PATTERN, (badge, separator) => {
75
+ const normalizedBadgeMetadata = body.replace(PRIORITY_BADGE_WITH_METADATA_PATTERN, (badge, metadataSeverity, separator) => {
76
+ const badgeSeverity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
77
+ const severity = metadataSeverity.toUpperCase();
78
+ return badgeSeverity === severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
79
+ });
80
+ const normalizedBadges = normalizedBadgeMetadata.replace(PRIORITY_BADGE_HTML_PATTERN, (badge, separator) => {
74
81
  const severity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
75
82
  return severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
76
83
  });
77
- return normalizedBadges.replace(PRIORITY_METADATA_PATTERN, (_match, severity) => {
84
+ const normalizedMetadata = normalizedBadges.replace(PRIORITY_METADATA_PATTERN, (_match, severity) => {
78
85
  return `[${severity.toUpperCase()}] `;
79
86
  });
87
+ return normalizedMetadata;
80
88
  }
81
89
  export function formatGithubReviewComments(comments) {
82
90
  return comments.map((comment) => ({
@@ -37,7 +37,12 @@ async function runLocalReviewWithLogging(input) {
37
37
  prAuthor: author,
38
38
  files,
39
39
  apiKeys: input.apiKeys,
40
- reviewModelsEnabled: getDefaultReviewModels(),
40
+ reviewModelsEnabled: input.reviewModelsEnabled ?? getDefaultReviewModels(),
41
+ ...(input.reviewModelsByFocus ? { reviewModelsByFocus: input.reviewModelsByFocus } : {}),
42
+ ...(input.geminiFallbackFocuses
43
+ ? { geminiFallbackFocuses: input.geminiFallbackFocuses }
44
+ : {}),
45
+ ...(input.reviewSummaryModel ? { reviewSummaryModel: input.reviewSummaryModel } : {}),
41
46
  lowPriorityFindingPlacement: 'inline',
42
47
  workspaceDir: input.workspaceDir,
43
48
  onProgress: input.onProgress,
@@ -71,6 +71,9 @@ export async function summarizeReviewFindings({ credential, provider = 'zai', mo
71
71
  ...fallbackModels,
72
72
  ];
73
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.
74
77
  const result = await requestReview({
75
78
  credential: summaryModel.credential,
76
79
  provider: summaryModel.provider,
@@ -1,18 +1,22 @@
1
1
  import { performance } from 'node:perf_hooks';
2
+ import { SpanStatusCode } from '@opentelemetry/api';
2
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
6
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
7
+ import { withPhase } from '../../core/tracing.js';
6
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
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
18
  const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
19
+ const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
16
20
  function optionalEnv(name) {
17
21
  const value = process.env[name]?.trim();
18
22
  return value ? value : undefined;
@@ -27,12 +31,50 @@ function parseContext() {
27
31
  geminiApiKey: optionalEnv('GEMINI_API_KEY'),
28
32
  openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
29
33
  openaiApiKey: optionalEnv('OPENAI_KEY'),
30
- reviewModelsEnabled: [...REQUIRED_PRODUCTION_REVIEW_MODELS],
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'),
31
38
  reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
32
39
  reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
33
40
  dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
34
41
  };
35
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
+ }
36
78
  function optionalNumberEnv(name) {
37
79
  const value = optionalEnv(name);
38
80
  if (value === undefined) {
@@ -43,7 +85,7 @@ function optionalNumberEnv(name) {
43
85
  }
44
86
  function getConversationSummaryApiKeys(ctx) {
45
87
  const apiKeys = {};
46
- if (ctx.reviewModelsEnabled.includes('gemini') && ctx.geminiApiKey) {
88
+ if (ctx.geminiApiKey) {
47
89
  apiKeys.gemini = ctx.geminiApiKey;
48
90
  }
49
91
  if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
@@ -63,83 +105,35 @@ async function main() {
63
105
  logger.info('Review task started', {
64
106
  dryRun: ctx.dryRun,
65
107
  reviewModelsEnabled: ctx.reviewModelsEnabled,
108
+ reviewFocusesEnabled: ctx.reviewFocusesEnabled,
109
+ reviewRoutingCategory: ctx.reviewRoutingCategory,
110
+ reviewRoutingRuleId: ctx.reviewRoutingRuleId,
66
111
  });
67
- const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
68
- // GitHub's Check Runs API requires App-installation auth; calling it with
69
- // writeOctokit (the doistbot PAT) returns 403. Centralize the choice here
70
- // so it can't drift as new Check Run call sites get added.
71
- // See: https://docs.github.com/rest/checks/runs#create-a-check-run
72
- const checkRunOctokit = readOctokit;
73
112
  let exitCode = 0;
74
113
  let reviewCompletedSuccessfully = false;
75
114
  let reviewMetricModels = [];
76
- // Centralizes the boilerplate for posting the final Check Run from the
77
- // ~7 cleanup paths in this function. Skips silently when no headSha is
78
- // available (rare; logged at startup) or when running under dryRun.
79
- async function postFinalCheckRun(input) {
80
- if (ctx.dryRun || !ctx.headSha)
81
- return;
82
- const baseArgs = {
83
- octokit: checkRunOctokit,
84
- owner: ctx.owner,
85
- repo: ctx.repo,
86
- sha: ctx.headSha,
87
- pullNumber: ctx.prNumber,
88
- deliveryId: ctx.deliveryId,
89
- checkRunId: ctx.reviewDispatchLockCheckRunId,
90
- };
91
- if (input.conclusion === 'success') {
92
- await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
93
- }
94
- else {
95
- await postReviewFailureCheckRun(baseArgs);
96
- }
97
- // If the PR head moved during the review, also drop a neutral stale-
98
- // pointer Check Run on the current head so reviewers can find their
99
- // way back to the original. Closes the in-progress-during-push gap
100
- // the webhook-side fix (#311) can't cover on its own — see the helper
101
- // doc-comment for the full timeline this handles.
102
- await postStaleReviewPointerIfHeadMoved({
103
- octokit: checkRunOctokit,
104
- owner: ctx.owner,
105
- repo: ctx.repo,
106
- pullNumber: ctx.prNumber,
107
- reviewedSha: ctx.headSha,
108
- reviewedTitle: getReviewCheckRunTitle(input),
109
- });
110
- }
111
- try {
112
- if (!ctx.dryRun && ctx.headSha) {
113
- // Concurrency lock + "review running" signal both come from the
114
- // same Check Run: an `in_progress` run is posted at start (and
115
- // upserted to the final conclusion when the review completes).
116
- //
117
- // Caveat: the lock is keyed by the *triggering* SHA, but the
118
- // review pipeline itself (cloneRepository / pulls.listFiles)
119
- // operates on the PR's current head — they're not pinned to
120
- // ctx.headSha. So a new commit landing during a review can slip
121
- // past this lock and produce a duplicate review of the same final
122
- // PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
123
- // and the case is rare; pinning the pipeline is its own follow-up
124
- // (see PR #274's "Deferred" section, finding C2).
125
- //
126
- // There's also a small TOCTOU window between this check and the
127
- // in_progress post — same race the prior comment-based lock had,
128
- // and same bound (one duplicate review when it fires).
129
- if (!ctx.reviewDispatchLockAcquired) {
130
- const otherInProgress = await isAnotherReviewInProgress({
131
- octokit: checkRunOctokit,
132
- owner: ctx.owner,
133
- repo: ctx.repo,
134
- sha: ctx.headSha,
135
- deliveryId: ctx.deliveryId,
136
- });
137
- if (otherInProgress) {
138
- logger.info('Another review is already in progress on this SHA, skipping');
139
- return;
140
- }
141
- }
142
- await postReviewInProgressCheckRun({
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 = {
143
137
  octokit: checkRunOctokit,
144
138
  owner: ctx.owner,
145
139
  repo: ctx.repo,
@@ -147,133 +141,206 @@ async function main() {
147
141
  pullNumber: ctx.prNumber,
148
142
  deliveryId: ctx.deliveryId,
149
143
  checkRunId: ctx.reviewDispatchLockCheckRunId,
150
- });
151
- }
152
- else if (ctx.dryRun) {
153
- logger.info('Dry run enabled, skipping check-run posting');
154
- }
155
- else {
156
- // No HEAD_SHA available — can't post Check Runs (which need a
157
- // SHA) and can't lock. Reviews proceed unguarded; a rare
158
- // simultaneous /review may double-bill on the same PR. Log so
159
- // this is visible.
160
- logger.warn('HEAD_SHA missing; review will proceed without a Check Run lock or status posting');
161
- }
162
- await cloneRepository({
163
- app,
164
- owner: ctx.owner,
165
- repo: ctx.repo,
166
- prNumber: ctx.prNumber,
167
- installationId: ctx.installationId,
168
- });
169
- const [prResponse, files] = await Promise.all([
170
- readOctokit.rest.pulls.get({
171
- owner: ctx.owner,
172
- repo: ctx.repo,
173
- pull_number: ctx.prNumber,
174
- }),
175
- readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
176
- owner: ctx.owner,
177
- repo: ctx.repo,
178
- pull_number: ctx.prNumber,
179
- per_page: 100,
180
- }),
181
- ]);
182
- const pr = prResponse.data;
183
- const prAuthor = pr.user?.login ?? 'unknown';
184
- function loadPrConversation() {
185
- return fetchReviewConversationContext({
186
- octokit: readOctokit,
144
+ };
145
+ if (input.conclusion === 'success') {
146
+ await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
147
+ }
148
+ else {
149
+ await postReviewFailureCheckRun(baseArgs);
150
+ }
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({
157
+ octokit: checkRunOctokit,
187
158
  owner: ctx.owner,
188
159
  repo: ctx.repo,
189
160
  pullNumber: ctx.prNumber,
190
- prAuthor: pr.user?.login ?? undefined,
191
- summaryApiKeys: getConversationSummaryApiKeys(ctx),
192
- workspaceDir: WORKSPACE_DIR,
161
+ reviewedSha: ctx.headSha,
162
+ reviewedTitle: getReviewCheckRunTitle(input),
193
163
  });
194
164
  }
195
- reviewMetricModels = [...ctx.reviewModelsEnabled];
196
- const reviewApiKeys = {};
197
- if (ctx.geminiApiKey) {
198
- reviewApiKeys.gemini = ctx.geminiApiKey;
199
- }
200
- if (ctx.openRouterApiKey) {
201
- reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
202
- reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
203
- }
204
- if (ctx.openaiApiKey) {
205
- reviewApiKeys.openai = ctx.openaiApiKey;
206
- }
207
- const reviewResult = await runSharedReview({
208
- owner: ctx.owner,
209
- repo: ctx.repo,
210
- prNumber: ctx.prNumber,
211
- branch: ctx.branch,
212
- baseBranch: ctx.baseBranch,
213
- prTitle: pr.title ?? '',
214
- prBody: pr.body ?? '',
215
- loadPrConversation,
216
- prAuthor,
217
- files: files,
218
- apiKeys: reviewApiKeys,
219
- reviewModelsEnabled: ctx.reviewModelsEnabled,
220
- requiredReviewModels: REQUIRED_PRODUCTION_REVIEW_MODELS,
221
- workspaceDir: WORKSPACE_DIR,
222
- });
223
- reviewMetricModels = reviewResult.metricModels;
224
- if (reviewResult.status === 'skipped') {
225
- if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
226
- await postFinalCheckRun({
227
- conclusion: 'success',
228
- review: { summary: '', comments: [] },
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,
229
204
  });
230
205
  }
206
+ else if (ctx.dryRun) {
207
+ logger.info('Dry run enabled, skipping check-run posting');
208
+ }
231
209
  else {
232
- await postFinalCheckRun({ conclusion: 'failure' });
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');
233
215
  }
234
- return;
235
- }
236
- const { review } = reviewResult;
237
- if (ctx.dryRun) {
238
- logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
239
- commentsCount: review.comments.length,
216
+ await cloneRepository({
217
+ app,
218
+ owner: ctx.owner,
219
+ repo: ctx.repo,
220
+ prNumber: ctx.prNumber,
221
+ installationId: ctx.installationId,
240
222
  });
241
- process.stdout.write(`${formatReviewForTerminal({
242
- review,
243
- })}\n`);
244
- }
245
- else {
246
- await submitReview({
247
- octokit: writeOctokit,
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({
248
262
  owner: ctx.owner,
249
263
  repo: ctx.repo,
250
- pull_number: ctx.prNumber,
251
- deliveryId: ctx.deliveryId,
252
- review,
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,
276
+ workspaceDir: WORKSPACE_DIR,
253
277
  });
254
- await postFinalCheckRun({ conclusion: 'success', review });
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,
302
+ });
303
+ process.stdout.write(`${formatReviewForTerminal({
304
+ review,
305
+ })}\n`);
306
+ }
307
+ else {
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 });
317
+ }
318
+ logger.info('Review task completed');
319
+ reviewCompletedSuccessfully = true;
255
320
  }
256
- logger.info('Review task completed');
257
- reviewCompletedSuccessfully = true;
258
- }
259
- catch (error) {
260
- logger.error('Review failed', {
261
- error: error instanceof Error ? error.message : String(error),
262
- });
263
- await postFinalCheckRun({ conclusion: 'failure' });
264
- exitCode = 1;
265
- }
266
- finally {
267
- if (reviewMetricModels.length > 0) {
268
- await recordTaskMetrics({
269
- type: 'review',
270
- repository: `${ctx.owner}/${ctx.repo}`,
271
- model: reviewMetricModels,
272
- outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
273
- durationMs: performance.now() - startedAt,
321
+ catch (error) {
322
+ const message = error instanceof Error ? error.message : String(error);
323
+ logger.error('Review failed', {
324
+ error: message,
274
325
  });
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;
275
331
  }
276
- }
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
+ }
342
+ }
343
+ });
277
344
  if (exitCode !== 0) {
278
345
  process.exit(exitCode);
279
346
  }