@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,214 @@
1
+ import { performance } from 'node:perf_hooks';
2
+ import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
3
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
4
+ import { App } from 'octokit';
5
+ import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
6
+ import { recordTaskMetrics, } from '../../core/datadog-metrics.js';
7
+ import { logger } from '../../core/logging.js';
8
+ import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
9
+ import { LOG_SNAPSHOT_MARKER, parseS3MetadataFromComment, PRESIGN_EXPIRY_SECONDS, } from '../../core/snapshot-helpers.js';
10
+ import { shutdownTracing } from '../../core/tracing-setup.js';
11
+ function requiredEnv(name) {
12
+ const value = process.env[name];
13
+ if (!value)
14
+ throw new Error(`Missing required environment variable: ${name}`);
15
+ return value;
16
+ }
17
+ function parseContext() {
18
+ return {
19
+ owner: requiredEnv('REPO_OWNER'),
20
+ repo: requiredEnv('REPO_NAME'),
21
+ issueNumber: Number(requiredEnv('ISSUE_NUMBER')),
22
+ installationId: Number(requiredEnv('INSTALLATION_ID')),
23
+ appId: requiredEnv('APP_ID'),
24
+ privateKey: requiredEnv('PRIVATE_KEY'),
25
+ logSnapshotBucket: requiredEnv('LOG_SNAPSHOT_BUCKET'),
26
+ commentId: process.env.COMMENT_ID ? Number(process.env.COMMENT_ID) : undefined,
27
+ reactionId: process.env.REACTION_ID ? Number(process.env.REACTION_ID) : undefined,
28
+ deliveryId: process.env.DELIVERY_ID?.trim() || 'unknown',
29
+ };
30
+ }
31
+ export async function main() {
32
+ const startedAt = performance.now();
33
+ const ctx = parseContext();
34
+ let exitCode = 0;
35
+ let metricOutcome = 'failure';
36
+ let metricReason;
37
+ logger.setBaseContext({
38
+ deliveryId: ctx.deliveryId,
39
+ repository: `${ctx.owner}/${ctx.repo}`,
40
+ issueNumber: ctx.issueNumber,
41
+ });
42
+ logger.info('Refresh-s3-urls task started', {
43
+ task: 'refresh-s3-urls',
44
+ issueNumber: ctx.issueNumber,
45
+ });
46
+ let octokit;
47
+ let cleanedUp = false;
48
+ async function cleanup() {
49
+ if (cleanedUp)
50
+ return;
51
+ cleanedUp = true;
52
+ if (!octokit)
53
+ return;
54
+ await removeIssueCommentReactionIfPresent({
55
+ octokit,
56
+ owner: ctx.owner,
57
+ repo: ctx.repo,
58
+ commentId: ctx.commentId,
59
+ reactionId: ctx.reactionId,
60
+ warnMessage: 'Failed to remove refresh-s3-urls reaction',
61
+ successMessage: 'Refresh-s3-urls reaction removed',
62
+ logMeta: { task: 'refresh-s3-urls', reactionId: ctx.reactionId },
63
+ });
64
+ }
65
+ try {
66
+ const app = new App({ appId: ctx.appId, privateKey: ctx.privateKey });
67
+ octokit = await app.getInstallationOctokit(ctx.installationId);
68
+ const snapshotComment = await findSnapshotComment(octokit, ctx);
69
+ if (!snapshotComment) {
70
+ await octokit.rest.issues.createComment({
71
+ owner: ctx.owner,
72
+ repo: ctx.repo,
73
+ issue_number: ctx.issueNumber,
74
+ body: 'No log snapshot found on this issue. Run `@doistbot /persist-logs` first.',
75
+ });
76
+ metricOutcome = 'success';
77
+ return;
78
+ }
79
+ logger.info('Found snapshot comment, parsing S3 metadata', {
80
+ task: 'refresh-s3-urls',
81
+ snapshotCommentId: snapshotComment.id,
82
+ });
83
+ const parsed = parseS3MetadataFromComment(snapshotComment.body ?? '');
84
+ if (!parsed) {
85
+ await octokit.rest.issues.createComment({
86
+ owner: ctx.owner,
87
+ repo: ctx.repo,
88
+ issue_number: ctx.issueNumber,
89
+ body: 'The existing log snapshot does not contain S3 metadata. Please run `@doistbot /persist-logs` again to create a new snapshot with refreshable links.',
90
+ });
91
+ metricOutcome = 'success';
92
+ return;
93
+ }
94
+ if (parsed.bucket !== ctx.logSnapshotBucket) {
95
+ logger.warn('Snapshot bucket mismatch — refusing to sign', {
96
+ task: 'refresh-s3-urls',
97
+ expected: ctx.logSnapshotBucket,
98
+ found: parsed.bucket,
99
+ });
100
+ await octokit.rest.issues.createComment({
101
+ owner: ctx.owner,
102
+ repo: ctx.repo,
103
+ issue_number: ctx.issueNumber,
104
+ body: 'The log snapshot references an unexpected S3 bucket. Please run `@doistbot /persist-logs` again.',
105
+ });
106
+ metricOutcome = 'success';
107
+ return;
108
+ }
109
+ // Generate fresh presigned URLs for all S3 keys
110
+ logger.info('Generating fresh presigned URLs', {
111
+ task: 'refresh-s3-urls',
112
+ bucket: parsed.bucket,
113
+ keyCount: parsed.keys.length,
114
+ });
115
+ const s3 = new S3Client({});
116
+ const urlMap = new Map();
117
+ for (const key of parsed.keys) {
118
+ const freshUrl = await getSignedUrl(s3, new GetObjectCommand({ Bucket: parsed.bucket, Key: key }), { expiresIn: PRESIGN_EXPIRY_SECONDS });
119
+ urlMap.set(key, freshUrl);
120
+ }
121
+ // Replace all presigned S3 URLs in the comment body
122
+ const updatedBody = replacePresignedUrls(snapshotComment.body ?? '', urlMap);
123
+ logger.info('Presigned URLs refreshed, updating comment', {
124
+ task: 'refresh-s3-urls',
125
+ urlsReplaced: urlMap.size,
126
+ });
127
+ await octokit.rest.issues.updateComment({
128
+ owner: ctx.owner,
129
+ repo: ctx.repo,
130
+ comment_id: snapshotComment.id,
131
+ body: updatedBody,
132
+ });
133
+ metricOutcome = 'success';
134
+ logger.info('Refresh-s3-urls task completed', {
135
+ task: 'refresh-s3-urls',
136
+ issueNumber: ctx.issueNumber,
137
+ snapshotCommentId: snapshotComment.id,
138
+ durationMs: performance.now() - startedAt,
139
+ });
140
+ }
141
+ catch (error) {
142
+ metricReason = 'exception';
143
+ logger.error('Refresh-s3-urls task failed', {
144
+ task: 'refresh-s3-urls',
145
+ issueNumber: ctx.issueNumber,
146
+ error: error instanceof Error ? error.message : String(error),
147
+ durationMs: performance.now() - startedAt,
148
+ });
149
+ if (octokit) {
150
+ try {
151
+ await octokit.rest.issues.createComment({
152
+ owner: ctx.owner,
153
+ repo: ctx.repo,
154
+ issue_number: ctx.issueNumber,
155
+ body: 'Sorry, I encountered an error while refreshing the log snapshot URLs. Please try again.',
156
+ });
157
+ }
158
+ catch {
159
+ // Best effort
160
+ }
161
+ }
162
+ exitCode = 1;
163
+ }
164
+ finally {
165
+ // Independent network targets (GitHub vs Datadog) — safe to run concurrently.
166
+ await Promise.all([
167
+ cleanup(),
168
+ recordTaskMetrics({
169
+ type: 'refresh',
170
+ repository: `${ctx.owner}/${ctx.repo}`,
171
+ model: 'none',
172
+ outcome: metricOutcome,
173
+ durationMs: performance.now() - startedAt,
174
+ reason: metricReason,
175
+ }),
176
+ ]);
177
+ }
178
+ if (exitCode !== 0) {
179
+ await shutdownTracing();
180
+ process.exit(exitCode);
181
+ }
182
+ }
183
+ async function findSnapshotComment(octokit, ctx) {
184
+ const comments = await octokit.paginate(octokit.rest.issues.listComments, {
185
+ owner: ctx.owner,
186
+ repo: ctx.repo,
187
+ issue_number: ctx.issueNumber,
188
+ per_page: 100,
189
+ });
190
+ // Return the most recent bot-authored snapshot so /refresh-s3-urls targets the latest one
191
+ let latest = null;
192
+ for (const comment of comments) {
193
+ const login = comment.user?.login;
194
+ if (isKnownDoistbotLogin(login) && comment.body?.includes(LOG_SNAPSHOT_MARKER)) {
195
+ latest = { id: comment.id, body: comment.body };
196
+ }
197
+ }
198
+ return latest;
199
+ }
200
+ /**
201
+ * Replaces presigned S3 URLs in a comment body with fresh ones.
202
+ * Matches any URL containing `X-Amz-` query params (the presigned URL signature)
203
+ * and swaps it if the URL path contains one of our known S3 keys.
204
+ */
205
+ export function replacePresignedUrls(body, urlMap) {
206
+ return body.replace(/https:\/\/[^\s)]+X-Amz-[^\s)]+/g, (existingUrl) => {
207
+ for (const [key, freshUrl] of urlMap) {
208
+ if (existingUrl.includes(encodeURIComponent(key)) || existingUrl.includes(key)) {
209
+ return freshUrl;
210
+ }
211
+ }
212
+ return existingUrl;
213
+ });
214
+ }
@@ -0,0 +1,326 @@
1
+ import { isAnotherDeliveryInProgress, postCheckRun } from '../../core/check-run.js';
2
+ import { logger } from '../../core/logging.js';
3
+ import { appendReviewFeedbackLink } from './review-feedback.js';
4
+ import { parseSeverity } from './severity.js';
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
+ }
22
+ /**
23
+ * Concurrency lock for the review task: returns `true` if a different
24
+ * delivery's `in_progress` Check Run is already running on this SHA. Skips
25
+ * the duplicate review. Replaces the pre-Check-Runs PR-comment-based lock.
26
+ */
27
+ export function isAnotherReviewInProgress(params) {
28
+ return isAnotherDeliveryInProgress({ ...params, name: CHECK_RUN_NAME });
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
+ };
39
+ /**
40
+ * Counts findings by their `[Pn]` severity tag, looking at both the inline
41
+ * comments and any summary-only findings the bot included. Findings without
42
+ * a recognizable tag are bucketed as `untagged`.
43
+ */
44
+ export function countFindingsBySeverity(review) {
45
+ const counts = {
46
+ p0: 0,
47
+ p1: 0,
48
+ p2: 0,
49
+ p3: 0,
50
+ untagged: 0,
51
+ total: 0,
52
+ };
53
+ function increment(sev) {
54
+ if (sev)
55
+ counts[SEVERITY_TO_COUNTER_KEY[sev]] += 1;
56
+ else
57
+ counts.untagged += 1;
58
+ counts.total += 1;
59
+ }
60
+ for (const comment of review.comments ?? []) {
61
+ increment(comment.body ? parseSeverity(comment.body) : null);
62
+ }
63
+ // Summary may also contain `[Pn]`-tagged findings — the prompt's fallback
64
+ // tells the model to surface findings in the summary when inline placement
65
+ // fails (e.g. unclear line numbers). Scan line-by-line so we don't
66
+ // double-count tags repeated within the same sentence; only increment for
67
+ // lines that match.
68
+ for (const line of review.summary?.split('\n') ?? []) {
69
+ const sev = parseSeverity(line);
70
+ if (sev)
71
+ increment(sev);
72
+ }
73
+ return counts;
74
+ }
75
+ /**
76
+ * Builds a human-readable title for a successful check run.
77
+ * Examples: "No findings", "3 findings (2 P1, 1 P2)".
78
+ */
79
+ export function buildSuccessTitle(counts) {
80
+ if (counts.total === 0) {
81
+ return 'No findings';
82
+ }
83
+ const parts = [];
84
+ if (counts.p0 > 0)
85
+ parts.push(`${counts.p0} P0`);
86
+ if (counts.p1 > 0)
87
+ parts.push(`${counts.p1} P1`);
88
+ if (counts.p2 > 0)
89
+ parts.push(`${counts.p2} P2`);
90
+ if (counts.p3 > 0)
91
+ parts.push(`${counts.p3} P3`);
92
+ if (counts.untagged > 0)
93
+ parts.push(`${counts.untagged} other`);
94
+ const breakdown = parts.length > 0 ? ` (${parts.join(', ')})` : '';
95
+ return `${counts.total} finding${counts.total === 1 ? '' : 's'}${breakdown}`;
96
+ }
97
+ /**
98
+ * Builds the markdown summary body for a successful check run. Renders in the
99
+ * Checks tab when expanded. Distinguishes between inline and summary-only
100
+ * findings so the wording matches reality (the prompt's fallback contract
101
+ * lets the model surface findings in `review.summary` when it can't place
102
+ * them inline — saying "inline comments" in that case sends authors to look
103
+ * for threads that don't exist).
104
+ */
105
+ export function buildSuccessSummary(review, pullRequestUrl, counts = countFindingsBySeverity(review)) {
106
+ if (counts.total === 0) {
107
+ return [
108
+ 'Doistbot reviewed this PR and surfaced no findings.',
109
+ '',
110
+ `[View PR](${pullRequestUrl})`,
111
+ ].join('\n');
112
+ }
113
+ const inlineCount = review.comments?.length ?? 0;
114
+ let placement;
115
+ if (inlineCount === counts.total) {
116
+ placement = 'as inline comments';
117
+ }
118
+ else if (inlineCount === 0) {
119
+ placement = 'in the review summary';
120
+ }
121
+ else {
122
+ placement = 'on this PR (some inline, some in the review summary)';
123
+ }
124
+ return [
125
+ `Doistbot posted **${counts.total} finding${counts.total === 1 ? '' : 's'}** ${placement}.`,
126
+ '',
127
+ `[View PR](${pullRequestUrl})`,
128
+ ].join('\n');
129
+ }
130
+ /**
131
+ * Private helper: shared boilerplate (`detailsUrl`, `name`, footer links) for
132
+ * the in-progress, success, and failure wrappers below. Appends the standard
133
+ * Datadog-logs + feedback-issue footer used elsewhere in the review output, so
134
+ * authors can act on the Check Run summary directly without hopping back to the
135
+ * PR conversation comment.
136
+ */
137
+ async function postReviewCheckRun(params) {
138
+ const { octokit, owner, repo, sha, pullNumber, deliveryId, checkRunId, title, summaryBody } = params;
139
+ const pullRequestUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`;
140
+ const summary = appendReviewFeedbackLink(summaryBody, {
141
+ owner,
142
+ repo,
143
+ prNumber: pullNumber,
144
+ deliveryId,
145
+ });
146
+ const sharedArgs = {
147
+ octokit,
148
+ owner,
149
+ repo,
150
+ sha,
151
+ title,
152
+ summary,
153
+ detailsUrl: pullRequestUrl,
154
+ name: CHECK_RUN_NAME,
155
+ externalId: deliveryId,
156
+ checkRunId,
157
+ };
158
+ if (params.status === 'in_progress') {
159
+ await postCheckRun({ ...sharedArgs, status: 'in_progress' });
160
+ return;
161
+ }
162
+ await postCheckRun({ ...sharedArgs, conclusion: params.conclusion });
163
+ }
164
+ /**
165
+ * Posts an `in_progress` check run when the review starts, so the PR's Checks
166
+ * tab shows that Doistbot is working. The summary carries the standard
167
+ * footer (Datadog logs + feedback link), so authors can follow live progress
168
+ * via the logs link without waiting for the final result. The completion call
169
+ * (`postReviewSuccessCheckRun` / `postReviewFailureCheckRun`) updates this
170
+ * same run via the upsert path.
171
+ */
172
+ export async function postReviewInProgressCheckRun({ octokit, owner, repo, sha, pullNumber, deliveryId, }) {
173
+ await postReviewCheckRun({
174
+ octokit,
175
+ owner,
176
+ repo,
177
+ sha,
178
+ pullNumber,
179
+ deliveryId,
180
+ status: 'in_progress',
181
+ title: 'Doistbot review in progress',
182
+ summaryBody: 'Doistbot is reviewing this PR. Live logs are linked below.',
183
+ });
184
+ }
185
+ /**
186
+ * Phase-1 advisory mode: conclusion is always `success`, regardless of finding
187
+ * severity. Title summarizes the count + breakdown so authors see it at a
188
+ * glance in the Checks tab. Future phases (per the rollout proposal in
189
+ * `Doist/doistbot-app#237`) may flip to `failure` based on P0/P1 counts.
190
+ */
191
+ export async function postReviewSuccessCheckRun({ octokit, owner, repo, sha, pullNumber, deliveryId, review, }) {
192
+ const pullRequestUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`;
193
+ const counts = countFindingsBySeverity(review);
194
+ await postReviewCheckRun({
195
+ octokit,
196
+ owner,
197
+ repo,
198
+ sha,
199
+ pullNumber,
200
+ deliveryId,
201
+ conclusion: 'success',
202
+ title: buildSuccessTitle(counts),
203
+ summaryBody: buildSuccessSummary(review, pullRequestUrl, counts),
204
+ });
205
+ }
206
+ /** Shared title used by both the failure Check Run and stale-pointer recovery. */
207
+ const FAILURE_TITLE = 'Doistbot review unavailable';
208
+ /**
209
+ * Recovers the title that the terminal Check Run posted to `reviewedSha`,
210
+ * given the same input shape `postFinalCheckRun` already has. Lets the
211
+ * stale-pointer follow-up cite "Original result: …" without duplicating
212
+ * the title-building logic in two places.
213
+ */
214
+ export function getReviewCheckRunTitle(input) {
215
+ if (input.conclusion === 'success') {
216
+ return buildSuccessTitle(countFindingsBySeverity(input.review));
217
+ }
218
+ return FAILURE_TITLE;
219
+ }
220
+ /**
221
+ * Posts a `neutral` check run with a "review unavailable" title so PR authors
222
+ * aren't left wondering whether the bot ran. `neutral` (gray −) visually
223
+ * distinguishes "didn't run" from "ran successfully" without blocking merges,
224
+ * per the rollout proposal: never block merges on Doistbot's own degradation.
225
+ */
226
+ export async function postReviewFailureCheckRun({ octokit, owner, repo, sha, pullNumber, deliveryId, }) {
227
+ const pullRequestUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`;
228
+ await postReviewCheckRun({
229
+ octokit,
230
+ owner,
231
+ repo,
232
+ sha,
233
+ pullNumber,
234
+ deliveryId,
235
+ conclusion: 'neutral',
236
+ title: FAILURE_TITLE,
237
+ summaryBody: [
238
+ "Doistbot's review didn't complete (provider outage, rate limit, or other failure).",
239
+ '',
240
+ "Optionally re-trigger with `@doistbot /review`. Don't block your PR on this.",
241
+ '',
242
+ `[View PR](${pullRequestUrl})`,
243
+ ].join('\n'),
244
+ });
245
+ }
246
+ /**
247
+ * After the terminal Check Run lands on `reviewedSha`, look up the PR's
248
+ * current head SHA. If it has moved during the review (i.e. the user pushed
249
+ * at least one commit while the review was in flight), post a neutral
250
+ * stale-pointer Check Run on the current head that links back to
251
+ * `reviewedSha`. Closes the in-progress-during-push gap that the webhook-side
252
+ * fix in [#311](https://github.com/Doist/doistbot-app/pull/311) can't cover
253
+ * on its own: that path only fires on `pull_request.synchronize` and only
254
+ * surfaces a pointer when a *completed* run sits on `payload.before`, so any
255
+ * commits pushed before the review terminated stayed empty.
256
+ *
257
+ * Uses the same `stale-pointer:<sha>` `external_id` format and same summary
258
+ * copy as `apps/webhook-handler/src/tasks/review/stale-check-run.ts` so the
259
+ * two paths interoperate: a subsequent synchronize-driven post finds this
260
+ * pointer on the prior head and unwraps to `reviewedSha`, keeping the
261
+ * "reference original" semantics across chained pushes.
262
+ *
263
+ * Skips quietly when:
264
+ * - Current head equals `reviewedSha` (no movement — the common case).
265
+ * - `pulls.get` fails (warn + return; restoring visibility is degraded UX,
266
+ * not a correctness issue).
267
+ *
268
+ * The `postCheckRun` call itself swallows write failures with a warn, same
269
+ * pattern as the other terminal-write helpers.
270
+ *
271
+ * TOCTOU caveat: a push between the `pulls.get` here and the `postCheckRun`
272
+ * write can leave the new pointer on a SHA that's no longer the head. Same
273
+ * race shape and same bound as the existing TOCTOU notes elsewhere; the
274
+ * webhook-side handler will paper over it on the next synchronize.
275
+ */
276
+ export async function postStaleReviewPointerIfHeadMoved({ octokit, owner, repo, pullNumber, reviewedSha, reviewedTitle, }) {
277
+ let currentHeadSha;
278
+ try {
279
+ const { data } = await octokit.rest.pulls.get({
280
+ owner,
281
+ repo,
282
+ pull_number: pullNumber,
283
+ });
284
+ currentHeadSha = data.head.sha;
285
+ }
286
+ catch (error) {
287
+ logger.warn('Failed to fetch PR current head; skipping stale pointer', {
288
+ component: 'stale-check-run',
289
+ pr_number: pullNumber,
290
+ reviewed_sha: reviewedSha,
291
+ error: error instanceof Error ? error.message : String(error),
292
+ });
293
+ return;
294
+ }
295
+ if (currentHeadSha === reviewedSha) {
296
+ return;
297
+ }
298
+ const reviewedShaShort = reviewedSha.slice(0, 7);
299
+ const commitUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}/commits/${reviewedSha}`;
300
+ const summary = [
301
+ `⚠️ Doistbot reviewed an earlier commit ([\`${reviewedShaShort}\`](${commitUrl})).`,
302
+ '',
303
+ `**Original result:** ${reviewedTitle}`,
304
+ '',
305
+ 'Inline review comments from the original run are still on this PR; ' +
306
+ 'they were against the prior commit and may not reflect the current diff. ' +
307
+ 'Comment `@doistbot /review` to re-run against the latest commit.',
308
+ ].join('\n');
309
+ await postCheckRun({
310
+ octokit,
311
+ owner,
312
+ repo,
313
+ sha: currentHeadSha,
314
+ name: CHECK_RUN_NAME,
315
+ title: truncateTitle(`${STALE_TITLE_PREFIX}${reviewedTitle}`),
316
+ summary,
317
+ conclusion: 'neutral',
318
+ externalId: `${STALE_POINTER_EXTERNAL_ID_PREFIX}${reviewedSha}`,
319
+ });
320
+ logger.info('Posted stale-pointer Check Run on current head (head moved during review)', {
321
+ component: 'stale-check-run',
322
+ pr_number: pullNumber,
323
+ reviewed_sha: reviewedSha,
324
+ current_head_sha: currentHeadSha,
325
+ });
326
+ }
@@ -0,0 +1,46 @@
1
+ import { logger } from '../../core/logging.js';
2
+ /**
3
+ * Converts model findings to review comments, filtering out invalid diff locations.
4
+ */
5
+ function getInvalidFindingLocationReason(finding, lineLookup) {
6
+ const fileLines = lineLookup.get(finding.path);
7
+ if (!fileLines) {
8
+ return 'file not in diff';
9
+ }
10
+ const validLines = finding.side === 'LEFT' ? fileLines.old : fileLines.new;
11
+ if (!validLines.has(finding.line)) {
12
+ return 'line not in diff';
13
+ }
14
+ return null;
15
+ }
16
+ export function canMapFindingToComment(finding, lineLookup) {
17
+ return getInvalidFindingLocationReason(finding, lineLookup) == null;
18
+ }
19
+ export function convertFindingsToComments(findings, lineLookup) {
20
+ const comments = [];
21
+ const skipped = [];
22
+ for (const finding of findings) {
23
+ const invalidReason = getInvalidFindingLocationReason(finding, lineLookup);
24
+ if (invalidReason) {
25
+ skipped.push({
26
+ path: finding.path,
27
+ line: finding.line,
28
+ reason: invalidReason,
29
+ });
30
+ continue;
31
+ }
32
+ comments.push({
33
+ path: finding.path,
34
+ line: finding.line,
35
+ side: finding.side,
36
+ body: finding.body,
37
+ });
38
+ }
39
+ if (skipped.length > 0) {
40
+ logger.info('Filtered invalid findings', {
41
+ skippedCount: skipped.length,
42
+ skipped,
43
+ });
44
+ }
45
+ return comments;
46
+ }