@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,585 @@
1
+ import { execFileSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
6
+ import { gitAuthEnv } from '../../core/git-auth.js';
7
+ import { logger } from '../../core/logging.js';
8
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
9
+ import { escapeRegExp } from '../../core/regexp.js';
10
+ import { withRetry } from '../../core/retry.js';
11
+ import { requestAssigneeReview } from './assignee-review.js';
12
+ import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
13
+ import { markPullRequestReady } from './pr-readiness.js';
14
+ const PR_TEMPLATE_PATHS = [
15
+ '.github/pull_request_template.md',
16
+ '.github/PULL_REQUEST_TEMPLATE.md',
17
+ 'pull_request_template.md',
18
+ 'PULL_REQUEST_TEMPLATE.md',
19
+ 'docs/pull_request_template.md',
20
+ ];
21
+ const GIT_PUSH_TIMEOUT_MS = 2 * 60 * 1000;
22
+ const MAX_FIX_PR_BODY_CHARS = 60_000;
23
+ export const RECORDED_CHECKS_HEADING = '## Recorded autofix checks';
24
+ const REFERENCE_HEADING_PATTERN = /^#{1,6}\s+(?:\S+\s+)?references?\s*:?\s*$/i;
25
+ export function findPRTemplate(repoLocalPath) {
26
+ for (const templatePath of PR_TEMPLATE_PATHS) {
27
+ const fullPath = path.join(repoLocalPath, templatePath);
28
+ try {
29
+ const content = fs.readFileSync(fullPath, 'utf8').trim();
30
+ if (content) {
31
+ logger.info('Found PR template', {
32
+ task: 'issue-triage',
33
+ templatePath,
34
+ repoLocalPath,
35
+ contentChars: content.length,
36
+ });
37
+ return content;
38
+ }
39
+ }
40
+ catch {
41
+ // File doesn't exist at this path, try next
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ // TODO: extract into a shared module consumed by both `apps/sandbox` and
47
+ // `apps/webhook-handler`. The webhook handler hardcodes the same prefix in
48
+ // `apps/webhook-handler/src/tasks/promotion-ci-gate/index.ts`
49
+ // (`DOISTBOT_BRANCH_PREFIX`) to fast-path filter `check_suite.completed`
50
+ // events before any API call. If this value changes, update both sites.
51
+ const DOISTBOT_BRANCH_PREFIX = 'doistbot/';
52
+ export function generateBranchName(source) {
53
+ const timestamp = Date.now();
54
+ const { issueNumber, sentryIssue } = source;
55
+ const identity = sentryIssue
56
+ ? `sentry-${sentryIssue.product.toLowerCase()}-${sentryIssue.routeId}-${sentryIssue.organization}-${sentryIssue.projectId}-${sentryIssue.issueId}`
57
+ : `issue-${issueNumber}`;
58
+ return `${DOISTBOT_BRANCH_PREFIX}auto-fix/${identity}-${timestamp}`;
59
+ }
60
+ export async function resolveInstallationIdForRepo({ app, owner, repo, fallbackInstallationId, }) {
61
+ try {
62
+ const { data } = await app.octokit.request('GET /repos/{owner}/{repo}/installation', {
63
+ owner,
64
+ repo,
65
+ });
66
+ if (typeof data.id === 'number' && Number.isInteger(data.id) && data.id > 0) {
67
+ return data.id;
68
+ }
69
+ }
70
+ catch (error) {
71
+ logger.warn('Failed to resolve installation for target repository; using fallback', {
72
+ task: 'issue-triage',
73
+ repository: `${owner}/${repo}`,
74
+ error: error instanceof Error ? error.message : String(error),
75
+ });
76
+ }
77
+ return fallbackInstallationId;
78
+ }
79
+ const GIT_IDENTITY_ENV = {
80
+ GIT_AUTHOR_NAME: 'Doistbot',
81
+ GIT_AUTHOR_EMAIL: '37183429+doistbot@users.noreply.github.com',
82
+ GIT_COMMITTER_NAME: 'Doistbot',
83
+ GIT_COMMITTER_EMAIL: '37183429+doistbot@users.noreply.github.com',
84
+ };
85
+ function pushBranch({ repoLocalPath, branch, token, owner, repo, }) {
86
+ const remoteUrl = `https://github.com/${owner}/${repo}.git`;
87
+ execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'push', remoteUrl, branch], {
88
+ cwd: repoLocalPath,
89
+ encoding: 'utf8',
90
+ stdio: 'pipe',
91
+ timeout: GIT_PUSH_TIMEOUT_MS,
92
+ env: gitAuthEnv(token),
93
+ });
94
+ }
95
+ export function commitStagedChanges({ repoLocalPath, checkoutArgs, commitMessage, signingKey, }) {
96
+ const env = { ...process.env, ...GIT_IDENTITY_ENV };
97
+ delete env.GIT_SSH_SIGNING_KEY;
98
+ const execOptions = {
99
+ cwd: repoLocalPath,
100
+ encoding: 'utf8',
101
+ stdio: 'pipe',
102
+ env,
103
+ };
104
+ execFileSync('git', checkoutArgs, execOptions);
105
+ const signingDir = signingKey
106
+ ? fs.mkdtempSync(path.join(os.tmpdir(), 'doistbot-signing-'))
107
+ : undefined;
108
+ try {
109
+ const signingArgs = [];
110
+ if (signingDir && signingKey) {
111
+ const keyPath = path.join(signingDir, 'key');
112
+ fs.writeFileSync(keyPath, `${signingKey.trim()}\n`, { mode: 0o600 });
113
+ signingArgs.push('-c', 'gpg.format=ssh', '-c', `user.signingkey=${keyPath}`, '-c', 'gpg.ssh.program=/usr/bin/ssh-keygen');
114
+ }
115
+ // Files are already staged by captureGitDiff in fix-attempt.ts.
116
+ execFileSync('git', [
117
+ '-c',
118
+ 'core.hooksPath=/dev/null',
119
+ ...signingArgs,
120
+ 'commit',
121
+ signingDir ? '-S' : '--no-gpg-sign',
122
+ '-m',
123
+ commitMessage,
124
+ ], execOptions);
125
+ }
126
+ finally {
127
+ if (signingDir)
128
+ fs.rmSync(signingDir, { recursive: true, force: true });
129
+ }
130
+ return execFileSync('git', ['rev-parse', 'HEAD'], execOptions).trim();
131
+ }
132
+ async function commitAndPushBranch({ branch, githubToken, repoOwner, repoName, octokit, ...commit }) {
133
+ const signingKey = process.env.GIT_SSH_SIGNING_KEY?.trim();
134
+ if (!signingKey) {
135
+ throw new Error('GIT_SSH_SIGNING_KEY is required to publish Doistbot commits.');
136
+ }
137
+ const headSha = commitStagedChanges({ ...commit, signingKey });
138
+ pushBranch({
139
+ repoLocalPath: commit.repoLocalPath,
140
+ branch,
141
+ token: githubToken,
142
+ owner: repoOwner,
143
+ repo: repoName,
144
+ });
145
+ await verifyCommitSignature({ octokit, repoOwner, repoName, headSha });
146
+ return headSha;
147
+ }
148
+ export async function verifyCommitSignature({ octokit, repoOwner, repoName, headSha, }) {
149
+ const { data } = await withRetry(() => octokit.rest.repos.getCommit({
150
+ owner: repoOwner,
151
+ repo: repoName,
152
+ ref: headSha,
153
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
154
+ }));
155
+ if (data.commit.verification?.verified !== true) {
156
+ throw new Error(`GitHub did not verify Doistbot commit ${headSha} (${data.commit.verification?.reason ?? 'no verification result'}).`);
157
+ }
158
+ }
159
+ /**
160
+ * Strips markdown sections whose body contains no meaningful content.
161
+ * A section is considered empty when its body (between this heading and the
162
+ * next) consists only of whitespace, HTML comments, common placeholder text
163
+ * (e.g. "N/A"), or entirely-unchecked checkbox items.
164
+ */
165
+ export function stripEmptySections(body) {
166
+ const lines = body.split('\n');
167
+ const sections = [];
168
+ const preamble = [];
169
+ for (const line of lines) {
170
+ const headingMatch = line.match(/^(#{1,6})\s/);
171
+ if (headingMatch) {
172
+ const level = headingMatch[1].length;
173
+ // Only start a new top-level section for headings at the same or higher
174
+ // level as the current one. Deeper subheadings are treated as content
175
+ // of the enclosing section so a parent isn't stripped when only its
176
+ // children carry the content.
177
+ if (sections.length === 0 || level <= sections[sections.length - 1].level) {
178
+ sections.push({ headingLine: line, bodyLines: [], level });
179
+ }
180
+ else {
181
+ sections[sections.length - 1].bodyLines.push(line);
182
+ }
183
+ }
184
+ else if (sections.length > 0) {
185
+ sections[sections.length - 1].bodyLines.push(line);
186
+ }
187
+ else {
188
+ preamble.push(line);
189
+ }
190
+ }
191
+ const nonEmpty = sections.filter((section) => {
192
+ let content = section.bodyLines
193
+ // Exclude subheading lines so a parent with only empty children is still empty
194
+ .filter((l) => !/^#{1,6}\s/.test(l))
195
+ .join('\n');
196
+ // Strip HTML comments
197
+ content = content.replace(/<!--[\s\S]*?-->/g, '');
198
+ content = content.trim();
199
+ if (!content)
200
+ return false;
201
+ // Common placeholder values (with optional list bullet and trailing period)
202
+ if (/^(?:[-*+]\s*)?(N\/A|None|TODO|TBD|-|—)\.?$/i.test(content))
203
+ return false;
204
+ // All lines are unchecked checkboxes — template boilerplate the model left as-is
205
+ const contentLines = content
206
+ .split('\n')
207
+ .map((l) => l.trim())
208
+ .filter((l) => l);
209
+ if (contentLines.length > 0 && contentLines.every((l) => /^[-*+] \[ \](?:\s|$)/.test(l)))
210
+ return false;
211
+ return true;
212
+ });
213
+ const result = [...preamble, ...nonEmpty.flatMap((s) => [s.headingLine, ...s.bodyLines])];
214
+ // Collapse runs of 2+ blank lines down to 1
215
+ const collapsed = [];
216
+ let blankCount = 0;
217
+ for (const line of result) {
218
+ if (line.trim() === '') {
219
+ blankCount++;
220
+ if (blankCount <= 1)
221
+ collapsed.push(line);
222
+ }
223
+ else {
224
+ blankCount = 0;
225
+ collapsed.push(line);
226
+ }
227
+ }
228
+ return collapsed.join('\n').trim();
229
+ }
230
+ /**
231
+ * Places the Sentry closing reference in the description's Reference section.
232
+ *
233
+ * Replaces a Reference section line that only references the issue, or else starts the first
234
+ * Reference section. Without a Reference section, it replaces such a line elsewhere, such as a
235
+ * template's `fixes` line, or adds a section before the recorded autofix checks.
236
+ */
237
+ function placeClosingReference({ body, issueUrl, shortId, reference, }) {
238
+ const id = escapeRegExp(shortId);
239
+ const url = `${escapeRegExp(issueUrl.replace(/\/+$/, ''))}/?`;
240
+ const issueReferenceLine = new RegExp(`^(\\s*(?:[-*+]\\s+)?)(?:(?:related\\s+to|refs?|see|fix(?:e[sd])?|close[sd]?|resolve[sd]?|(?:sentry|source)\\s+issue)\\s*:?\\s+)?(?:<?${url}>?|${id}|\\[(?:${id}|${url})\\]\\(${url}\\))\\.?\\s*$`, 'i');
241
+ const lines = body.split('\n');
242
+ let referenceHeading = -1;
243
+ let referenceLevel = 0;
244
+ let sectionLine = -1;
245
+ let otherLine = -1;
246
+ let inComment = false;
247
+ let fence = '';
248
+ lines.forEach((line, index) => {
249
+ if (!inComment) {
250
+ const fenceMarker = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
251
+ if (fence) {
252
+ if (fenceMarker?.startsWith(fence) && line.trim() === fenceMarker)
253
+ fence = '';
254
+ return;
255
+ }
256
+ if (fenceMarker) {
257
+ fence = fenceMarker;
258
+ return;
259
+ }
260
+ }
261
+ const commented = inComment;
262
+ const commentStart = line.lastIndexOf('<!--');
263
+ const commentEnd = line.lastIndexOf('-->');
264
+ if (commentStart > commentEnd)
265
+ inComment = true;
266
+ else if (commentEnd >= 0)
267
+ inComment = false;
268
+ if (commented)
269
+ return;
270
+ const heading = line.match(/^(#{1,6})\s/);
271
+ if (heading) {
272
+ const level = heading[1].length;
273
+ if (REFERENCE_HEADING_PATTERN.test(line)) {
274
+ referenceLevel = level;
275
+ if (referenceHeading < 0)
276
+ referenceHeading = index;
277
+ }
278
+ else if (level <= referenceLevel) {
279
+ referenceLevel = 0;
280
+ }
281
+ return;
282
+ }
283
+ if (!issueReferenceLine.test(line))
284
+ return;
285
+ if (referenceLevel > 0) {
286
+ if (sectionLine < 0)
287
+ sectionLine = index;
288
+ }
289
+ else if (otherLine < 0) {
290
+ otherLine = index;
291
+ }
292
+ });
293
+ function replaceLine(index) {
294
+ lines[index] = lines[index].match(issueReferenceLine)[1] + reference;
295
+ }
296
+ if (sectionLine >= 0) {
297
+ replaceLine(sectionLine);
298
+ }
299
+ else if (referenceHeading >= 0) {
300
+ const nextLine = lines[referenceHeading + 1];
301
+ lines.splice(referenceHeading + 1, 0, '', reference, ...(nextLine?.trim() ? [''] : []));
302
+ }
303
+ else if (otherLine >= 0) {
304
+ replaceLine(otherLine);
305
+ }
306
+ else {
307
+ const checks = lines.lastIndexOf(RECORDED_CHECKS_HEADING);
308
+ const section = ['## Reference', '', reference];
309
+ if (checks >= 0)
310
+ lines.splice(checks, 0, ...section, '');
311
+ else
312
+ lines.push('', ...section);
313
+ }
314
+ return lines.join('\n');
315
+ }
316
+ function getConfidenceQualifier(classification, confidence) {
317
+ if (classification === 'auto-fixable' && confidence === 'high') {
318
+ return 'High confidence this addresses the issue correctly.';
319
+ }
320
+ if (classification === 'auto-fixable' && confidence === 'medium') {
321
+ return 'Should address the core issue but may need minor adjustments.';
322
+ }
323
+ if (classification === 'speculative' && confidence === 'high') {
324
+ return 'Starting point for the fix. The approach is likely correct but needs human verification.';
325
+ }
326
+ if (classification === 'speculative' && confidence === 'medium') {
327
+ return 'Starting point for the fix. Review carefully — the approach may need significant adjustments.';
328
+ }
329
+ return '';
330
+ }
331
+ const PR_BODY_TEMPLATE_FILE = 'auto-fix-pr-body-template.md';
332
+ function buildPRBody({ issueNumber, issueUrl, triageAnalysis, fixDescription, fixAssessment, reference, }) {
333
+ const template = loadPromptTemplateFile({
334
+ fileName: resolvePromptFile(import.meta.url, PR_BODY_TEMPLATE_FILE),
335
+ promptLabel: 'auto-fix PR body',
336
+ });
337
+ const logging = fixAssessment?.classification === 'logs-needed';
338
+ let confidenceLine = '';
339
+ if (logging) {
340
+ confidenceLine = `\n> **Investigation confidence: ${fixAssessment.confidence}.** This PR adds diagnostics to test the triage hypotheses. The issue remains open.\n`;
341
+ }
342
+ else if (fixAssessment) {
343
+ const qualifier = getConfidenceQualifier(fixAssessment.classification, fixAssessment.confidence);
344
+ confidenceLine = `\n> **Fix confidence: ${fixAssessment.confidence}** — This is a ${fixAssessment.classification} fix. ${qualifier}\n`;
345
+ }
346
+ return fillPromptTemplate(template, {
347
+ ISSUE_URL: issueUrl,
348
+ FIX_CONFIDENCE: confidenceLine,
349
+ REFERENCE_SECTION: reference ? `\n## Reference\n\n${reference}` : '',
350
+ FIX_DESCRIPTION: fixDescription,
351
+ TRIAGE_ANALYSIS: triageAnalysis,
352
+ ISSUE_NUMBER: issueNumber === undefined ? '' : String(issueNumber),
353
+ CHANGE_SUMMARY: logging ? 'Automated logging changes' : 'Automated fix',
354
+ CHANGE_HEADING: logging ? 'Logging changes' : 'Fix Description',
355
+ VERIFY_TASK: logging
356
+ ? 'Verify the diagnostics answer the investigation questions without changing application behavior'
357
+ : `Verify the fix addresses the issue described in ${issueUrl}`,
358
+ });
359
+ }
360
+ export async function commitAndPushFix(input) {
361
+ const branch = input.branch ?? generateBranchName(input);
362
+ const logging = input.classification === 'logs-needed';
363
+ const titleLine = input.prTitle ?? `${logging ? 'chore: add diagnostics for' : 'fix:'} ${input.issueTitle}`;
364
+ const description = logging
365
+ ? 'Diagnostics generated by Doistbot based on the triage logging plan.'
366
+ : 'Automated fix generated by Doistbot based on triage analysis.';
367
+ const source = input.sentryIssue
368
+ ? (input.sentryIssue.shortId ?? `Sentry issue ${input.sentryIssue.issueId}`)
369
+ : `#${input.issueNumber}`;
370
+ const commitMessage = `${titleLine} (${source})\n\n${description}\nIssue: ${source}`;
371
+ logger.info('Creating fix PR', {
372
+ task: 'issue-triage',
373
+ repository: `${input.repoOwner}/${input.repoName}`,
374
+ issueNumber: input.issueNumber,
375
+ branch,
376
+ });
377
+ const headSha = await commitAndPushBranch({
378
+ repoLocalPath: input.repoLocalPath,
379
+ checkoutArgs: ['checkout', '-b', branch],
380
+ commitMessage,
381
+ branch,
382
+ githubToken: input.githubToken,
383
+ octokit: input.octokit,
384
+ repoOwner: input.repoOwner,
385
+ repoName: input.repoName,
386
+ });
387
+ logger.info('Fix branch pushed, creating draft PR', {
388
+ task: 'issue-triage',
389
+ repository: `${input.repoOwner}/${input.repoName}`,
390
+ branch,
391
+ });
392
+ return { branch, headSha };
393
+ }
394
+ /**
395
+ * Commits + pushes a second fix attempt on the EXISTING iter-0 branch. Unlike
396
+ * commitAndPushFix, this does not create a new branch — callers (the fix loop's
397
+ * iter-1 path) need the existing PR to pick up the new commit, which only
398
+ * works if we push to the same branch the PR was opened from.
399
+ *
400
+ * Assumes the working tree already has the iter-1 changes staged (typically by
401
+ * runFixCore's captureGitDiff `git add -A`) and that the repo is currently on
402
+ * `branch` (the case after iter-0 push + iter-1 `git reset --hard <iter0Sha>`).
403
+ */
404
+ export async function commitAndPushIteration(input) {
405
+ const logging = input.classification === 'logs-needed';
406
+ const titleLine = input.prTitle ?? `${logging ? 'chore: add diagnostics for' : 'fix:'} ${input.issueTitle}`;
407
+ const description = logging
408
+ ? 'Follow-up diagnostics addressing prior review findings.'
409
+ : 'Follow-up fix addressing prior review findings.';
410
+ const source = input.sentryIssue
411
+ ? (input.sentryIssue.shortId ?? `Sentry issue ${input.sentryIssue.issueId}`)
412
+ : `#${input.issueNumber}`;
413
+ const commitMessage = `${titleLine} (iteration) (${source})\n\n${description}\nIssue: ${source}`;
414
+ // Defensive: make sure we are on the iter-0 branch before committing. The
415
+ // iter-1 codex run does `git reset --hard <iter0Sha>` which preserves the
416
+ // current branch, so this should be a no-op, but a checkout makes the
417
+ // invariant explicit.
418
+ const headSha = await commitAndPushBranch({
419
+ repoLocalPath: input.repoLocalPath,
420
+ checkoutArgs: ['checkout', input.branch],
421
+ commitMessage,
422
+ branch: input.branch,
423
+ githubToken: input.githubToken,
424
+ octokit: input.octokit,
425
+ repoOwner: input.repoOwner,
426
+ repoName: input.repoName,
427
+ });
428
+ logger.info('Fix iteration pushed to existing branch', {
429
+ task: 'issue-triage',
430
+ repository: `${input.repoOwner}/${input.repoName}`,
431
+ branch: input.branch,
432
+ issueNumber: input.issueNumber,
433
+ });
434
+ return { headSha };
435
+ }
436
+ /** Opens a draft, then marks it ready if an eligible assignee review was requested. */
437
+ export async function openDraftFixPR(input) {
438
+ const logging = input.fixAssessment?.classification === 'logs-needed';
439
+ const title = input.prTitle || `${logging ? 'chore: add diagnostics for' : 'fix:'} ${input.issueTitle}`;
440
+ const source = input.sentryIssue
441
+ ? (input.sentryIssue.shortId ?? `Sentry issue ${input.sentryIssue.issueId}`)
442
+ : `todoist/Issues#${input.issueNumber}`;
443
+ const prTitle = `${title} (${source})`;
444
+ const shortId = !logging ? input.sentryIssue?.shortId : undefined;
445
+ let reference;
446
+ if (shortId) {
447
+ const issueLink = `[${shortId}](${input.issueUrl})`;
448
+ const titleClosesIssue = new RegExp(`\\bFixes\\s+${escapeRegExp(shortId)}(?![\\w-])`, 'i').test(prTitle);
449
+ reference = titleClosesIssue ? issueLink : `Fixes ${issueLink}`;
450
+ }
451
+ const loggingSummary = logging
452
+ ? `This PR implements the \`logs-needed\` triage plan. It adds diagnostics and keeps the issue open. Related to ${input.issueUrl}.\n\n`
453
+ : '';
454
+ let modelBody = input.prBody ? stripEmptySections(input.prBody) : '';
455
+ if (input.prBody && shortId && reference) {
456
+ modelBody = placeClosingReference({
457
+ body: modelBody,
458
+ issueUrl: input.issueUrl,
459
+ shortId,
460
+ reference,
461
+ });
462
+ }
463
+ const generatedBody = input.prBody
464
+ ? loggingSummary + modelBody
465
+ : buildPRBody({
466
+ issueNumber: input.issueNumber,
467
+ issueUrl: input.issueUrl,
468
+ triageAnalysis: input.triageAnalysis,
469
+ fixDescription: input.fixDescription,
470
+ fixAssessment: input.fixAssessment,
471
+ reference,
472
+ });
473
+ let prBody = generatedBody.includes(input.issueUrl)
474
+ ? generatedBody
475
+ : `${generatedBody}\n\nSource issue: ${input.issueUrl}`;
476
+ if (prBody.length > MAX_FIX_PR_BODY_CHARS) {
477
+ const shortened = "[PR description shortened to fit GitHub's size limit.]";
478
+ let notice = `${shortened}\n\nSource issue: ${input.issueUrl}\n\n`;
479
+ let cutoff = MAX_FIX_PR_BODY_CHARS - notice.length;
480
+ const referenceStart = reference ? prBody.indexOf(reference) : -1;
481
+ if (reference && referenceStart + reference.length > cutoff) {
482
+ notice = `${shortened}\n\n${reference}\n\n`;
483
+ cutoff = Math.min(MAX_FIX_PR_BODY_CHARS - notice.length, referenceStart);
484
+ }
485
+ // Avoid leaving half of a Unicode character at the cutoff.
486
+ const excerpt = prBody.slice(0, cutoff).replace(/[\uD800-\uDBFF]$/, '');
487
+ prBody = notice + excerpt;
488
+ }
489
+ const { data: repo } = await input.octokit.rest.repos.get({
490
+ owner: input.repoOwner,
491
+ repo: input.repoName,
492
+ });
493
+ const { data: pr } = await input.octokit.rest.pulls.create({
494
+ owner: input.repoOwner,
495
+ repo: input.repoName,
496
+ title: prTitle,
497
+ body: prBody,
498
+ head: input.branch,
499
+ base: repo.default_branch,
500
+ draft: true,
501
+ });
502
+ const requestedAssignees = await requestAssigneeReview({
503
+ owner: input.repoOwner,
504
+ repo: input.repoName,
505
+ prNumber: pr.number,
506
+ octokit: input.octokit,
507
+ assigneeReview: input.assigneeReview,
508
+ });
509
+ try {
510
+ const prLabel = input.fixAssessment?.classification === 'speculative'
511
+ ? DOISTBOT_SPECULATIVE_FIX_LABEL
512
+ : DOISTBOT_AUTO_FIX_LABEL;
513
+ await input.octokit.rest.issues.addLabels({
514
+ owner: input.repoOwner,
515
+ repo: input.repoName,
516
+ issue_number: pr.number,
517
+ labels: [prLabel],
518
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
519
+ });
520
+ }
521
+ catch (error) {
522
+ logger.warn('Failed to add label to fix PR (non-fatal)', {
523
+ task: 'issue-triage',
524
+ repository: `${input.repoOwner}/${input.repoName}`,
525
+ prNumber: pr.number,
526
+ error: error instanceof Error ? error.message : String(error),
527
+ });
528
+ }
529
+ if (requestedAssignees.length > 0) {
530
+ // Apply the autofix label before emitting ready_for_review. An accepted
531
+ // assignee request is sufficient, even if writing its receipt failed.
532
+ try {
533
+ await markPullRequestReady({
534
+ octokit: input.octokit,
535
+ owner: input.repoOwner,
536
+ repo: input.repoName,
537
+ prNumber: pr.number,
538
+ });
539
+ logger.info('Assignee-reviewed fix PR marked ready', {
540
+ task: 'issue-triage',
541
+ repository: `${input.repoOwner}/${input.repoName}`,
542
+ prNumber: pr.number,
543
+ });
544
+ }
545
+ catch (error) {
546
+ // The PR and review request already exist. Preserve both and keep
547
+ // the fix loop running rather than reporting PR creation failure.
548
+ logger.warn('Failed to mark assignee-reviewed fix PR ready', {
549
+ task: 'issue-triage',
550
+ repository: `${input.repoOwner}/${input.repoName}`,
551
+ prNumber: pr.number,
552
+ error: error instanceof Error ? error.message : String(error),
553
+ });
554
+ try {
555
+ await withRetry(() => input.octokit.rest.issues.createComment({
556
+ owner: input.repoOwner,
557
+ repo: input.repoName,
558
+ issue_number: pr.number,
559
+ body: "Review was requested from the issue assignees, but I couldn't confirm that this PR was marked ready for review. Please check its status and mark it ready manually if needed.",
560
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
561
+ }));
562
+ }
563
+ catch (commentError) {
564
+ logger.warn('Failed to report fix PR readiness failure', {
565
+ task: 'issue-triage',
566
+ repository: `${input.repoOwner}/${input.repoName}`,
567
+ prNumber: pr.number,
568
+ error: commentError instanceof Error ? commentError.message : String(commentError),
569
+ });
570
+ }
571
+ }
572
+ }
573
+ logger.info('Fix PR created', {
574
+ task: 'issue-triage',
575
+ repository: `${input.repoOwner}/${input.repoName}`,
576
+ prNumber: pr.number,
577
+ prUrl: pr.html_url,
578
+ branch: input.branch,
579
+ });
580
+ return {
581
+ prUrl: pr.html_url,
582
+ prNumber: pr.number,
583
+ ...(requestedAssignees.length > 0 && { requestedAssignees }),
584
+ };
585
+ }
@@ -0,0 +1,25 @@
1
+ import { withRetry } from '../../core/retry.js';
2
+ import { GITHUB_REQUEST_TIMEOUT_MS } from './github-request.js';
3
+ /** Safe both at creation and when the later CI-driven promotion runs. */
4
+ export async function markPullRequestReady({ octokit, owner, repo, prNumber, }) {
5
+ // Re-read on every attempt: a mutation may have succeeded even if its
6
+ // response was lost. Repeating that mutation on a ready PR would fail.
7
+ await withRetry(async () => {
8
+ const { data: pr } = await octokit.rest.pulls.get({
9
+ owner,
10
+ repo,
11
+ pull_number: prNumber,
12
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
13
+ });
14
+ if (!pr.draft)
15
+ return;
16
+ await octokit.graphql(`mutation($pullRequestId: ID!) {
17
+ markPullRequestReadyForReview(input: { pullRequestId: $pullRequestId }) {
18
+ pullRequest { isDraft }
19
+ }
20
+ }`, {
21
+ pullRequestId: pr.node_id,
22
+ request: { signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) },
23
+ });
24
+ });
25
+ }
@@ -0,0 +1,63 @@
1
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
2
+ import { describeWorkflow, loadRepositoryWorkflow } from './repo-prompts.js';
3
+ import { TRIAGE_METADATA_SCHEMA } from './report-schema.js';
4
+ import { describeSandboxEnvironment } from './sandbox-environment.js';
5
+ export function buildReportCompactionPrompt(output) {
6
+ return fillPromptTemplate(loadPromptTemplateFile({
7
+ fileName: resolvePromptFile(import.meta.url, 'report-compaction.md'),
8
+ promptLabel: 'triage report compaction',
9
+ }), { REPORT: output });
10
+ }
11
+ function buildPriorInvestigationPrompt({ latest, pending }) {
12
+ if (!latest)
13
+ return '';
14
+ const template = loadPromptTemplateFile({
15
+ fileName: resolvePromptFile(import.meta.url, 'prior-investigation.md'),
16
+ promptLabel: 'prior investigation',
17
+ });
18
+ const previousReports = {
19
+ latest: { id: latest.report.id, url: latest.commentUrl },
20
+ completedLoggingRounds: latest.report.completedLoggingRounds,
21
+ pending: pending && { id: pending.report.id, url: pending.commentUrl },
22
+ };
23
+ return fillPromptTemplate(template, {
24
+ PREVIOUS_REPORTS: JSON.stringify(previousReports, null, 2),
25
+ });
26
+ }
27
+ export function buildTriagePrompt({ issueUrl, repositories, history, source = 'github', }) {
28
+ const template = loadPromptTemplateFile({
29
+ fileName: resolvePromptFile(import.meta.url, 'issue-triage-prompt.md'),
30
+ promptLabel: 'issue triage',
31
+ });
32
+ const { reinvestigation } = TRIAGE_METADATA_SCHEMA.properties;
33
+ const { none, pending, completed } = reinvestigation.mapping;
34
+ const metadataSchema = {
35
+ ...TRIAGE_METADATA_SCHEMA,
36
+ properties: {
37
+ ...TRIAGE_METADATA_SCHEMA.properties,
38
+ reinvestigation: {
39
+ ...reinvestigation,
40
+ mapping: history.pending ? { pending, completed } : { none },
41
+ },
42
+ },
43
+ };
44
+ return (fillPromptTemplate(template, {
45
+ INVESTIGATION_WORKFLOW: repositories
46
+ .map(({ repository, localPath }) => `### ${repository.fullName}\n\nApply this workflow only to ${repository.fullName}.\n\n${describeWorkflow(loadRepositoryWorkflow(localPath, 'triage'), 'triage-workflow.md')}`)
47
+ .join('\n\n'),
48
+ ISSUE_URL: issueUrl,
49
+ SOURCE_INSTRUCTIONS: source === 'sentry'
50
+ ? 'Use the sentry CLI to read the source issue and relevant events, stack traces, breadcrumbs, release details, and issue history. Read CLI help first if needed. Fetch the evidence; the URL alone is not a diagnosis. If access fails or there is no useful event evidence, report insufficient-context. This source has no GitHub issue or GitHub discussion. The Sentry POC only implements high-confidence bug fixes; logs-needed decisions stop without a fix. Completed reports are temporary and deleted when the task stops.'
51
+ : 'Use `gh` to read the issue report, labels, and its discussion. Fetch all comment pages and read the complete relevant comments, including prior Doistbot reports. The issue URL identifies the source; no shortened copy of the issue or discussion is supplied here.',
52
+ RESOLVED_REPOS: repositories
53
+ .map(({ repository, localPath }) => `- ${repository.fullName}: ${localPath}`)
54
+ .join('\n'),
55
+ PRIOR_INVESTIGATION: buildPriorInvestigationPrompt(history),
56
+ FIX_ASSESSMENT_INSTRUCTIONS: loadPromptTemplateFile({
57
+ fileName: resolvePromptFile(import.meta.url, 'fix-assessment-instructions.md'),
58
+ promptLabel: 'triage decision',
59
+ }),
60
+ PENDING_LOGGING_REPORT_ID: history.pending?.report.id ?? 'none',
61
+ METADATA_SCHEMA: JSON.stringify(metadataSchema, null, 2),
62
+ }) + describeSandboxEnvironment());
63
+ }