@todoist/doistbot-cli 0.0.0-stage → 1.0.16

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 (185) hide show
  1. package/dist/actions/auth.js +225 -0
  2. package/dist/actions/doctor.js +39 -0
  3. package/dist/actions/review-usage.js +25 -0
  4. package/dist/actions/review.js +311 -0
  5. package/dist/actions/setup.js +14 -0
  6. package/dist/auth.js +202 -0
  7. package/dist/config.js +145 -0
  8. package/dist/env.js +10 -0
  9. package/dist/file-store.js +112 -0
  10. package/dist/index.js +164 -0
  11. package/dist/input.js +40 -0
  12. package/dist/runtime.js +105 -0
  13. package/dist/secure-store.js +148 -0
  14. package/dist/terminal.js +185 -0
  15. package/dist/test-utils/secure-store-mocks.js +29 -0
  16. package/dist/update-cache-entrypoint.js +16 -0
  17. package/dist/update-check.js +235 -0
  18. package/package.json +65 -5
  19. package/sandbox/_node_modules/doistbot-logging/dist/index.d.ts +23 -0
  20. package/sandbox/_node_modules/doistbot-logging/dist/index.js +51 -0
  21. package/sandbox/_node_modules/doistbot-logging/package.json +12 -0
  22. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +93 -0
  23. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +197 -0
  24. package/sandbox/_node_modules/doistbot-repo-config/package.json +12 -0
  25. package/sandbox/dist/core/autofix-resources.js +58 -0
  26. package/sandbox/dist/core/autofix-service-query.js +69 -0
  27. package/sandbox/dist/core/autofix-test-policy.js +11 -0
  28. package/sandbox/dist/core/autofix-verification.js +355 -0
  29. package/sandbox/dist/core/bot-logins.js +14 -0
  30. package/sandbox/dist/core/check-run.js +158 -0
  31. package/sandbox/dist/core/datadog-metrics.js +113 -0
  32. package/sandbox/dist/core/environment.js +49 -0
  33. package/sandbox/dist/core/git-auth.js +17 -0
  34. package/sandbox/dist/core/github-history.js +4 -0
  35. package/sandbox/dist/core/github-issues.js +18 -0
  36. package/sandbox/dist/core/label-parsing.js +1 -0
  37. package/sandbox/dist/core/langfuse-observation.js +33 -0
  38. package/sandbox/dist/core/langfuse-session.js +39 -0
  39. package/sandbox/dist/core/logger-span-processor.js +90 -0
  40. package/sandbox/dist/core/logging.js +18 -0
  41. package/sandbox/dist/core/pi-autofix-extension.js +188 -0
  42. package/sandbox/dist/core/pi-execution-limits-extension.js +79 -0
  43. package/sandbox/dist/core/pi-progress-logging.js +51 -0
  44. package/sandbox/dist/core/pi-tool-output-filter-extension.js +65 -0
  45. package/sandbox/dist/core/pi.js +870 -0
  46. package/sandbox/dist/core/pii-scrubber.js +31 -0
  47. package/sandbox/dist/core/prompt-template.js +63 -0
  48. package/sandbox/dist/core/reactions.js +36 -0
  49. package/sandbox/dist/core/regexp.js +3 -0
  50. package/sandbox/dist/core/repository-map.js +1 -0
  51. package/sandbox/dist/core/repository-resolver.js +1 -0
  52. package/sandbox/dist/core/retry.js +29 -0
  53. package/sandbox/dist/core/s3-utils.js +42 -0
  54. package/sandbox/dist/core/shared.js +124 -0
  55. package/sandbox/dist/core/snapshot-helpers.js +17 -0
  56. package/sandbox/dist/core/span-test-helpers.js +13 -0
  57. package/sandbox/dist/core/tool-environment.js +83 -0
  58. package/sandbox/dist/core/tool-output.js +18 -0
  59. package/sandbox/dist/core/tracing-setup.js +92 -0
  60. package/sandbox/dist/core/tracing.js +81 -0
  61. package/sandbox/dist/main.js +51 -0
  62. package/sandbox/dist/providers/config.js +23 -0
  63. package/sandbox/dist/providers/credentials.js +40 -0
  64. package/sandbox/dist/providers/gemini.js +28 -0
  65. package/sandbox/dist/providers/helpers.js +253 -0
  66. package/sandbox/dist/providers/index.js +27 -0
  67. package/sandbox/dist/providers/openai.js +23 -0
  68. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  69. package/sandbox/dist/providers/pi-review.js +301 -0
  70. package/sandbox/dist/providers/process.js +34 -0
  71. package/sandbox/dist/providers/schemas.js +61 -0
  72. package/sandbox/dist/providers/types.js +4 -0
  73. package/sandbox/dist/tasks/chat/chat.js +440 -0
  74. package/sandbox/dist/tasks/chat/prompt.js +20 -0
  75. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +304 -0
  76. package/sandbox/dist/tasks/issue-summarize/context.js +21 -0
  77. package/sandbox/dist/tasks/issue-summarize/media.js +335 -0
  78. package/sandbox/dist/tasks/issue-summarize/model.js +230 -0
  79. package/sandbox/dist/tasks/issue-summarize/output.js +45 -0
  80. package/sandbox/dist/tasks/issue-summarize/prompt.js +11 -0
  81. package/sandbox/dist/tasks/issue-summarize/summarize.js +238 -0
  82. package/sandbox/dist/tasks/issue-summarize/thread.js +123 -0
  83. package/sandbox/dist/tasks/issue-summarize/types.js +1 -0
  84. package/sandbox/dist/tasks/issue-triage/assignee-review.js +114 -0
  85. package/sandbox/dist/tasks/issue-triage/autofix-capabilities.js +22 -0
  86. package/sandbox/dist/tasks/issue-triage/autofix-checkpoint.js +122 -0
  87. package/sandbox/dist/tasks/issue-triage/autofix-policy.js +36 -0
  88. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  89. package/sandbox/dist/tasks/issue-triage/context.js +57 -0
  90. package/sandbox/dist/tasks/issue-triage/diff-validator.js +102 -0
  91. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +617 -0
  92. package/sandbox/dist/tasks/issue-triage/fix-context.js +5 -0
  93. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +263 -0
  94. package/sandbox/dist/tasks/issue-triage/fix-failure-reasons.js +2 -0
  95. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.js +526 -0
  96. package/sandbox/dist/tasks/issue-triage/fix-loop/effects.types.js +1 -0
  97. package/sandbox/dist/tasks/issue-triage/fix-loop/fake-effects.js +65 -0
  98. package/sandbox/dist/tasks/issue-triage/fix-loop/promotion-task.js +108 -0
  99. package/sandbox/dist/tasks/issue-triage/fix-loop/recovery.js +512 -0
  100. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +233 -0
  101. package/sandbox/dist/tasks/issue-triage/fix-loop/state-machine.js +171 -0
  102. package/sandbox/dist/tasks/issue-triage/github-request.js +2 -0
  103. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +206 -0
  104. package/sandbox/dist/tasks/issue-triage/markers.js +6 -0
  105. package/sandbox/dist/tasks/issue-triage/model.js +124 -0
  106. package/sandbox/dist/tasks/issue-triage/output.js +164 -0
  107. package/sandbox/dist/tasks/issue-triage/pr-creator.js +585 -0
  108. package/sandbox/dist/tasks/issue-triage/pr-readiness.js +25 -0
  109. package/sandbox/dist/tasks/issue-triage/prompt.js +63 -0
  110. package/sandbox/dist/tasks/issue-triage/repo-config.js +47 -0
  111. package/sandbox/dist/tasks/issue-triage/repo-prompts.js +77 -0
  112. package/sandbox/dist/tasks/issue-triage/report-fixture.js +53 -0
  113. package/sandbox/dist/tasks/issue-triage/report-history.js +113 -0
  114. package/sandbox/dist/tasks/issue-triage/report-schema.js +63 -0
  115. package/sandbox/dist/tasks/issue-triage/report.js +111 -0
  116. package/sandbox/dist/tasks/issue-triage/resolution.js +30 -0
  117. package/sandbox/dist/tasks/issue-triage/routing.js +169 -0
  118. package/sandbox/dist/tasks/issue-triage/sandbox-environment.js +59 -0
  119. package/sandbox/dist/tasks/issue-triage/shared.js +86 -0
  120. package/sandbox/dist/tasks/issue-triage/side-effects.js +102 -0
  121. package/sandbox/dist/tasks/issue-triage/triage.js +442 -0
  122. package/sandbox/dist/tasks/issue-triage/types.js +1 -0
  123. package/sandbox/dist/tasks/persist-logs/context.js +19 -0
  124. package/sandbox/dist/tasks/persist-logs/dd-proxy.js +116 -0
  125. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +156 -0
  126. package/sandbox/dist/tasks/persist-logs/persist-logs.js +491 -0
  127. package/sandbox/dist/tasks/refresh-s3-urls/refresh-s3-urls.js +214 -0
  128. package/sandbox/dist/tasks/review/check-run.js +326 -0
  129. package/sandbox/dist/tasks/review/comment-mapper.js +46 -0
  130. package/sandbox/dist/tasks/review/conversation-context.js +477 -0
  131. package/sandbox/dist/tasks/review/diff.js +95 -0
  132. package/sandbox/dist/tasks/review/engines/dedupe.js +291 -0
  133. package/sandbox/dist/tasks/review/engines/multi-focus.js +419 -0
  134. package/sandbox/dist/tasks/review/engines/shared.js +87 -0
  135. package/sandbox/dist/tasks/review/engines/types.js +1 -0
  136. package/sandbox/dist/tasks/review/finding-output.js +18 -0
  137. package/sandbox/dist/tasks/review/format.js +17 -0
  138. package/sandbox/dist/tasks/review/github-comment-format.js +94 -0
  139. package/sandbox/dist/tasks/review/local-git.js +374 -0
  140. package/sandbox/dist/tasks/review/local.js +118 -0
  141. package/sandbox/dist/tasks/review/multi-focus-prompt.js +59 -0
  142. package/sandbox/dist/tasks/review/prompt.js +81 -0
  143. package/sandbox/dist/tasks/review/review-feedback.js +48 -0
  144. package/sandbox/dist/tasks/review/review-mode-github.js +58 -0
  145. package/sandbox/dist/tasks/review/review-mode-policy.js +232 -0
  146. package/sandbox/dist/tasks/review/review-mode-types.js +1 -0
  147. package/sandbox/dist/tasks/review/review-mode.js +296 -0
  148. package/sandbox/dist/tasks/review/review-summary.js +123 -0
  149. package/sandbox/dist/tasks/review/review.js +391 -0
  150. package/sandbox/dist/tasks/review/runner.js +255 -0
  151. package/sandbox/dist/tasks/review/severity.js +5 -0
  152. package/sandbox/dist/tasks/review/summary-model.js +271 -0
  153. package/sandbox/dist/tasks/review/thinking-level.js +14 -0
  154. package/sandbox/dist/tasks/review/translation-review-policy.js +37 -0
  155. package/sandbox/dist/tasks/review/types.js +1 -0
  156. package/sandbox/dist/tasks/review-slice-plan/context.js +66 -0
  157. package/sandbox/dist/tasks/review-slice-plan/plan.js +210 -0
  158. package/sandbox/dist/tasks/review-slice-plan/prompt.js +59 -0
  159. package/sandbox/dist/tasks/review-slice-plan/review-slice-plan.js +367 -0
  160. package/sandbox/dist/tasks/sentry-triage/store.js +133 -0
  161. package/sandbox/dist/tasks/sentry-triage/triage.js +217 -0
  162. package/sandbox/node_modules/doistbot-logging/dist/index.d.ts +23 -0
  163. package/sandbox/node_modules/doistbot-logging/dist/index.js +51 -0
  164. package/sandbox/node_modules/doistbot-logging/package.json +12 -0
  165. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +93 -0
  166. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +197 -0
  167. package/sandbox/node_modules/doistbot-repo-config/package.json +12 -0
  168. package/sandbox/src/review/prompts/ai-internal-tools.md +254 -0
  169. package/sandbox/src/review/prompts/backend-repository-standards.md +102 -0
  170. package/sandbox/src/review/prompts/observability-standard.md +169 -0
  171. package/sandbox/src/review/prompts/outline-map.json +26 -0
  172. package/sandbox/src/review/prompts/secrets-management.md +286 -0
  173. package/sandbox/src/review/prompts/service-production-readiness.md +122 -0
  174. package/sandbox/src/review/prompts/workload-placement.md +345 -0
  175. package/sandbox/src/tasks/review/prompts/review-focus-prompts/efficiency.md +16 -0
  176. package/sandbox/src/tasks/review/prompts/review-focus-prompts/general.md +32 -0
  177. package/sandbox/src/tasks/review/prompts/review-focus-prompts/quality.md +20 -0
  178. package/sandbox/src/tasks/review/prompts/review-focus-prompts/reuse.md +13 -0
  179. package/sandbox/src/tasks/review/prompts/review-focus-prompts/security.md +18 -0
  180. package/sandbox/src/tasks/review/prompts/review-focus-prompts/standards.md +21 -0
  181. package/sandbox/src/tasks/review/prompts/review-focus-prompts/tests.md +19 -0
  182. package/sandbox/src/tasks/review/prompts/review-mode.md +40 -0
  183. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +142 -0
  184. package/scripts/restore-runtime-assets.mjs +21 -0
  185. package/README.md +0 -3
@@ -0,0 +1,123 @@
1
+ import { logger } from '../../core/logging.js';
2
+ import { requestReviewSummary } from './summary-model.js';
3
+ export const MAX_SUMMARY_BODY_CHARS = 2000;
4
+ const MAX_REVIEW_OBSERVATION_CHARS = 500;
5
+ export function buildReviewSummaryPrompt({ prTitle, prBody, findings, reviewObservations = [], }) {
6
+ const safeTitle = prTitle.trim() || 'Untitled';
7
+ const safeBody = prBody.trim();
8
+ const condensedBody = safeBody
9
+ ? safeBody.slice(0, MAX_SUMMARY_BODY_CHARS)
10
+ : 'No PR description was provided.';
11
+ const findingsBlock = findings.length > 0
12
+ ? findings
13
+ .map((finding) => `<review-finding>\n${finding.trim()}\n</review-finding>`)
14
+ .join('\n\n')
15
+ : 'No issues were flagged in the inline comments.';
16
+ const observationsBlock = reviewObservations.length > 0
17
+ ? reviewObservations
18
+ .map((observation) => observation.trim())
19
+ .filter(Boolean)
20
+ .slice(0, 8)
21
+ .map((observation) => `<review-observation>\n${observation.slice(0, MAX_REVIEW_OBSERVATION_CHARS)}\n</review-observation>`)
22
+ .join('\n\n') || 'No additional review observations were captured.'
23
+ : 'No additional review observations were captured.';
24
+ return `You are an expert code review summarizer for the Doist engineering team.
25
+
26
+ <pr_title>
27
+ ${safeTitle}
28
+ </pr_title>
29
+
30
+ <pr_description>
31
+ ${condensedBody}
32
+ </pr_description>
33
+
34
+ <review_findings>
35
+ ${findingsBlock}
36
+ </review_findings>
37
+
38
+ <review_observations>
39
+ ${observationsBlock}
40
+ </review_observations>
41
+
42
+ Write the complete GitHub review summary body. Synthesize the findings into grouped themes—do not mirror every inline comment one-for-one or reference the reviewers, models, focus passes, or review observations. Use review observations only as context for the overview. Do not mention issues not present in the <review-finding> blocks. If there are no <review-finding> blocks, state that no inline issues were flagged.
43
+
44
+ Structure:
45
+ - Start with one short sentence giving a concise overview of what changed.
46
+ - If issues exist, add a blank line, then the exact heading "Few things worth tightening:", then 1-4 concise bullets grouping what to improve.
47
+ - If no issues exist, do not add a heading or bullets.
48
+
49
+ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR is large and the second sentence adds useful context. Use a kind, warm, and supportive tone while staying concrete, constructive, and pragmatic. Avoid generic praise or flattery. Bullets should be short fragments or single sentences, not paragraphs. Do not include any <review-finding> or <review-observation> tags in the output.
50
+
51
+ Respond with only valid JSON in this exact shape:
52
+ {"summary":"<your summary>","comments":[]}`;
53
+ }
54
+ export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
55
+ const prompt = buildReviewSummaryPrompt({
56
+ prTitle,
57
+ prBody,
58
+ findings,
59
+ reviewObservations,
60
+ });
61
+ logger.info('Summarizing review findings with the review summary model', {
62
+ findingsCount: findings.length,
63
+ reviewObservationsCount: reviewObservations?.length ?? 0,
64
+ });
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', {
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,
111
+ });
112
+ return summary;
113
+ }
114
+ return null;
115
+ }
116
+ export function buildFallbackSummary({ prTitle, findings, }) {
117
+ const safeTitle = prTitle.trim();
118
+ const base = safeTitle ? `This PR updates ${safeTitle}.` : 'This PR updates the codebase.';
119
+ if (findings.length === 0) {
120
+ return `${base} No issues were flagged in the reviewed diff.`;
121
+ }
122
+ return `${base}\n\nFew things worth tightening:\n- See the inline comments for the issues found in the review.`;
123
+ }
@@ -0,0 +1,391 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { SpanStatusCode } from '@opentelemetry/api';
3
+ import { getDefaultReviewModels } from 'doistbot-repo-config';
4
+ import { recordTaskMetrics } from '../../core/datadog-metrics.js';
5
+ import { parseBaseContext, requiredEnv, WORKSPACE_DIR } from '../../core/environment.js';
6
+ import { logger } from '../../core/logging.js';
7
+ import { cloneRepository, createGitHubClients } from '../../core/shared.js';
8
+ import { withPhase } from '../../core/tracing.js';
9
+ import { shutdownTracing } from '../../core/tracing-setup.js';
10
+ import { createOpenRouterCredential } from '../../providers/credentials.js';
11
+ import { getReviewCheckRunTitle, isAnotherReviewInProgress, postReviewFailureCheckRun, postReviewInProgressCheckRun, postReviewSuccessCheckRun, postStaleReviewPointerIfHeadMoved, } from './check-run.js';
12
+ import { fetchReviewConversationContext } from './conversation-context.js';
13
+ import {} from './diff.js';
14
+ import { formatReviewForTerminal } from './format.js';
15
+ import { formatGithubReviewComments, formatGithubReviewSummary } from './github-comment-format.js';
16
+ import { REVIEW_FOCUS_DEFINITIONS } from './multi-focus-prompt.js';
17
+ import { appendReviewFeedbackLink } from './review-feedback.js';
18
+ import { createGitHubReviewModeContext } from './review-mode-github.js';
19
+ import { runSharedReview } from './runner.js';
20
+ const REVIEW_EVENT = 'COMMENT';
21
+ const DEFAULT_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
22
+ const SUPPORTED_PRODUCTION_REVIEW_MODELS = ['openai', 'deepseek', 'grok', 'zai'];
23
+ const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
24
+ function optionalEnv(name) {
25
+ const value = process.env[name]?.trim();
26
+ return value ? value : undefined;
27
+ }
28
+ function parseContext() {
29
+ const base = parseBaseContext();
30
+ return {
31
+ ...base,
32
+ branch: requiredEnv('BRANCH'),
33
+ baseBranch: requiredEnv('BASE_BRANCH'),
34
+ headSha: optionalEnv('HEAD_SHA'),
35
+ geminiApiKey: optionalEnv('GEMINI_API_KEY'),
36
+ openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
37
+ openaiApiKey: optionalEnv('OPENAI_KEY'),
38
+ reviewModelsEnabled: parseReviewModelsEnv(process.env.REVIEW_MODELS_ENABLED),
39
+ reviewFocusesEnabled: parseReviewFocusesEnv(process.env.REVIEW_FOCUSES_ENABLED),
40
+ reviewRoutingCategory: optionalEnv('REVIEW_ROUTING_CATEGORY'),
41
+ reviewRoutingRuleId: optionalEnv('REVIEW_ROUTING_RULE_ID'),
42
+ reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
43
+ reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
44
+ dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
45
+ };
46
+ }
47
+ export function parseReviewModelsEnv(value) {
48
+ return parseCsvAllowList({
49
+ value,
50
+ allowedValues: SUPPORTED_PRODUCTION_REVIEW_MODELS,
51
+ fallback: DEFAULT_PRODUCTION_REVIEW_MODELS,
52
+ envName: 'REVIEW_MODELS_ENABLED',
53
+ });
54
+ }
55
+ export function parseReviewFocusesEnv(value) {
56
+ return parseCsvAllowList({
57
+ value,
58
+ allowedValues: REVIEW_FOCUSES,
59
+ fallback: REVIEW_FOCUSES,
60
+ envName: 'REVIEW_FOCUSES_ENABLED',
61
+ });
62
+ }
63
+ function parseCsvAllowList({ value, allowedValues, fallback, envName, }) {
64
+ if (value === undefined) {
65
+ return [...fallback];
66
+ }
67
+ const allowed = new Set(allowedValues);
68
+ const requested = value
69
+ .split(',')
70
+ .map((item) => item.trim())
71
+ .filter((item) => item.length > 0);
72
+ if (requested.length === 0) {
73
+ throw new Error(`${envName} must include at least one supported value when set.`);
74
+ }
75
+ const unsupported = requested.filter((item) => !allowed.has(item));
76
+ if (unsupported.length > 0) {
77
+ throw new Error(`${envName} contains unsupported value(s): ${[...new Set(unsupported)].join(', ')}. ` +
78
+ `Supported values: ${allowedValues.join(', ')}.`);
79
+ }
80
+ return [...new Set(requested)];
81
+ }
82
+ function optionalNumberEnv(name) {
83
+ const value = optionalEnv(name);
84
+ if (value === undefined) {
85
+ return undefined;
86
+ }
87
+ const parsed = Number(value);
88
+ return Number.isFinite(parsed) ? parsed : undefined;
89
+ }
90
+ function getConversationSummaryApiKeys(ctx) {
91
+ const apiKeys = {};
92
+ if (ctx.geminiApiKey) {
93
+ apiKeys.gemini = ctx.geminiApiKey;
94
+ }
95
+ if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
96
+ apiKeys.openai = ctx.openaiApiKey;
97
+ }
98
+ return apiKeys;
99
+ }
100
+ async function main() {
101
+ const startedAt = performance.now();
102
+ const ctx = parseContext();
103
+ const logContext = {
104
+ deliveryId: ctx.deliveryId ?? 'unknown',
105
+ repository: `${ctx.owner}/${ctx.repo}`,
106
+ prNumber: ctx.prNumber,
107
+ };
108
+ logger.setBaseContext(logContext);
109
+ logger.info('Review task started', {
110
+ dryRun: ctx.dryRun,
111
+ reviewModelsEnabled: ctx.reviewModelsEnabled,
112
+ reviewFocusesEnabled: ctx.reviewFocusesEnabled,
113
+ reviewRoutingCategory: ctx.reviewRoutingCategory,
114
+ reviewRoutingRuleId: ctx.reviewRoutingRuleId,
115
+ });
116
+ let exitCode = 0;
117
+ let reviewCompletedSuccessfully = false;
118
+ let reviewMetricModels = [];
119
+ await withPhase('review', {
120
+ task: 'review',
121
+ 'gen_ai.operation.name': 'invoke_agent',
122
+ 'gen_ai.agent.name': 'doistbot.review',
123
+ 'pr.number': ctx.prNumber,
124
+ dry_run: ctx.dryRun,
125
+ }, async (span) => {
126
+ // Inside the span so installation-auth/setup failures are captured
127
+ // by the root `review` trace.
128
+ const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
129
+ // GitHub's Check Runs API requires App-installation auth; calling it
130
+ // with writeOctokit (the doistbot PAT) returns 403. Centralize the
131
+ // choice here so it can't drift as new Check Run call sites get added.
132
+ // See: https://docs.github.com/rest/checks/runs#create-a-check-run
133
+ const checkRunOctokit = readOctokit;
134
+ // Centralizes the boilerplate for posting the final Check Run from the
135
+ // ~7 cleanup paths below. Skips silently when no headSha is available
136
+ // (rare; logged at startup) or when running under dryRun.
137
+ async function postFinalCheckRun(input) {
138
+ if (ctx.dryRun || !ctx.headSha)
139
+ return;
140
+ const baseArgs = {
141
+ octokit: checkRunOctokit,
142
+ owner: ctx.owner,
143
+ repo: ctx.repo,
144
+ sha: ctx.headSha,
145
+ pullNumber: ctx.prNumber,
146
+ deliveryId: ctx.deliveryId,
147
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
148
+ };
149
+ if (input.conclusion === 'success') {
150
+ await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
151
+ }
152
+ else {
153
+ await postReviewFailureCheckRun(baseArgs);
154
+ }
155
+ // If the PR head moved during the review, also drop a neutral
156
+ // stale-pointer Check Run on the current head so reviewers can
157
+ // find their way back to the original. Closes the in-progress-
158
+ // during-push gap the webhook-side fix (#311) can't cover on its
159
+ // own — see the helper doc-comment for the full timeline.
160
+ await postStaleReviewPointerIfHeadMoved({
161
+ octokit: checkRunOctokit,
162
+ owner: ctx.owner,
163
+ repo: ctx.repo,
164
+ pullNumber: ctx.prNumber,
165
+ reviewedSha: ctx.headSha,
166
+ reviewedTitle: getReviewCheckRunTitle(input),
167
+ });
168
+ }
169
+ try {
170
+ if (!ctx.dryRun && ctx.headSha) {
171
+ // Concurrency lock + "review running" signal both come from the
172
+ // same Check Run: an `in_progress` run is posted at start (and
173
+ // upserted to the final conclusion when the review completes).
174
+ //
175
+ // Caveat: the lock is keyed by the *triggering* SHA, but the
176
+ // review pipeline itself (cloneRepository / pulls.listFiles)
177
+ // operates on the PR's current head — they're not pinned to
178
+ // ctx.headSha. So a new commit landing during a review can slip
179
+ // past this lock and produce a duplicate review of the same final
180
+ // PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
181
+ // and the case is rare; pinning the pipeline is its own follow-up
182
+ // (see PR #274's "Deferred" section, finding C2).
183
+ //
184
+ // There's also a small TOCTOU window between this check and the
185
+ // in_progress post — same race the prior comment-based lock had,
186
+ // and same bound (one duplicate review when it fires).
187
+ if (!ctx.reviewDispatchLockAcquired) {
188
+ const otherInProgress = await isAnotherReviewInProgress({
189
+ octokit: checkRunOctokit,
190
+ owner: ctx.owner,
191
+ repo: ctx.repo,
192
+ sha: ctx.headSha,
193
+ deliveryId: ctx.deliveryId,
194
+ });
195
+ if (otherInProgress) {
196
+ logger.info('Another review is already in progress on this SHA, skipping');
197
+ return;
198
+ }
199
+ }
200
+ await postReviewInProgressCheckRun({
201
+ octokit: checkRunOctokit,
202
+ owner: ctx.owner,
203
+ repo: ctx.repo,
204
+ sha: ctx.headSha,
205
+ pullNumber: ctx.prNumber,
206
+ deliveryId: ctx.deliveryId,
207
+ checkRunId: ctx.reviewDispatchLockCheckRunId,
208
+ });
209
+ }
210
+ else if (ctx.dryRun) {
211
+ logger.info('Dry run enabled, skipping check-run posting');
212
+ }
213
+ else {
214
+ // No HEAD_SHA available — can't post Check Runs (which need a
215
+ // SHA) and can't lock. Reviews proceed unguarded; a rare
216
+ // simultaneous /review may double-bill on the same PR. Log so
217
+ // this is visible.
218
+ logger.warn('HEAD_SHA missing; review will proceed without a Check Run lock or status posting');
219
+ }
220
+ await cloneRepository({
221
+ app,
222
+ owner: ctx.owner,
223
+ repo: ctx.repo,
224
+ prNumber: ctx.prNumber,
225
+ installationId: ctx.installationId,
226
+ });
227
+ const [prResponse, files] = await Promise.all([
228
+ readOctokit.rest.pulls.get({
229
+ owner: ctx.owner,
230
+ repo: ctx.repo,
231
+ pull_number: ctx.prNumber,
232
+ }),
233
+ readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
234
+ owner: ctx.owner,
235
+ repo: ctx.repo,
236
+ pull_number: ctx.prNumber,
237
+ per_page: 100,
238
+ }),
239
+ ]);
240
+ const pr = prResponse.data;
241
+ const prAuthor = pr.user?.login ?? 'unknown';
242
+ function loadPrConversation() {
243
+ return fetchReviewConversationContext({
244
+ octokit: readOctokit,
245
+ owner: ctx.owner,
246
+ repo: ctx.repo,
247
+ pullNumber: ctx.prNumber,
248
+ prAuthor: pr.user?.login ?? undefined,
249
+ summaryApiKeys: getConversationSummaryApiKeys(ctx),
250
+ workspaceDir: WORKSPACE_DIR,
251
+ });
252
+ }
253
+ reviewMetricModels = [...ctx.reviewModelsEnabled];
254
+ const reviewApiKeys = {};
255
+ if (ctx.geminiApiKey) {
256
+ reviewApiKeys.gemini = ctx.geminiApiKey;
257
+ }
258
+ if (ctx.openRouterApiKey) {
259
+ reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
260
+ reviewApiKeys.grok = createOpenRouterCredential(ctx.openRouterApiKey);
261
+ reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
262
+ }
263
+ if (ctx.openaiApiKey) {
264
+ reviewApiKeys.openai = ctx.openaiApiKey;
265
+ }
266
+ const reviewMode = createGitHubReviewModeContext({
267
+ octokit: readOctokit,
268
+ owner: ctx.owner,
269
+ repo: ctx.repo,
270
+ pullNumber: ctx.prNumber,
271
+ baseSha: pr.base.sha,
272
+ labels: pr.labels,
273
+ });
274
+ const reviewResult = await runSharedReview({
275
+ owner: ctx.owner,
276
+ repo: ctx.repo,
277
+ prNumber: ctx.prNumber,
278
+ branch: ctx.branch,
279
+ baseBranch: ctx.baseBranch,
280
+ prTitle: pr.title ?? '',
281
+ prBody: pr.body ?? '',
282
+ loadPrConversation,
283
+ ...(reviewMode ? { reviewMode } : {}),
284
+ prAuthor,
285
+ files: files,
286
+ apiKeys: reviewApiKeys,
287
+ reviewModelsEnabled: ctx.reviewModelsEnabled,
288
+ reviewFocusesEnabled: ctx.reviewFocusesEnabled,
289
+ requiredReviewModels: ctx.reviewModelsEnabled,
290
+ workspaceDir: WORKSPACE_DIR,
291
+ });
292
+ reviewMetricModels = reviewResult.metricModels;
293
+ if (reviewResult.status === 'skipped') {
294
+ if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
295
+ await postFinalCheckRun({
296
+ conclusion: 'success',
297
+ review: { summary: '', comments: [] },
298
+ });
299
+ }
300
+ else {
301
+ // no_models / no_review / empty_review post a failing
302
+ // check run, so the span must end ERROR to match — a
303
+ // return-based failure won't trip withPhase's throw path.
304
+ span.setStatus({
305
+ code: SpanStatusCode.ERROR,
306
+ message: `review skipped: ${reviewResult.reason}`,
307
+ });
308
+ await postFinalCheckRun({ conclusion: 'failure' });
309
+ }
310
+ return;
311
+ }
312
+ const { review } = reviewResult;
313
+ if (ctx.dryRun) {
314
+ logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
315
+ commentsCount: review.comments.length,
316
+ });
317
+ process.stdout.write(`${formatReviewForTerminal({
318
+ review,
319
+ })}\n`);
320
+ }
321
+ else {
322
+ await submitReview({
323
+ octokit: writeOctokit,
324
+ owner: ctx.owner,
325
+ repo: ctx.repo,
326
+ pull_number: ctx.prNumber,
327
+ deliveryId: ctx.deliveryId,
328
+ review,
329
+ });
330
+ await postFinalCheckRun({ conclusion: 'success', review });
331
+ }
332
+ logger.info('Review task completed');
333
+ reviewCompletedSuccessfully = true;
334
+ }
335
+ catch (error) {
336
+ const message = error instanceof Error ? error.message : String(error);
337
+ logger.error('Review failed', {
338
+ error: message,
339
+ });
340
+ await postFinalCheckRun({ conclusion: 'failure' });
341
+ // Set the span status before the run exits; deferring process.exit
342
+ // until after withPhase lets phase.end flush.
343
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
344
+ exitCode = 1;
345
+ }
346
+ finally {
347
+ if (reviewMetricModels.length > 0) {
348
+ await recordTaskMetrics({
349
+ type: 'review',
350
+ repository: `${ctx.owner}/${ctx.repo}`,
351
+ model: reviewMetricModels,
352
+ outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
353
+ durationMs: performance.now() - startedAt,
354
+ });
355
+ }
356
+ }
357
+ });
358
+ if (exitCode !== 0) {
359
+ await shutdownTracing();
360
+ process.exit(exitCode);
361
+ }
362
+ }
363
+ async function submitReview({ octokit, owner, repo, pull_number, deliveryId, review, }) {
364
+ const payload = {
365
+ owner,
366
+ repo,
367
+ pull_number,
368
+ event: REVIEW_EVENT,
369
+ };
370
+ if (review.summary?.length) {
371
+ const summaryWithFeedbackLink = appendReviewFeedbackLink(formatGithubReviewSummary(review.summary), {
372
+ owner,
373
+ repo,
374
+ prNumber: pull_number,
375
+ deliveryId,
376
+ });
377
+ if (summaryWithFeedbackLink) {
378
+ payload.body = summaryWithFeedbackLink;
379
+ }
380
+ }
381
+ if (review.comments?.length) {
382
+ payload.comments = formatGithubReviewComments(review.comments);
383
+ }
384
+ await octokit.rest.pulls.createReview(payload);
385
+ logger.info('Submitted review', {
386
+ commentsCount: review.comments?.length ?? 0,
387
+ });
388
+ }
389
+ if (process.env.VITEST !== 'true') {
390
+ main();
391
+ }