@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,113 @@
1
+ import { createDatadogMetrics, resolveDatadogApiKey, resolveStandardObservabilityTags, } from 'doistbot-datadog-metrics';
2
+ import { logger } from './logging.js';
3
+ const TASK_COMPLETION_METRIC = 'doistbot.task.completion';
4
+ const TASK_DURATION_METRIC = 'doistbot.task.duration_seconds';
5
+ let hasWarnedAboutMissingDatadogApiKey = false;
6
+ const { postDatadogMetric } = createDatadogMetrics({ logger });
7
+ function toTaskMetricBaseModel(model) {
8
+ switch (model) {
9
+ case 'deepseek':
10
+ return 'deepseek';
11
+ case 'gemini':
12
+ case 'google':
13
+ return 'google';
14
+ case 'grok':
15
+ return 'grok';
16
+ case 'zai':
17
+ return 'zai';
18
+ case 'openai':
19
+ return 'openai';
20
+ case 'none':
21
+ return 'none';
22
+ }
23
+ }
24
+ function toTaskMetricModel(model) {
25
+ const models = Array.isArray(model) ? model : [model];
26
+ return Array.from(new Set(models.map((entry) => toTaskMetricBaseModel(entry))))
27
+ .sort()
28
+ .join('-');
29
+ }
30
+ function normalizeMetricRepository(repository) {
31
+ const normalized = repository.trim().toLowerCase();
32
+ return normalized || 'unknown';
33
+ }
34
+ function buildTaskMetricTags({ type, repository, model, outcome, reason, sandboxVariant, }) {
35
+ const tags = [
36
+ `type:${type}`,
37
+ `repo:${normalizeMetricRepository(repository)}`,
38
+ `model:${model}`,
39
+ `status:${outcome}`,
40
+ ];
41
+ if (sandboxVariant) {
42
+ tags.push(`sandbox_variant:${sandboxVariant}`);
43
+ }
44
+ if (reason) {
45
+ tags.push(`reason:${reason}`);
46
+ }
47
+ for (const standardTag of resolveStandardObservabilityTags()) {
48
+ tags.push(standardTag);
49
+ }
50
+ return tags;
51
+ }
52
+ export async function recordTaskMetrics({ type, repository, model, outcome, durationMs, reason, sandboxVariant, }) {
53
+ const apiKey = resolveDatadogApiKey();
54
+ if (!apiKey) {
55
+ if (!hasWarnedAboutMissingDatadogApiKey) {
56
+ hasWarnedAboutMissingDatadogApiKey = true;
57
+ logger.warn('Skipping Datadog task metric: DATADOG_API_KEY is missing', {
58
+ type,
59
+ repository: normalizeMetricRepository(repository),
60
+ model: toTaskMetricModel(model),
61
+ outcome,
62
+ ...(sandboxVariant ? { sandboxVariant } : {}),
63
+ ...(reason ? { reason } : {}),
64
+ });
65
+ }
66
+ return;
67
+ }
68
+ const timestamp = Math.floor(Date.now() / 1000);
69
+ const durationSeconds = Math.max(durationMs, 0) / 1000;
70
+ const metricModel = toTaskMetricModel(model);
71
+ const tags = buildTaskMetricTags({
72
+ type,
73
+ repository,
74
+ model: metricModel,
75
+ outcome,
76
+ reason,
77
+ sandboxVariant,
78
+ });
79
+ await Promise.allSettled([
80
+ postDatadogMetric({
81
+ apiKey,
82
+ path: '/api/v2/series',
83
+ body: {
84
+ series: [
85
+ {
86
+ metric: TASK_COMPLETION_METRIC,
87
+ type: 1,
88
+ points: [
89
+ {
90
+ timestamp,
91
+ value: 1,
92
+ },
93
+ ],
94
+ tags,
95
+ },
96
+ ],
97
+ },
98
+ }),
99
+ postDatadogMetric({
100
+ apiKey,
101
+ path: '/api/v1/distribution_points',
102
+ body: {
103
+ series: [
104
+ {
105
+ metric: TASK_DURATION_METRIC,
106
+ points: [[timestamp, [durationSeconds]]],
107
+ tags,
108
+ },
109
+ ],
110
+ },
111
+ }),
112
+ ]);
113
+ }
@@ -0,0 +1,49 @@
1
+ export const WORKSPACE_DIR = process.env.WORKSPACE_DIR || '/workspace';
2
+ export const REPOS_SUBDIR = 'repos';
3
+ /**
4
+ * Parses a required environment variable, throwing if missing.
5
+ */
6
+ export function requiredEnv(name, env = process.env) {
7
+ const value = env[name];
8
+ if (!value) {
9
+ throw new Error(`Missing required environment variable: ${name}`);
10
+ }
11
+ return value;
12
+ }
13
+ /**
14
+ * Parses a required environment variable as a positive integer.
15
+ */
16
+ export function requiredPositiveInt(name, env = process.env) {
17
+ const value = requiredEnv(name, env);
18
+ const parsed = Number(value);
19
+ if (!Number.isInteger(parsed) || parsed <= 0) {
20
+ throw new Error(`Invalid ${name}: expected a positive integer, got "${value}"`);
21
+ }
22
+ return parsed;
23
+ }
24
+ /**
25
+ * Parses an optional string as a positive integer, returning undefined if missing or invalid.
26
+ */
27
+ export function optionalPositiveInt(value) {
28
+ if (!value)
29
+ return undefined;
30
+ const parsed = Number(value);
31
+ if (!Number.isInteger(parsed) || parsed <= 0)
32
+ return undefined;
33
+ return parsed;
34
+ }
35
+ /**
36
+ * Parses the base environment variables common to all sandbox tasks.
37
+ */
38
+ export function parseBaseContext(env = process.env) {
39
+ return {
40
+ owner: requiredEnv('REPO_OWNER', env),
41
+ repo: requiredEnv('REPO_NAME', env),
42
+ prNumber: requiredPositiveInt('PR_NUMBER', env),
43
+ installationId: requiredPositiveInt('INSTALLATION_ID', env),
44
+ appId: requiredEnv('APP_ID', env),
45
+ privateKey: requiredEnv('PRIVATE_KEY', env),
46
+ githubToken: env.GITHUB_TOKEN?.trim() || undefined,
47
+ deliveryId: env.DELIVERY_ID?.trim() || undefined,
48
+ };
49
+ }
@@ -0,0 +1,17 @@
1
+ export const GITHUB_HTTPS_REWRITE_ARGS = [
2
+ '-c',
3
+ 'url.https://github.com/.insteadOf=git@github.com:',
4
+ '-c',
5
+ 'url.https://github.com/.insteadOf=ssh://git@github.com/',
6
+ ];
7
+ export function gitAuthEnv(token) {
8
+ const index = Number(process.env.GIT_CONFIG_COUNT ?? 0);
9
+ const env = { ...process.env };
10
+ delete env.GIT_SSH_SIGNING_KEY;
11
+ return {
12
+ ...env,
13
+ GIT_CONFIG_COUNT: String(index + 1),
14
+ [`GIT_CONFIG_KEY_${index}`]: 'http.https://github.com/.extraheader',
15
+ [`GIT_CONFIG_VALUE_${index}`]: `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
16
+ };
17
+ }
@@ -0,0 +1,4 @@
1
+ /** Preserve pre-rename report/checkpoint identities. Never use this for API authorization. */
2
+ export function githubHistoryIdentity(value) {
3
+ return value.replace(/^(https:\/\/github\.com\/)?todoist\//i, '$1Doist/');
4
+ }
@@ -0,0 +1,18 @@
1
+ export async function fetchIssue(octokit, { owner, repo, issueNumber, mediaType }) {
2
+ const response = await octokit.rest.issues.get({
3
+ owner,
4
+ repo,
5
+ issue_number: issueNumber,
6
+ ...(mediaType && { mediaType }),
7
+ });
8
+ return response.data;
9
+ }
10
+ export async function fetchIssueComments(octokit, { owner, repo, issueNumber, mediaType }) {
11
+ return (await octokit.paginate(octokit.rest.issues.listComments, {
12
+ owner,
13
+ repo,
14
+ issue_number: issueNumber,
15
+ per_page: 100,
16
+ ...(mediaType && { mediaType }),
17
+ }));
18
+ }
@@ -0,0 +1 @@
1
+ export { extractScopedLabelValues, normalizeScopedLabelValue, scopedLabelValue, } from 'doistbot-issue-routing';
@@ -0,0 +1,33 @@
1
+ import { context, SpanStatusCode, trace } from '@opentelemetry/api';
2
+ import { SANDBOX_TRACER_NAME } from './tracing.js';
3
+ import { isLangfuseTracingEnabled } from './tracing-setup.js';
4
+ // Cap prompt/completion payloads well under exporter attribute limits. Review
5
+ // prompts embed full diffs and can reach megabytes.
6
+ const LANGFUSE_IO_MAX_CHARS = 200_000;
7
+ // Opens a Langfuse generation for one model call, as a child of the active
8
+ // span. Each call gets its own span, so a call outside a model phase (e.g.
9
+ // conversation summarization under the task root) or a retry inside one never
10
+ // overwrites another span. The span is not made active, so tool spans keep
11
+ // their phase parent, and the LoggerSpanProcessor skips it, so Datadog log
12
+ // lines keep their shape. Returns undefined when Langfuse is disabled.
13
+ export function startGeneration(name, input) {
14
+ if (!isLangfuseTracingEnabled())
15
+ return undefined;
16
+ return trace.getTracer(SANDBOX_TRACER_NAME).startSpan(name, {
17
+ attributes: {
18
+ 'doistbot.kind': 'generation',
19
+ 'langfuse.observation.type': 'generation',
20
+ 'langfuse.observation.input': input.slice(0, LANGFUSE_IO_MAX_CHARS),
21
+ },
22
+ }, context.active());
23
+ }
24
+ export function endGeneration(span, outcome) {
25
+ if (!span)
26
+ return;
27
+ const output = 'output' in outcome ? outcome.output : outcome.error;
28
+ span.setAttribute('langfuse.observation.output', output.slice(0, LANGFUSE_IO_MAX_CHARS));
29
+ // The error text is already the output; keep the status message out of it.
30
+ if ('error' in outcome)
31
+ span.setStatus({ code: SpanStatusCode.ERROR });
32
+ span.end();
33
+ }
@@ -0,0 +1,39 @@
1
+ import { propagateAttributes } from '@langfuse/tracing';
2
+ import { context, trace, TraceFlags } from '@opentelemetry/api';
3
+ import { deriveLangfuseTraceId } from 'doistbot-shared-contracts';
4
+ import { isLangfuseTracingEnabled } from './tracing-setup.js';
5
+ export function buildSessionId(env = process.env) {
6
+ const number = env.PR_NUMBER ?? env.ISSUE_NUMBER;
7
+ if (!env.REPO_OWNER || !env.REPO_NAME || !number)
8
+ return undefined;
9
+ return `${env.REPO_OWNER}/${env.REPO_NAME}#${number}`;
10
+ }
11
+ // Wraps a whole task run so every span inherits the Langfuse sessionId/userId,
12
+ // rooted under a deterministic trace id derived from DELIVERY_ID — the same id
13
+ // the webhook-handler re-derives to attach feedback scores. No-op when Langfuse
14
+ // is disabled, so log/visualizer output is unchanged (their root-span detection
15
+ // relies on parent_span_id being null).
16
+ export async function withLangfuseTaskContext(fn) {
17
+ if (!isLangfuseTracingEnabled())
18
+ return fn();
19
+ let run = fn;
20
+ const sessionId = buildSessionId();
21
+ const userId = process.env.ACTOR_LOGIN?.trim() || undefined;
22
+ if (sessionId || userId) {
23
+ const inner = run;
24
+ run = () => propagateAttributes({ ...(sessionId ? { sessionId } : {}), ...(userId ? { userId } : {}) }, inner);
25
+ }
26
+ const deliveryId = process.env.DELIVERY_ID?.trim();
27
+ if (deliveryId) {
28
+ const traceId = deriveLangfuseTraceId(deliveryId);
29
+ const parent = trace.setSpanContext(context.active(), {
30
+ traceId,
31
+ spanId: traceId.slice(0, 16), // synthetic remote parent, never emitted
32
+ traceFlags: TraceFlags.SAMPLED,
33
+ isRemote: true,
34
+ });
35
+ const inner = run;
36
+ run = () => context.with(parent, inner);
37
+ }
38
+ return run();
39
+ }
@@ -0,0 +1,90 @@
1
+ import { SpanStatusCode, trace } from '@opentelemetry/api';
2
+ import { logger } from './logging.js';
3
+ const MAX_ATTR_SERIALIZED_LEN = 4096;
4
+ export function hrTimeToMs(hrTime) {
5
+ const [seconds, nanos] = hrTime;
6
+ return Math.round(seconds * 1000 + nanos / 1_000_000);
7
+ }
8
+ function capString(value) {
9
+ if (value.length <= MAX_ATTR_SERIALIZED_LEN)
10
+ return value;
11
+ // Reserve room for the marker so the FINAL emitted value stays <= the cap.
12
+ const marker = `…[truncated, ${value.length} chars]`;
13
+ return `${value.slice(0, Math.max(0, MAX_ATTR_SERIALIZED_LEN - marker.length))}${marker}`;
14
+ }
15
+ export function truncateAttrs(attrs) {
16
+ const out = {};
17
+ for (const [key, value] of Object.entries(attrs)) {
18
+ if (key === 'doistbot.kind')
19
+ continue; // internal marker, never emitted
20
+ // Langfuse-only payloads (prompt/completion text) — kept out of Datadog.
21
+ if (key.startsWith('langfuse.'))
22
+ continue;
23
+ if (typeof value === 'string') {
24
+ out[key] = capString(value);
25
+ }
26
+ else if (value !== null && typeof value === 'object') {
27
+ // Arrays/objects (e.g. decision paths, parsed-label arrays): keep
28
+ // the native value if its JSON serialization is under the cap;
29
+ // otherwise replace it with the truncated serialized string.
30
+ const json = JSON.stringify(value);
31
+ out[key] = json.length > MAX_ATTR_SERIALIZED_LEN ? capString(json) : value;
32
+ }
33
+ else {
34
+ out[key] = value; // number / boolean / null — inherently small
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+ // Parent span id access on a finished span is SDK-version-specific. Prefer the
40
+ // newer `parentSpanContext`, fall back to the older `parentSpanId`. Only used
41
+ // in onEnd — onStart derives the parent from the parent Context instead.
42
+ function parentSpanIdOf(span) {
43
+ const withParentCtx = span;
44
+ if (withParentCtx.parentSpanContext?.spanId)
45
+ return withParentCtx.parentSpanContext.spanId;
46
+ const withParentId = span;
47
+ return withParentId.parentSpanId ?? null;
48
+ }
49
+ // Generation spans exist only for Langfuse (see startGeneration). Datadog keeps
50
+ // the phase and tool spans it already had.
51
+ function isLangfuseOnly(span) {
52
+ return span.attributes['doistbot.kind'] === 'generation';
53
+ }
54
+ // Span attributes are spread FIRST so task attributes can never overwrite the
55
+ // envelope fields (event/name/span_id/parent_span_id/duration_ms/status/error)
56
+ // that the Datadog pipeline and the visualizer key on. Timestamps come from
57
+ // the logging system, never from meta.
58
+ export class LoggerSpanProcessor {
59
+ onStart(span, parentContext) {
60
+ const readable = span;
61
+ if (isLangfuseOnly(readable))
62
+ return;
63
+ const isTool = readable.attributes['doistbot.kind'] === 'tool';
64
+ logger.info(`${isTool ? 'tool' : 'phase'}.start: ${readable.name}`, {
65
+ ...truncateAttrs(readable.attributes),
66
+ event: isTool ? 'tool.start' : 'phase.start',
67
+ name: readable.name,
68
+ span_id: readable.spanContext().spanId,
69
+ parent_span_id: trace.getSpanContext(parentContext)?.spanId ?? null,
70
+ });
71
+ }
72
+ onEnd(span) {
73
+ if (isLangfuseOnly(span))
74
+ return;
75
+ const isTool = span.attributes['doistbot.kind'] === 'tool';
76
+ const status = span.status.code === SpanStatusCode.ERROR ? 'error' : 'ok';
77
+ logger.info(`${isTool ? 'tool' : 'phase'}.end: ${span.name}`, {
78
+ ...truncateAttrs(span.attributes),
79
+ event: isTool ? 'tool.end' : 'phase.end',
80
+ name: span.name,
81
+ span_id: span.spanContext().spanId,
82
+ parent_span_id: parentSpanIdOf(span),
83
+ duration_ms: hrTimeToMs(span.duration),
84
+ status,
85
+ ...(status === 'error' && span.status.message ? { error: span.status.message } : {}),
86
+ });
87
+ }
88
+ async forceFlush() { }
89
+ async shutdown() { }
90
+ }
@@ -0,0 +1,18 @@
1
+ import { createLogger } from 'doistbot-logging';
2
+ let suppressedLogLevels = new Set();
3
+ export const logger = createLogger({
4
+ suppressLogs: (level) => suppressedLogLevels.has(level),
5
+ });
6
+ export async function withLogsSuppressed(logLevels, task) {
7
+ const previousValue = suppressedLogLevels;
8
+ suppressedLogLevels = new Set(logLevels);
9
+ try {
10
+ return await task();
11
+ }
12
+ finally {
13
+ suppressedLogLevels = previousValue;
14
+ }
15
+ }
16
+ export async function withInfoLogsSuppressed(suppressInfoLogs, task) {
17
+ return suppressInfoLogs ? await withLogsSuppressed(['info'], task) : await task();
18
+ }
@@ -0,0 +1,188 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Type } from '@earendil-works/pi-ai';
5
+ import { serviceQueryArgs } from './autofix-service-query.js';
6
+ import { isForbiddenAutofixTest } from './autofix-test-policy.js';
7
+ import { AUTOFIX_RUNTIME_ENV, AutofixVerifier, hashAutofixInputs, readAutofixRuntime, summarizeVerification, } from './autofix-verification.js';
8
+ import { boundedToolOutput, safeToolOutput, writeToolRecord } from './tool-output.js';
9
+ // eslint-disable-next-line import/no-default-export
10
+ export default function autofixExtension(pi) {
11
+ const file = process.env[AUTOFIX_RUNTIME_ENV];
12
+ if (!file)
13
+ throw new Error('Missing autofix runtime configuration');
14
+ const runtime = readAutofixRuntime(file);
15
+ const cancellation = new AbortController();
16
+ process.once('SIGTERM', () => {
17
+ cancellation.abort();
18
+ setTimeout(() => process.exit(143), 1500).unref();
19
+ });
20
+ function git(args) {
21
+ return execFileSync('git', args, {
22
+ cwd: runtime.repoPath,
23
+ encoding: 'utf8',
24
+ maxBuffer: 5 * 1024 * 1024,
25
+ env: { ...process.env, GIT_EXTERNAL_DIFF: '', GIT_PAGER: 'cat' },
26
+ timeout: 10_000,
27
+ });
28
+ }
29
+ function fingerprint() {
30
+ git(['add', '-A']);
31
+ return hashAutofixInputs(git(['diff', '--no-ext-diff', '--no-textconv', '--full-index', 'HEAD', '--binary']));
32
+ }
33
+ const verifier = new AutofixVerifier(runtime, fingerprint, (result) => {
34
+ writeToolRecord({ type: 'doistbot_verification_result', ...result });
35
+ });
36
+ pi.registerTool({
37
+ name: 'verify',
38
+ label: 'Verify fix',
39
+ description: 'Run checks from the repository autofix policy. The first round runs all configured checks. Later rounds can select named checks. There is no round-count limit; checks must finish within the autofix time limit. Checks run in order. Group repairs before each rerun. Supply testSelectors for the smallest set of relevant existing or new unit tests. The repo commands receive these as DOISTBOT_TEST_SELECTORS, one per line. Unchanged passed checks are reused.',
40
+ parameters: Type.Object({
41
+ checks: Type.Array(Type.String(), { minItems: 1, uniqueItems: true }),
42
+ testSelectors: Type.Optional(Type.Array(Type.String({ pattern: '^[^\\r\\n\\u0000]{1,200}$' }), {
43
+ maxItems: 30,
44
+ uniqueItems: true,
45
+ })),
46
+ }),
47
+ async execute(_id, params, signal) {
48
+ const state = await verifier.verify(params.checks, AbortSignal.any([cancellation.signal, ...(signal ? [signal] : [])]), params.testSelectors);
49
+ return {
50
+ content: [
51
+ {
52
+ type: 'text',
53
+ text: summarizeVerification(state, runtime.policy, fingerprint()),
54
+ },
55
+ ],
56
+ details: {},
57
+ };
58
+ },
59
+ });
60
+ pi.registerTool({
61
+ name: 'query_service',
62
+ label: 'Query evidence',
63
+ description: 'Read GitHub evidence with gh, Datadog logs with pup, or Sentry reports with sentry. Supply separate CLI arguments. Use --help to read supported syntax. Resolve only a specific gap in the triage plan.',
64
+ parameters: Type.Object({
65
+ cli: Type.Union([Type.Literal('gh'), Type.Literal('pup'), Type.Literal('sentry')]),
66
+ args: Type.Array(Type.String(), { minItems: 1 }),
67
+ }),
68
+ async execute(_id, params) {
69
+ const args = serviceQueryArgs(params.cli, params.args);
70
+ try {
71
+ const output = execFileSync(params.cli, args, {
72
+ cwd: runtime.repoPath,
73
+ encoding: 'utf8',
74
+ stdio: 'pipe',
75
+ env: { ...process.env, GH_HOST: 'github.com' },
76
+ maxBuffer: 2 * 1024 * 1024,
77
+ timeout: Math.max(1, Math.min(60_000, runtime.deadlineMs - Date.now())),
78
+ });
79
+ return {
80
+ content: [{ type: 'text', text: safeToolOutput(output).slice(-24_000) }],
81
+ details: {},
82
+ };
83
+ }
84
+ catch (error) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: safeToolOutput(error instanceof Error ? error.message : 'Evidence query failed').slice(-8000),
90
+ },
91
+ ],
92
+ details: {},
93
+ isError: true,
94
+ };
95
+ }
96
+ },
97
+ });
98
+ pi.registerTool({
99
+ name: 'inspect_git',
100
+ label: 'Inspect Git',
101
+ description: 'Read repository status, current diff, or recent commit history.',
102
+ parameters: Type.Object({
103
+ view: Type.Union([Type.Literal('status'), Type.Literal('diff'), Type.Literal('log')]),
104
+ }),
105
+ async execute(_id, params) {
106
+ const args = params.view === 'status'
107
+ ? ['status', '--short']
108
+ : params.view === 'log'
109
+ ? ['log', '-10', '--oneline']
110
+ : ['diff', '--no-ext-diff', '--no-textconv', 'HEAD'];
111
+ return {
112
+ content: [{ type: 'text', text: boundedToolOutput(safeToolOutput(git(args))) }],
113
+ details: {},
114
+ };
115
+ },
116
+ });
117
+ pi.on('tool_call', (event) => {
118
+ if (event.toolName === 'bash')
119
+ return { block: true, reason: 'Use verify for approved checks.' };
120
+ if (event.toolName !== 'edit' && event.toolName !== 'write')
121
+ return undefined;
122
+ const inputPath = event.input.path;
123
+ if (typeof inputPath !== 'string')
124
+ return { block: true, reason: 'Missing file path' };
125
+ // Pi rewrites these forms before a write. Require plain paths at this boundary.
126
+ if (/^[@~]|^file:\/\/|[\u00a0\u2000-\u200a\u202f\u205f\u3000]/.test(inputPath))
127
+ return { block: true, reason: 'Use a plain relative or absolute file path.' };
128
+ const fullPath = path.resolve(runtime.repoPath, inputPath);
129
+ const relative = path.relative(runtime.repoPath, fullPath);
130
+ if (relative.startsWith('..') ||
131
+ path.isAbsolute(relative) ||
132
+ relative === '.git' ||
133
+ relative.startsWith('.git/')) {
134
+ return { block: true, reason: 'Only target repository source files can be changed.' };
135
+ }
136
+ // Resolve existing ancestors so a repository symlink cannot write outside the checkout.
137
+ let ancestor = fullPath;
138
+ while (ancestor !== path.dirname(ancestor)) {
139
+ try {
140
+ const resolved = path.relative(realpathSync(runtime.repoPath), path.resolve(realpathSync(ancestor), path.relative(ancestor, fullPath)));
141
+ if (resolved === '.github/doistbot.yml' ||
142
+ resolved === '.git' ||
143
+ resolved.startsWith('.git/')) {
144
+ return { block: true, reason: 'Autofix cannot change Git files or its policy.' };
145
+ }
146
+ if (isForbiddenAutofixTest(resolved, runtime.policy.forbidden_test_paths)) {
147
+ return { block: true, reason: 'Autofix cannot change UI tests.' };
148
+ }
149
+ if (resolved.startsWith('..') || path.isAbsolute(resolved)) {
150
+ return { block: true, reason: 'File resolves outside the target repository.' };
151
+ }
152
+ break;
153
+ }
154
+ catch {
155
+ ancestor = path.dirname(ancestor);
156
+ }
157
+ }
158
+ let content = '';
159
+ if (event.toolName === 'write' && typeof event.input.content === 'string') {
160
+ content = event.input.content;
161
+ }
162
+ else if (event.toolName === 'edit' && Array.isArray(event.input.edits)) {
163
+ content = event.input.edits
164
+ .flatMap((edit) => {
165
+ if (edit &&
166
+ typeof edit === 'object' &&
167
+ 'newText' in edit &&
168
+ typeof edit.newText === 'string')
169
+ return [edit.newText];
170
+ return [];
171
+ })
172
+ .join('\n');
173
+ }
174
+ else if (event.toolName === 'edit' &&
175
+ 'newText' in event.input &&
176
+ typeof event.input.newText === 'string') {
177
+ content = event.input.newText;
178
+ }
179
+ if (relative === '.github/doistbot.yml' ||
180
+ isForbiddenAutofixTest(relative, runtime.policy.forbidden_test_paths, content)) {
181
+ return {
182
+ block: true,
183
+ reason: 'Autofix cannot change its policy or create or change UI tests.',
184
+ };
185
+ }
186
+ return undefined;
187
+ });
188
+ }
@@ -0,0 +1,79 @@
1
+ export const PI_EXECUTION_LIMIT_ENV = {
2
+ startedAtMs: 'DOISTBOT_PI_LIMIT_STARTED_AT_MS',
3
+ softTimeMs: 'DOISTBOT_PI_SOFT_TIME_LIMIT_MS',
4
+ softToolCalls: 'DOISTBOT_PI_SOFT_TOOL_LIMIT',
5
+ hardTimeMs: 'DOISTBOT_PI_HARD_TIME_LIMIT_MS',
6
+ hardToolCalls: 'DOISTBOT_PI_HARD_TOOL_LIMIT',
7
+ };
8
+ const SOFT_LIMIT_MESSAGE = 'The inspection budget is nearly complete. Stop gathering new context and return the requested final result now using the evidence you already have.';
9
+ const HARD_LIMIT_MESSAGE = 'The inspection budget is complete. Do not request more tools. Return the requested final result immediately using the evidence you already have.';
10
+ const HARD_TOOL_BLOCK_REASON = 'The inspection budget is complete. No more tool calls are available. Return the final result now.';
11
+ function readPositiveInteger(name) {
12
+ // Keep this parser local. Pi loads this file as a standalone extension, so
13
+ // importing shared application helpers would also load their dependencies.
14
+ const parsed = Number(process.env[name]);
15
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
16
+ }
17
+ // Pi's extension loader requires a default export.
18
+ // eslint-disable-next-line import/no-default-export
19
+ export default function executionLimitsExtension(pi) {
20
+ const startedAtMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.startedAtMs) ?? Date.now();
21
+ const softTimeMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.softTimeMs);
22
+ const softToolCalls = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.softToolCalls);
23
+ const hardTimeMs = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.hardTimeMs);
24
+ const hardToolCalls = readPositiveInteger(PI_EXECUTION_LIMIT_ENV.hardToolCalls);
25
+ let timersStarted = false;
26
+ let softLimitReached = false;
27
+ let hardLimitReached = false;
28
+ let toolCallCount = 0;
29
+ const timers = [];
30
+ function steer(message) {
31
+ pi.sendUserMessage(message, { deliverAs: 'steer' });
32
+ }
33
+ function reachSoftLimit() {
34
+ if (softLimitReached)
35
+ return;
36
+ softLimitReached = true;
37
+ steer(SOFT_LIMIT_MESSAGE);
38
+ }
39
+ function reachHardLimit() {
40
+ if (hardLimitReached)
41
+ return;
42
+ hardLimitReached = true;
43
+ softLimitReached = true;
44
+ steer(HARD_LIMIT_MESSAGE);
45
+ }
46
+ function scheduleAtElapsed(elapsedMs, callback) {
47
+ const remainingMs = Math.max(0, elapsedMs - (Date.now() - startedAtMs));
48
+ timers.push(setTimeout(callback, remainingMs));
49
+ }
50
+ pi.on('agent_start', () => {
51
+ if (timersStarted)
52
+ return;
53
+ timersStarted = true;
54
+ if (softTimeMs)
55
+ scheduleAtElapsed(softTimeMs, reachSoftLimit);
56
+ if (hardTimeMs)
57
+ scheduleAtElapsed(hardTimeMs, reachHardLimit);
58
+ });
59
+ pi.on('tool_call', () => {
60
+ if (hardLimitReached) {
61
+ return { block: true, reason: HARD_TOOL_BLOCK_REASON };
62
+ }
63
+ toolCallCount += 1;
64
+ if (softToolCalls && toolCallCount >= softToolCalls) {
65
+ reachSoftLimit();
66
+ }
67
+ if (hardToolCalls && toolCallCount >= hardToolCalls) {
68
+ // The call that reaches the hard limit is allowed to finish. All
69
+ // later calls are blocked so the model can submit its current work.
70
+ reachHardLimit();
71
+ }
72
+ return undefined;
73
+ });
74
+ pi.on('agent_settled', () => {
75
+ for (const timer of timers)
76
+ clearTimeout(timer);
77
+ timers.length = 0;
78
+ });
79
+ }