@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,31 @@
1
+ import { Redactor } from '@redactpii/node';
2
+ const redactor = new Redactor();
3
+ /**
4
+ * Redact PII (emails, phone numbers, names, credit cards, SSNs) from text.
5
+ * Preserves numeric user IDs, service names, error messages, and code references.
6
+ */
7
+ export function scrubPii(text) {
8
+ if (!text)
9
+ return text;
10
+ return redactor.redact(text);
11
+ }
12
+ /**
13
+ * Deep-scrub PII from a JSON-serializable object.
14
+ * Walks strings in objects/arrays recursively.
15
+ */
16
+ export function scrubPiiFromObject(obj) {
17
+ if (typeof obj === 'string') {
18
+ return scrubPii(obj);
19
+ }
20
+ if (Array.isArray(obj)) {
21
+ return obj.map((item) => scrubPiiFromObject(item));
22
+ }
23
+ if (obj !== null && typeof obj === 'object') {
24
+ const result = {};
25
+ for (const [key, value] of Object.entries(obj)) {
26
+ result[key] = scrubPiiFromObject(value);
27
+ }
28
+ return result;
29
+ }
30
+ return obj;
31
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { logger } from './logging.js';
5
+ const promptCache = new Map();
6
+ const resolveCache = new Map();
7
+ /** Swap the `dist` directory nearest to the file (not just the first `/dist/` in the path) for `src`. */
8
+ function distDirToSrcDir(dir) {
9
+ const distSeg = `${path.sep}dist${path.sep}`;
10
+ const idx = dir.lastIndexOf(distSeg);
11
+ if (idx === -1) {
12
+ return dir;
13
+ }
14
+ return `${dir.slice(0, idx)}${path.sep}src${path.sep}${dir.slice(idx + distSeg.length)}`;
15
+ }
16
+ /**
17
+ * Resolve a prompt file living in `<module>/prompts/`, relative to the calling
18
+ * module. At runtime the module lives under `dist/`, but the prompts are shipped
19
+ * under `src/`, so we map `dist`->`src`. The `dist` candidate is tried first so a
20
+ * future build step that copies prompts into `dist/` works without code changes.
21
+ */
22
+ export function resolvePromptFile(moduleUrl, fileName) {
23
+ const byFileName = resolveCache.get(moduleUrl) ?? new Map();
24
+ resolveCache.set(moduleUrl, byFileName);
25
+ const cached = byFileName.get(fileName);
26
+ if (cached !== undefined) {
27
+ return cached;
28
+ }
29
+ const dir = path.dirname(fileURLToPath(moduleUrl));
30
+ const candidates = [
31
+ path.join(dir, 'prompts', fileName),
32
+ path.join(distDirToSrcDir(dir), 'prompts', fileName),
33
+ ];
34
+ const resolved = candidates.find(fs.existsSync) ?? candidates[candidates.length - 1];
35
+ byFileName.set(fileName, resolved);
36
+ return resolved;
37
+ }
38
+ export function loadPromptTemplateFile({ fileName, promptLabel, }) {
39
+ if (!path.isAbsolute(fileName)) {
40
+ throw new Error(`${promptLabel} prompt template path must be absolute (resolve it with resolvePromptFile first), got: ${fileName}`);
41
+ }
42
+ const cached = promptCache.get(fileName);
43
+ if (cached !== undefined) {
44
+ return cached;
45
+ }
46
+ try {
47
+ const contents = fs.readFileSync(fileName, 'utf8');
48
+ promptCache.set(fileName, contents);
49
+ return contents;
50
+ }
51
+ catch (error) {
52
+ logger.error(`Failed to load ${promptLabel} prompt template`, {
53
+ error: error instanceof Error ? error.message : String(error),
54
+ promptPath: fileName,
55
+ });
56
+ throw error;
57
+ }
58
+ }
59
+ export function fillPromptTemplate(template, variables) {
60
+ return template.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, varName) => {
61
+ return variables[varName] ?? match;
62
+ });
63
+ }
@@ -0,0 +1,36 @@
1
+ import { logger } from './logging.js';
2
+ export async function removeIssueCommentReaction({ octokit, owner, repo, commentId, reactionId, warnMessage, logMeta, }) {
3
+ try {
4
+ await octokit.rest.reactions.deleteForIssueComment({
5
+ owner,
6
+ repo,
7
+ comment_id: commentId,
8
+ reaction_id: reactionId,
9
+ });
10
+ return true;
11
+ }
12
+ catch (error) {
13
+ logger.warn(warnMessage, {
14
+ ...(logMeta ?? {}),
15
+ error: error instanceof Error ? error.message : String(error),
16
+ });
17
+ return false;
18
+ }
19
+ }
20
+ export async function removeIssueCommentReactionIfPresent({ octokit, owner, repo, commentId, reactionId, warnMessage, successMessage, logMeta, }) {
21
+ if (!commentId || !reactionId) {
22
+ return;
23
+ }
24
+ const removed = await removeIssueCommentReaction({
25
+ octokit,
26
+ owner,
27
+ repo,
28
+ commentId,
29
+ reactionId,
30
+ warnMessage,
31
+ logMeta,
32
+ });
33
+ if (removed && successMessage) {
34
+ logger.info(successMessage, logMeta ?? {});
35
+ }
36
+ }
@@ -0,0 +1,3 @@
1
+ export function escapeRegExp(value) {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
@@ -0,0 +1 @@
1
+ export { APP_LABEL_ALIASES, APP_TO_ROUTE_SELECTORS, findIntegrationRoute, findIntegrationRoutes, findIssueRoute, findOwningRoutes, findRepositoryRoutes, findRepositoryTarget, INTEGRATION_LABEL_ALIASES, INTEGRATION_ROUTES, PRODUCT_LABEL_ALIASES, PRODUCT_ROUTES, RESOLUTION_PRECEDENCE, TEAM_LABEL_ALIASES, TEAM_TO_ROUTE_SELECTORS, } from 'doistbot-issue-routing';
@@ -0,0 +1 @@
1
+ export { isKnownIssueRoutingLabel, resolveIssueRepositories } from 'doistbot-issue-routing';
@@ -0,0 +1,29 @@
1
+ /** Default per-attempt delays for retrying transient HTTP failures. */
2
+ export const HTTP_RETRY_DELAYS_MS = [250, 500, 1000];
3
+ /** True for HTTP errors that are typically worth retrying: 429 and any 5xx. */
4
+ function isTransientHttpError(error) {
5
+ const status = error?.status;
6
+ return typeof status === 'number' && (status === 429 || (status >= 500 && status < 600));
7
+ }
8
+ /**
9
+ * Retries `fn` on errors classified as retryable, sleeping between attempts.
10
+ * Total attempts = 1 + delaysMs.length. Throws immediately on a non-retryable
11
+ * error, or after the budget is exhausted (re-throwing the last error).
12
+ *
13
+ * Defaults match HTTP usage (429 / 5xx with exponential backoff). Pass
14
+ * `delaysMs` / `isRetryable` to retry against a different signal.
15
+ */
16
+ export async function withRetry(fn, delaysMs = HTTP_RETRY_DELAYS_MS, isRetryable = isTransientHttpError) {
17
+ let attempt = 0;
18
+ while (true) {
19
+ try {
20
+ return await fn();
21
+ }
22
+ catch (error) {
23
+ if (!isRetryable(error) || attempt >= delaysMs.length)
24
+ throw error;
25
+ await new Promise((resolve) => setTimeout(resolve, delaysMs[attempt]));
26
+ attempt++;
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,42 @@
1
+ import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
2
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
3
+ import { PRESIGN_EXPIRY_SECONDS } from './snapshot-helpers.js';
4
+ /**
5
+ * Upload files to S3 and generate presigned download URLs.
6
+ * Skips entries with empty body. Returns the S3 keys and presigned URLs
7
+ * keyed by each file's `name`.
8
+ */
9
+ export async function uploadAndPresign({ bucket, files, expiresIn = PRESIGN_EXPIRY_SECONDS, }) {
10
+ const seen = new Set();
11
+ for (const file of files) {
12
+ if (seen.has(file.name)) {
13
+ throw new Error(`Duplicate file name: "${file.name}"`);
14
+ }
15
+ seen.add(file.name);
16
+ }
17
+ const s3 = new S3Client({});
18
+ const s3Keys = {};
19
+ const presignedUrls = {};
20
+ for (const file of files) {
21
+ s3Keys[file.name] = '';
22
+ presignedUrls[file.name] = '';
23
+ }
24
+ for (const file of files) {
25
+ if (!file.body)
26
+ continue;
27
+ await s3.send(new PutObjectCommand({
28
+ Bucket: bucket,
29
+ Key: file.key,
30
+ Body: file.body,
31
+ ContentType: file.contentType,
32
+ }));
33
+ s3Keys[file.name] = file.key;
34
+ presignedUrls[file.name] = await getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: file.key }), { expiresIn });
35
+ }
36
+ const prefix = files[0]?.key.split('/').slice(0, -1).join('/');
37
+ return {
38
+ s3Path: `s3://${bucket}/${prefix ? prefix + '/' : ''}`,
39
+ s3Keys,
40
+ presignedUrls,
41
+ };
42
+ }
@@ -0,0 +1,124 @@
1
+ import { spawnSync } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { App, Octokit } from 'octokit';
5
+ import { WORKSPACE_DIR } from './environment.js';
6
+ import { logger } from './logging.js';
7
+ export function resolveWriteOctokit({ githubToken, fallbackOctokit, }) {
8
+ if (!githubToken) {
9
+ return fallbackOctokit;
10
+ }
11
+ return new Octokit({ auth: githubToken });
12
+ }
13
+ /**
14
+ * Creates the GitHub clients used by sandbox tasks.
15
+ * `readOctokit` uses the GitHub App installation.
16
+ * `writeOctokit` uses the doistbot PAT when configured, otherwise falls back to `readOctokit`.
17
+ */
18
+ export async function createGitHubClients(ctx) {
19
+ const app = new App({
20
+ appId: ctx.appId,
21
+ privateKey: ctx.privateKey,
22
+ });
23
+ const readOctokit = await app.getInstallationOctokit(ctx.installationId);
24
+ const writeOctokit = resolveWriteOctokit({
25
+ githubToken: ctx.githubToken,
26
+ fallbackOctokit: readOctokit,
27
+ });
28
+ return { app, readOctokit, writeOctokit };
29
+ }
30
+ /**
31
+ * Clones a repository to the workspace directory using GitHub App credentials.
32
+ *
33
+ * Always fetches via `pull/<num>/head` — works uniformly for open and
34
+ * closed/merged PRs (GitHub preserves the ref forever) and skips downloading
35
+ * the default branch, which we never need for review purposes.
36
+ */
37
+ export async function cloneRepository({ app, owner, repo, prNumber, installationId, depth = 50, }) {
38
+ await cloneToPath({
39
+ app,
40
+ owner,
41
+ repo,
42
+ installationId,
43
+ targetPath: WORKSPACE_DIR,
44
+ prNumber,
45
+ depth,
46
+ });
47
+ }
48
+ async function prepareGitClone(params) {
49
+ const { app, owner, repo, installationId, targetPath } = params;
50
+ const token = await app.octokit.auth({ type: 'installation', installationId });
51
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
52
+ const credentials = Buffer.from(`x-access-token:${token.token}`).toString('base64');
53
+ const authHeader = `http.extraheader=Authorization: Basic ${credentials}`;
54
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
55
+ return { repoUrl, authHeader };
56
+ }
57
+ /**
58
+ * Initializes a repo at `targetPath` and fetches just the PR's head ref.
59
+ * Shared utility for any task that needs the working tree at a PR's
60
+ * head SHA without the default-branch overhead.
61
+ */
62
+ export async function cloneToPath({ app, owner, repo, installationId, targetPath, prNumber, depth = 1, }) {
63
+ const { repoUrl, authHeader } = await prepareGitClone({
64
+ app,
65
+ owner,
66
+ repo,
67
+ installationId,
68
+ targetPath,
69
+ });
70
+ const steps = [
71
+ { args: ['init', targetPath], label: 'init' },
72
+ { args: ['-C', targetPath, 'remote', 'add', 'origin', repoUrl], label: 'remote-add' },
73
+ {
74
+ args: [
75
+ '-C',
76
+ targetPath,
77
+ '-c',
78
+ authHeader,
79
+ 'fetch',
80
+ '--depth',
81
+ String(depth),
82
+ 'origin',
83
+ `pull/${prNumber}/head:pr-${prNumber}`,
84
+ ],
85
+ label: 'fetch',
86
+ },
87
+ { args: ['-C', targetPath, 'checkout', `pr-${prNumber}`], label: 'checkout' },
88
+ ];
89
+ for (const step of steps) {
90
+ const stepResult = spawnSync('git', step.args, { encoding: 'utf8', stdio: 'pipe' });
91
+ if (stepResult.error || stepResult.status !== 0) {
92
+ const detail = cloneFailureDetail(stepResult);
93
+ throw new Error(`Failed to clone ${owner}/${repo} (${step.label}): ${detail}`);
94
+ }
95
+ }
96
+ logger.info('Repository cloned via PR ref', { owner, repo, prNumber, targetPath });
97
+ }
98
+ function cloneFailureDetail(result) {
99
+ if (result.error) {
100
+ return result.error.message;
101
+ }
102
+ const stderr = typeof result.stderr === 'string' ? result.stderr : '';
103
+ return stderr.trim().slice(0, 500) || `git exited with status ${result.status}`;
104
+ }
105
+ /**
106
+ * Clones a repository's default branch to `targetPath`. Used by tasks that
107
+ * need code context not tied to a specific PR (e.g. persist-logs investigates
108
+ * issues against the latest main).
109
+ */
110
+ export async function cloneDefaultBranchToPath({ app, owner, repo, installationId, targetPath, depth = 1, }) {
111
+ const { repoUrl, authHeader } = await prepareGitClone({
112
+ app,
113
+ owner,
114
+ repo,
115
+ installationId,
116
+ targetPath,
117
+ });
118
+ const result = spawnSync('git', ['-c', authHeader, 'clone', '--depth', String(depth), repoUrl, targetPath], { encoding: 'utf8', stdio: 'pipe' });
119
+ if (result.error || result.status !== 0) {
120
+ const detail = cloneFailureDetail(result);
121
+ throw new Error(`Failed to clone ${owner}/${repo}: ${detail}`);
122
+ }
123
+ logger.info('Repository cloned (default branch)', { owner, repo, targetPath });
124
+ }
@@ -0,0 +1,17 @@
1
+ export const LOG_SNAPSHOT_MARKER = '<!--doistbot-log-snapshot-->';
2
+ export const PRESIGN_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 days (SigV4 max)
3
+ /**
4
+ * Parses S3 bucket and key list from the hidden metadata comment.
5
+ * Format: `<!-- s3-keys:bucket:key1,key2,key3,... -->`
6
+ */
7
+ export function parseS3MetadataFromComment(commentBody) {
8
+ const match = commentBody.match(/<!-- s3-keys:([^:]+):([^\s]+) -->/);
9
+ const bucket = match?.[1];
10
+ const keysRaw = match?.[2];
11
+ if (!bucket || !keysRaw)
12
+ return null;
13
+ const keys = keysRaw.split(',').filter(Boolean);
14
+ if (keys.length === 0)
15
+ return null;
16
+ return { bucket, keys };
17
+ }
@@ -0,0 +1,13 @@
1
+ import { vi } from 'vitest';
2
+ /**
3
+ * Shared assertion helper for the model/phase span tests, which all spy on
4
+ * `logger.info` and assert on the same `phase.*` envelope shape emitted by the
5
+ * LoggerSpanProcessor.
6
+ *
7
+ * Returns the `phase.end` envelopes for the given span name, in call order.
8
+ */
9
+ export function phaseEnds(infoSpy, name) {
10
+ return infoSpy.mock.calls
11
+ .map((call) => call[1])
12
+ .filter((meta) => meta?.event === 'phase.end' && meta?.name === name);
13
+ }
@@ -0,0 +1,83 @@
1
+ /** Prepare credentials for Pi shell tools. */
2
+ export function buildToolEnvironment(extraEnv = {}, inherited = process.env) {
3
+ const env = { ...inherited };
4
+ delete env.GH_PACKAGES_TOKEN;
5
+ Object.assign(env, extraEnv);
6
+ delete env.GITHUB_TOKEN;
7
+ delete env.PRIVATE_KEY;
8
+ delete env.GIT_SSH_SIGNING_KEY;
9
+ // Tracing runs in the sandbox process; agent tools never need the keys.
10
+ delete env.LANGFUSE_PUBLIC_KEY;
11
+ delete env.LANGFUSE_SECRET_KEY;
12
+ env.GIT_TERMINAL_PROMPT = '0';
13
+ if (env.GH_TOKEN) {
14
+ const count = Number(env.GIT_CONFIG_COUNT ?? 0);
15
+ env.GIT_CONFIG_COUNT = String(count + 2);
16
+ env[`GIT_CONFIG_KEY_${count}`] = 'credential.https://github.com.helper';
17
+ env[`GIT_CONFIG_VALUE_${count}`] = '';
18
+ env[`GIT_CONFIG_KEY_${count + 1}`] = 'credential.https://github.com.helper';
19
+ env[`GIT_CONFIG_VALUE_${count + 1}`] = '!gh auth git-credential';
20
+ }
21
+ env.DD_API_KEY = env.DD_API_KEY ?? env.DATADOG_API_KEY;
22
+ env.DD_APP_KEY = env.DD_APP_KEY ?? env.DATADOG_APP_KEY;
23
+ env.DD_SITE = env.DD_SITE?.trim() || env.DATADOG_SITE?.trim() || 'datadoghq.com';
24
+ return env;
25
+ }
26
+ export function approvedSkillArgs(env = process.env) {
27
+ if (env.SANDBOX_PROFILE !== 'android')
28
+ return [];
29
+ return ['--skill', '/app/apps/sandbox/skills/agent-device/SKILL.md'];
30
+ }
31
+ export function buildVerificationEnvironment(inherited = process.env) {
32
+ const env = {};
33
+ const names = [
34
+ 'PATH',
35
+ 'HOME',
36
+ 'USER',
37
+ 'LOGNAME',
38
+ 'SHELL',
39
+ 'TMPDIR',
40
+ 'TMP',
41
+ 'TEMP',
42
+ 'LANG',
43
+ 'TZ',
44
+ 'CI',
45
+ 'TERM',
46
+ 'JAVA_HOME',
47
+ 'JDK_HOME',
48
+ 'ANDROID_HOME',
49
+ 'ANDROID_SDK_ROOT',
50
+ 'ANDROID_NDK_HOME',
51
+ 'GRADLE_USER_HOME',
52
+ 'GRADLE_OPTS',
53
+ 'JAVA_OPTS',
54
+ 'NODE_OPTIONS',
55
+ 'NPM_CONFIG_CACHE',
56
+ 'npm_config_cache',
57
+ 'PNPM_HOME',
58
+ 'PYTHONPATH',
59
+ 'PYTHONHOME',
60
+ 'VIRTUAL_ENV',
61
+ 'UV_CACHE_DIR',
62
+ 'PIP_CACHE_DIR',
63
+ 'XDG_CACHE_HOME',
64
+ 'XDG_CONFIG_HOME',
65
+ ];
66
+ for (const [name, value] of Object.entries(inherited)) {
67
+ if (names.includes(name) || name.startsWith('LC_'))
68
+ env[name] = value;
69
+ }
70
+ // Private submodules use the scoped installation token and a fixed Git helper.
71
+ env.GH_TOKEN = inherited.GH_TOKEN;
72
+ env.GH_PACKAGES_TOKEN = inherited.GH_PACKAGES_TOKEN;
73
+ env.GH_HOST = 'github.com';
74
+ env.GIT_TERMINAL_PROMPT = '0';
75
+ if (env.GH_TOKEN) {
76
+ env.GIT_CONFIG_COUNT = '2';
77
+ env.GIT_CONFIG_KEY_0 = 'credential.https://github.com.helper';
78
+ env.GIT_CONFIG_VALUE_0 = '';
79
+ env.GIT_CONFIG_KEY_1 = 'credential.https://github.com.helper';
80
+ env.GIT_CONFIG_VALUE_1 = '!gh auth git-credential';
81
+ }
82
+ return env;
83
+ }
@@ -0,0 +1,18 @@
1
+ import { writeSync } from 'node:fs';
2
+ import { scrubPii } from './pii-scrubber.js';
3
+ export function safeToolOutput(text) {
4
+ for (const [name, secret] of Object.entries(process.env)) {
5
+ if (/(?:KEY|TOKEN|PASSWORD|SECRET)$/.test(name) && secret && secret.length >= 8) {
6
+ text = text.split(secret).join('[REDACTED_SECRET]');
7
+ }
8
+ }
9
+ return scrubPii(text);
10
+ }
11
+ export function writeToolRecord(record) {
12
+ // Pi redirects process.stdout in JSON mode. Keep protocol records on stdout.
13
+ writeSync(1, `${JSON.stringify(record)}\n`);
14
+ }
15
+ export function boundedToolOutput(text, limit = 24_000) {
16
+ const marker = '\n[Output truncated]';
17
+ return text.length <= limit ? text : text.slice(0, limit - marker.length) + marker;
18
+ }
@@ -0,0 +1,92 @@
1
+ import { isDefaultExportSpan, LangfuseSpanProcessor } from '@langfuse/otel';
2
+ import { context, SpanStatusCode, trace } from '@opentelemetry/api';
3
+ import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
4
+ import { BasicTracerProvider, } from '@opentelemetry/sdk-trace-base';
5
+ import { LoggerSpanProcessor } from './logger-span-processor.js';
6
+ import { SANDBOX_TRACER_NAME } from './tracing.js';
7
+ // Upper bound on the exit flush, well inside ECS's 30s stop timeout.
8
+ const EXIT_FLUSH_TIMEOUT_MS = 5_000;
9
+ let provider;
10
+ let shutdownPromise;
11
+ // A flush exports ended spans only. Tracking the open ones lets an abnormal
12
+ // exit end them first, so the span a run was stuck in is not lost.
13
+ const openSpans = new Set();
14
+ const openSpanTracker = {
15
+ onStart(span) {
16
+ openSpans.add(span);
17
+ },
18
+ onEnd(span) {
19
+ openSpans.delete(span);
20
+ },
21
+ async forceFlush() { },
22
+ async shutdown() { },
23
+ };
24
+ // The base URL is required too: without it the SDK falls back to Langfuse cloud.
25
+ export function isLangfuseTracingEnabled(env = process.env) {
26
+ return Boolean(env.LANGFUSE_PUBLIC_KEY?.trim() &&
27
+ env.LANGFUSE_SECRET_KEY?.trim() &&
28
+ env.LANGFUSE_BASE_URL?.trim());
29
+ }
30
+ // The SDK's default filter keeps only gen_ai spans. It would drop workflow
31
+ // phases such as review.multi-focus and leave the model spans under them
32
+ // pointing at a parent Langfuse never receives.
33
+ export function shouldExportToLangfuse({ otelSpan }) {
34
+ return (otelSpan.instrumentationScope.name === SANDBOX_TRACER_NAME || isDefaultExportSpan(otelSpan));
35
+ }
36
+ export function setupTracing() {
37
+ if (provider)
38
+ return;
39
+ const spanProcessors = [openSpanTracker, new LoggerSpanProcessor()];
40
+ const langfuseEnabled = isLangfuseTracingEnabled();
41
+ if (langfuseEnabled) {
42
+ spanProcessors.push(new LangfuseSpanProcessor({ shouldExportSpan: shouldExportToLangfuse }));
43
+ }
44
+ context.setGlobalContextManager(new AsyncLocalStorageContextManager());
45
+ provider = new BasicTracerProvider({ spanProcessors });
46
+ trace.setGlobalTracerProvider(provider);
47
+ // Covers the natural-exit paths (tasks that return or set process.exitCode).
48
+ // Tasks that call process.exit() directly must await shutdownTracing() first,
49
+ // or buffered Langfuse spans are silently dropped.
50
+ process.once('beforeExit', () => {
51
+ shutdownTracing().catch(() => undefined);
52
+ });
53
+ if (langfuseEnabled)
54
+ flushTracingOnAbnormalExit();
55
+ }
56
+ // Crashes and ECS stops (SIGTERM) skip `beforeExit`, so failed and timed-out
57
+ // runs would lose their traces. These listeners end the spans still in flight
58
+ // with an error, flush, and exit. They replace Node's default handling, so
59
+ // they report the error and exit with the codes Node would use.
60
+ export function flushTracingOnAbnormalExit(target = process) {
61
+ function exitAfterFlush(code, reason) {
62
+ setTimeout(() => target.exit(code), EXIT_FLUSH_TIMEOUT_MS).unref();
63
+ for (const span of [...openSpans]) {
64
+ span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
65
+ span.end();
66
+ }
67
+ // shutdownTracing() never rejects.
68
+ shutdownTracing().then(() => target.exit(code));
69
+ }
70
+ function reportAndExit(error) {
71
+ const detail = error instanceof Error ? (error.stack ?? error.message) : String(error);
72
+ target.stderr.write(`${detail}\n`);
73
+ exitAfterFlush(1, 'Process crashed before the span ended');
74
+ }
75
+ target.once('uncaughtException', reportAndExit);
76
+ target.once('unhandledRejection', reportAndExit);
77
+ target.once('SIGTERM', () => exitAfterFlush(143, 'Task stopped before the span ended'));
78
+ }
79
+ // Flushes and shuts down span exporters. Idempotent and never throws — safe to
80
+ // await right before process.exit().
81
+ export function shutdownTracing() {
82
+ const active = provider;
83
+ if (!active)
84
+ return Promise.resolve();
85
+ // Shut down even when the flush fails, so exporters never stay registered.
86
+ shutdownPromise ??= active
87
+ .forceFlush()
88
+ .catch(() => undefined)
89
+ .then(() => active.shutdown())
90
+ .catch(() => undefined);
91
+ return shutdownPromise;
92
+ }
@@ -0,0 +1,81 @@
1
+ import { context, SpanStatusCode, trace } from '@opentelemetry/api';
2
+ // recordDecision/recordEvent emit directly (bypassing the processor), so they
3
+ // apply the same per-attribute cap themselves to keep large arrays/objects
4
+ // from blowing the log-size budget.
5
+ import { truncateAttrs } from './logger-span-processor.js';
6
+ import { logger } from './logging.js';
7
+ export const SANDBOX_TRACER_NAME = 'doistbot-sandbox';
8
+ const tracer = trace.getTracer(SANDBOX_TRACER_NAME);
9
+ /**
10
+ * THE phase-boundary API. Works for sequential AND parallel phases — OTel's
11
+ * async-hooks context manager (registered by setupTracing) parents nested
12
+ * spans correctly even across Promise.all.
13
+ *
14
+ * On throw: records the exception, sets span status ERROR, re-throws.
15
+ * On normal return: status is left UNSET (OTel's recommended success default;
16
+ * the LoggerSpanProcessor maps UNSET → 'ok'). We deliberately do NOT force
17
+ * OK, so a callback that returns a failure result (instead of throwing) and
18
+ * sets ERROR itself keeps its ERROR status.
19
+ * Always ends the span.
20
+ */
21
+ export async function withPhase(name, attributes, fn) {
22
+ return tracer.startActiveSpan(name, { attributes: { ...attributes, 'doistbot.kind': 'phase' } }, async (span) => {
23
+ try {
24
+ return await fn(span);
25
+ }
26
+ catch (err) {
27
+ span.recordException(err);
28
+ span.setStatus({
29
+ code: SpanStatusCode.ERROR,
30
+ message: err instanceof Error ? err.message : String(err),
31
+ });
32
+ throw err;
33
+ }
34
+ finally {
35
+ span.end();
36
+ }
37
+ });
38
+ }
39
+ /**
40
+ * Start a tool span manually. The ONE exception to "withPhase everywhere":
41
+ * pi emits tool start/end across separate async callbacks (onProgress), so
42
+ * there is no single scope to wrap. Parent = the currently-active span (the
43
+ * model phase). Caller MUST call `span.end()` when the matching tool_end
44
+ * arrives (and may `setStatus({ code: ERROR })` first on failure).
45
+ */
46
+ export function startToolSpan(name, attributes) {
47
+ return tracer.startSpan(name, { attributes: { ...attributes, 'doistbot.kind': 'tool' } }, context.active());
48
+ }
49
+ // Point-in-time lines bypass OTel spans entirely; they read the active span
50
+ // id only for correlation. Meta is spread FIRST so callers can never clobber
51
+ // the envelope fields (event/name/span_id). Timestamps come from the logging
52
+ // system, never from meta.
53
+ function emitPointInTime(kind, name, meta) {
54
+ const span = trace.getActiveSpan();
55
+ logger.info(`${kind}: ${name}`, {
56
+ ...truncateAttrs(meta),
57
+ event: kind,
58
+ name,
59
+ span_id: span?.spanContext().spanId ?? null,
60
+ });
61
+ }
62
+ /**
63
+ * Record a point-in-time decision (branch taken, scope resolved, gate passed)
64
+ * correlated to the active span. Not a span — no duration.
65
+ */
66
+ export function recordDecision(name, meta) {
67
+ emitPointInTime('decision', name, meta);
68
+ }
69
+ /** Generic point-in-time annotation. Same mechanics as recordDecision. */
70
+ export function recordEvent(name, meta) {
71
+ emitPointInTime('event', name, meta);
72
+ }
73
+ /**
74
+ * Maps a doistbot provider key to the OTel `gen_ai.provider.name` value
75
+ * (docs/sandbox-instrumentation.md). Only `gemini` has a standard name
76
+ * (`gcp.gemini`); the OpenRouter-routed keys (`grok`, `zai`, `deepseek`) have none, so
77
+ * they pass through. Single source so every span site stays consistent.
78
+ */
79
+ export function genAiProviderName(provider) {
80
+ return provider === 'gemini' ? 'gcp.gemini' : provider;
81
+ }