@doist/doistbot-cli 1.0.5 → 1.0.6

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 (53) hide show
  1. package/dist/actions/auth.js +55 -21
  2. package/dist/actions/doctor.js +13 -6
  3. package/dist/actions/review.js +75 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +34 -5
  6. package/dist/env.js +1 -0
  7. package/dist/runtime.js +1 -1
  8. package/dist/terminal.js +2 -0
  9. package/package.json +4 -4
  10. package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  11. package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
  12. package/sandbox/dist/core/check-run.js +16 -0
  13. package/sandbox/dist/core/datadog-metrics.js +6 -52
  14. package/sandbox/dist/core/logger-span-processor.js +78 -0
  15. package/sandbox/dist/core/pi.js +257 -176
  16. package/sandbox/dist/core/tracing-setup.js +15 -0
  17. package/sandbox/dist/core/tracing.js +71 -0
  18. package/sandbox/dist/main.js +4 -1
  19. package/sandbox/dist/providers/credentials.js +12 -2
  20. package/sandbox/dist/providers/gemini.js +5 -1
  21. package/sandbox/dist/providers/helpers.js +12 -17
  22. package/sandbox/dist/providers/index.js +4 -0
  23. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  24. package/sandbox/dist/providers/pi-review.js +12 -1
  25. package/sandbox/dist/tasks/chat/chat.js +161 -101
  26. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  27. package/sandbox/dist/tasks/issue-summarize/model.js +1 -1
  28. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  29. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  30. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +31 -8
  31. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  32. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  33. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  34. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  35. package/sandbox/dist/tasks/issue-triage/pr-creator.js +3 -2
  36. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  37. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  38. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  39. package/sandbox/dist/tasks/issue-triage/triage.js +288 -418
  40. package/sandbox/dist/tasks/review/check-run.js +2 -1
  41. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  42. package/sandbox/dist/tasks/review/engines/dedupe.js +5 -4
  43. package/sandbox/dist/tasks/review/engines/multi-focus.js +40 -9
  44. package/sandbox/dist/tasks/review/engines/shared.js +8 -6
  45. package/sandbox/dist/tasks/review/github-comment-format.js +64 -10
  46. package/sandbox/dist/tasks/review/local.js +2 -4
  47. package/sandbox/dist/tasks/review/review-summary.js +45 -7
  48. package/sandbox/dist/tasks/review/review.js +38 -24
  49. package/sandbox/dist/tasks/review/runner.js +23 -2
  50. package/sandbox/dist/tasks/review/summary-model.js +164 -17
  51. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  52. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  53. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
@@ -0,0 +1,71 @@
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
+ const tracer = trace.getTracer('doistbot-sandbox');
8
+ /**
9
+ * THE phase-boundary API. Works for sequential AND parallel phases — OTel's
10
+ * async-hooks context manager (registered by setupTracing) parents nested
11
+ * spans correctly even across Promise.all.
12
+ *
13
+ * On throw: records the exception, sets span status ERROR, re-throws.
14
+ * On normal return: status is left UNSET (OTel's recommended success default;
15
+ * the LoggerSpanProcessor maps UNSET → 'ok'). We deliberately do NOT force
16
+ * OK, so a callback that returns a failure result (instead of throwing) and
17
+ * sets ERROR itself keeps its ERROR status.
18
+ * Always ends the span.
19
+ */
20
+ export async function withPhase(name, attributes, fn) {
21
+ return tracer.startActiveSpan(name, { attributes: { ...attributes, 'doistbot.kind': 'phase' } }, async (span) => {
22
+ try {
23
+ return await fn(span);
24
+ }
25
+ catch (err) {
26
+ span.recordException(err);
27
+ span.setStatus({
28
+ code: SpanStatusCode.ERROR,
29
+ message: err instanceof Error ? err.message : String(err),
30
+ });
31
+ throw err;
32
+ }
33
+ finally {
34
+ span.end();
35
+ }
36
+ });
37
+ }
38
+ /**
39
+ * Start a tool span manually. The ONE exception to "withPhase everywhere":
40
+ * pi emits tool start/end across separate async callbacks (onProgress), so
41
+ * there is no single scope to wrap. Parent = the currently-active span (the
42
+ * model phase). Caller MUST call `span.end()` when the matching tool_end
43
+ * arrives (and may `setStatus({ code: ERROR })` first on failure).
44
+ */
45
+ export function startToolSpan(name, attributes) {
46
+ return tracer.startSpan(name, { attributes: { ...attributes, 'doistbot.kind': 'tool' } }, context.active());
47
+ }
48
+ // Point-in-time lines bypass OTel spans entirely; they read the active span
49
+ // id only for correlation. Meta is spread FIRST so callers can never clobber
50
+ // the envelope fields (event/name/span_id). Timestamps come from the logging
51
+ // system, never from meta.
52
+ function emitPointInTime(kind, name, meta) {
53
+ const span = trace.getActiveSpan();
54
+ logger.info(`${kind}: ${name}`, {
55
+ ...truncateAttrs(meta),
56
+ event: kind,
57
+ name,
58
+ span_id: span?.spanContext().spanId ?? null,
59
+ });
60
+ }
61
+ /**
62
+ * Record a point-in-time decision (branch taken, scope resolved, gate passed)
63
+ * correlated to the active span. Not a span — no duration.
64
+ */
65
+ export function recordDecision(name, meta) {
66
+ emitPointInTime('decision', name, meta);
67
+ }
68
+ /** Generic point-in-time annotation. Same mechanics as recordDecision. */
69
+ export function recordEvent(name, meta) {
70
+ emitPointInTime('event', name, meta);
71
+ }
@@ -1,3 +1,7 @@
1
+ import { setupTracing } from './core/tracing-setup.js';
2
+ // Register the OTel SDK before any task module loads — withPhase/startToolSpan
3
+ // are no-ops until a tracer provider is registered.
4
+ setupTracing();
1
5
  const taskType = process.env.TASK_TYPE ?? 'review';
2
6
  switch (taskType) {
3
7
  case 'chat':
@@ -32,4 +36,3 @@ switch (taskType) {
32
36
  process.stderr.write(`Unknown TASK_TYPE: ${taskType}\n`);
33
37
  process.exit(1);
34
38
  }
35
- export {};
@@ -1,7 +1,15 @@
1
+ import { parsePiAuthProviderId } from '../core/pi.js';
1
2
  export function resolveReviewProviderCredential(credential) {
2
3
  if (typeof credential === 'string') {
3
4
  const apiKey = credential.trim();
4
- return apiKey ? { apiKey } : undefined;
5
+ if (!apiKey) {
6
+ return undefined;
7
+ }
8
+ const piAuthProviderId = parsePiAuthProviderId(apiKey);
9
+ return {
10
+ apiKey,
11
+ ...(piAuthProviderId ? { piProviderId: piAuthProviderId } : {}),
12
+ };
5
13
  }
6
14
  if (!credential) {
7
15
  return undefined;
@@ -10,9 +18,11 @@ export function resolveReviewProviderCredential(credential) {
10
18
  if (!apiKey) {
11
19
  return undefined;
12
20
  }
21
+ const piAuthProviderId = parsePiAuthProviderId(apiKey);
22
+ const piProviderId = piAuthProviderId ?? credential.piProviderId;
13
23
  return {
14
24
  apiKey,
15
- ...(credential.piProviderId ? { piProviderId: credential.piProviderId } : {}),
25
+ ...(piProviderId ? { piProviderId } : {}),
16
26
  ...(credential.modelName ? { modelName: credential.modelName } : {}),
17
27
  };
18
28
  }
@@ -9,7 +9,11 @@ import { runPiReviewProvider } from './pi-review.js';
9
9
  export class GeminiProvider {
10
10
  name = 'gemini';
11
11
  async review(prompt, config) {
12
- const modelName = config.modelName ?? DEFAULT_OPENROUTER_MODELS.gemini ?? DEFAULT_PI_MODELS.gemini;
12
+ const modelName = config.modelName ??
13
+ (config.piProviderId === 'openrouter'
14
+ ? DEFAULT_OPENROUTER_MODELS.gemini
15
+ : DEFAULT_PI_MODELS.gemini) ??
16
+ DEFAULT_PI_MODELS.gemini;
13
17
  return await runPiReviewProvider({
14
18
  provider: this.name,
15
19
  modelName,
@@ -166,29 +166,24 @@ function extractBalancedJsonCandidates(content) {
166
166
  }
167
167
  return candidates;
168
168
  }
169
+ /**
170
+ * Returns JSON-looking candidates from a response string in parser preference order.
171
+ * Fenced JSON blocks are preferred over balanced objects because models often include
172
+ * code snippets or object-shaped prose before the final fenced response.
173
+ */
174
+ export function extractJsonCandidatesFromResponse(content) {
175
+ return [
176
+ ...extractFencedJsonCandidates(content).reverse(),
177
+ ...extractBalancedJsonCandidates(content).reverse(),
178
+ ];
179
+ }
169
180
  /**
170
181
  * Extracts JSON from a response string that may contain additional text.
171
182
  * Prefers valid fenced JSON blocks, then valid balanced JSON objects.
172
183
  * Throws when no valid JSON candidate can be parsed.
173
184
  */
174
185
  export function extractJsonFromResponse(content) {
175
- const fencedCandidates = extractFencedJsonCandidates(content);
176
- for (let index = fencedCandidates.length - 1; index >= 0; index--) {
177
- const candidate = fencedCandidates[index];
178
- if (!candidate) {
179
- continue;
180
- }
181
- try {
182
- JSON.parse(candidate);
183
- return candidate;
184
- }
185
- catch {
186
- continue;
187
- }
188
- }
189
- const balancedCandidates = extractBalancedJsonCandidates(content);
190
- for (let index = balancedCandidates.length - 1; index >= 0; index--) {
191
- const candidate = balancedCandidates[index];
186
+ for (const candidate of extractJsonCandidatesFromResponse(content)) {
192
187
  if (!candidate) {
193
188
  continue;
194
189
  }
@@ -3,11 +3,15 @@
3
3
  */
4
4
  import { GeminiProvider } from './gemini.js';
5
5
  import { OpenAIProvider } from './openai.js';
6
+ import { OpenRouterPiProvider } from './openrouter-pi.js';
6
7
  export { GeminiProvider } from './gemini.js';
7
8
  export { OpenAIProvider } from './openai.js';
9
+ export { OpenRouterPiProvider } from './openrouter-pi.js';
8
10
  export * from './types.js';
9
11
  const providers = {
12
+ deepseek: new OpenRouterPiProvider('deepseek'),
10
13
  gemini: new GeminiProvider(),
14
+ zai: new OpenRouterPiProvider('zai'),
11
15
  openai: new OpenAIProvider(),
12
16
  };
13
17
  /**
@@ -0,0 +1,25 @@
1
+ import { DEFAULT_OPENROUTER_MODELS, DEFAULT_PI_MODELS } from '../core/pi.js';
2
+ import { resolveProviderTimeoutMs } from './config.js';
3
+ import { extractJsonFromResponse } from './helpers.js';
4
+ import { runPiReviewProvider } from './pi-review.js';
5
+ export class OpenRouterPiProvider {
6
+ name;
7
+ fallbackModelName;
8
+ constructor(name, fallbackModelName = DEFAULT_OPENROUTER_MODELS[name] ??
9
+ DEFAULT_PI_MODELS[name]) {
10
+ this.name = name;
11
+ this.fallbackModelName = fallbackModelName;
12
+ }
13
+ async review(prompt, config) {
14
+ return await runPiReviewProvider({
15
+ provider: this.name,
16
+ modelName: config.modelName ?? this.fallbackModelName,
17
+ prompt,
18
+ config: {
19
+ ...config,
20
+ timeout: resolveProviderTimeoutMs(config.timeout),
21
+ },
22
+ parseOutput: (output) => JSON.parse(extractJsonFromResponse(output)),
23
+ });
24
+ }
25
+ }
@@ -1,5 +1,5 @@
1
1
  import { logger } from '../core/logging.js';
2
- import { createPiInvoker } from '../core/pi.js';
2
+ import { createPiInvoker, parsePiAuthProviderId, PI_PROVIDER_BY_DOISTBOT_PROVIDER, } from '../core/pi.js';
3
3
  import { logPiProgressEvent } from '../core/pi-progress-logging.js';
4
4
  import { WORKSPACE_DIR } from '../core/shared.js';
5
5
  import { logRawModelOutput, normalizeParsedModelOutput } from './helpers.js';
@@ -47,6 +47,12 @@ function logReviewRawModelOutput(args) {
47
47
  }
48
48
  logRawModelOutput(args);
49
49
  }
50
+ function isOpenRouterBackedReviewRequest(provider, config) {
51
+ const resolvedPiProviderId = parsePiAuthProviderId(config.apiKey) ??
52
+ config.piProviderId ??
53
+ PI_PROVIDER_BY_DOISTBOT_PROVIDER[provider];
54
+ return resolvedPiProviderId === 'openrouter';
55
+ }
50
56
  export async function runPiReviewProvider({ provider, modelName, prompt, config, prompts = [prompt], parseOutput, }) {
51
57
  const startTime = Date.now();
52
58
  const timeoutMs = config.timeout ?? 0;
@@ -59,6 +65,9 @@ export async function runPiReviewProvider({ provider, modelName, prompt, config,
59
65
  promptLength: prompt.length,
60
66
  modelName,
61
67
  timeoutMs,
68
+ ...(isOpenRouterBackedReviewRequest(provider, config) && config.openRouterSessionId
69
+ ? { openRouterSessionId: config.openRouterSessionId }
70
+ : {}),
62
71
  });
63
72
  try {
64
73
  const invoker = createPiInvoker({
@@ -72,6 +81,8 @@ export async function runPiReviewProvider({ provider, modelName, prompt, config,
72
81
  capabilityPreset: 'read-only',
73
82
  thinkingLevel: config.thinkingLevel,
74
83
  systemPrompt: REVIEW_PI_SYSTEM_PROMPT,
84
+ noContextFiles: true,
85
+ openRouterSessionId: config.openRouterSessionId,
75
86
  });
76
87
  for (let attempt = 0; attempt < prompts.length; attempt += 1) {
77
88
  const result = await invoker.invoke({
@@ -1,4 +1,5 @@
1
1
  import { performance } from 'node:perf_hooks';
2
+ import { SpanStatusCode } from '@opentelemetry/api';
2
3
  import { isKnownDoistbotLogin } from '../../core/bot-logins.js';
3
4
  import { recordTaskMetrics } from '../../core/datadog-metrics.js';
4
5
  import { logger } from '../../core/logging.js';
@@ -6,9 +7,12 @@ import { invokePiPrompt } from '../../core/pi.js';
6
7
  import { loadPromptTemplateFile } from '../../core/prompt-template.js';
7
8
  import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
8
9
  import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
10
+ import { withPhase } from '../../core/tracing.js';
9
11
  import { buildPrompt } from './prompt.js';
10
12
  const CHAT_PROMPT_FILE = 'chat-prompt.md';
11
13
  const CHAT_TIMEOUT_MS = 5 * 60 * 1000;
14
+ const CHAT_PROVIDER = 'zai';
15
+ const CHAT_MODEL = 'z-ai/glm-5.2';
12
16
  function parseContext() {
13
17
  const base = parseBaseContext();
14
18
  const reactionIdRaw = process.env.REACTION_ID;
@@ -19,6 +23,7 @@ function parseContext() {
19
23
  reactionId: Number.isFinite(reactionId) ? reactionId : undefined,
20
24
  commentType: requiredEnv('COMMENT_TYPE'),
21
25
  eventName: requiredEnv('EVENT_NAME'),
26
+ dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
22
27
  };
23
28
  }
24
29
  /**
@@ -122,26 +127,46 @@ async function gatherIssueCommentContext(octokit, owner, repo, prNumber, comment
122
127
  export async function invokeChatModel(prompt) {
123
128
  const apiKey = process.env.OPENROUTER_API_KEY?.trim();
124
129
  if (!apiKey) {
130
+ // Config error: no model call attempted, so no model span. The reason is
131
+ // returned so the caller's root span reports the real cause.
125
132
  logger.warn('Chat model API key missing');
126
- return null;
133
+ return { ok: false, reason: 'OPENROUTER_API_KEY missing' };
127
134
  }
128
- const result = await invokePiPrompt({
129
- provider: 'gemini',
130
- apiKey,
131
- piProviderId: 'openrouter',
132
- prompt,
133
- cwd: WORKSPACE_DIR,
134
- timeoutMs: CHAT_TIMEOUT_MS,
135
- capabilityPreset: 'workspace-inspection',
136
- });
137
- if (!result.ok) {
138
- logger.warn('Chat model request failed', {
139
- error: result.error,
140
- timedOut: result.timedOut,
135
+ return withPhase('chat.model', {
136
+ 'gen_ai.operation.name': 'chat',
137
+ 'gen_ai.provider.name': CHAT_PROVIDER,
138
+ 'gen_ai.request.model': CHAT_MODEL,
139
+ }, async (span) => {
140
+ const result = await invokePiPrompt({
141
+ provider: CHAT_PROVIDER,
142
+ apiKey,
143
+ piProviderId: 'openrouter',
144
+ prompt,
145
+ cwd: WORKSPACE_DIR,
146
+ timeoutMs: CHAT_TIMEOUT_MS,
147
+ capabilityPreset: 'workspace-inspection',
148
+ // Keep the executed model in sync with the recorded span attr.
149
+ modelName: CHAT_MODEL,
141
150
  });
142
- return null;
143
- }
144
- return result.output.trim() || null;
151
+ if (!result.ok) {
152
+ // Failure returns a result rather than throwing — mark the span
153
+ // ERROR explicitly so it survives to phase.end (withPhase only
154
+ // auto-errors on a throw).
155
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
156
+ logger.warn('Chat model request failed', {
157
+ error: result.error,
158
+ timedOut: result.timedOut,
159
+ });
160
+ return { ok: false, reason: result.error };
161
+ }
162
+ const output = result.output.trim();
163
+ if (!output) {
164
+ const reason = 'chat model produced no output';
165
+ span.setStatus({ code: SpanStatusCode.ERROR, message: reason });
166
+ return { ok: false, reason };
167
+ }
168
+ return { ok: true, output };
169
+ });
145
170
  }
146
171
  /**
147
172
  * Posts the response to the correct location.
@@ -170,6 +195,17 @@ async function postResponse(octokit, ctx, response) {
170
195
  prNumber: ctx.prNumber,
171
196
  });
172
197
  }
198
+ function formatChatDryRunOutput(ctx, response) {
199
+ return JSON.stringify({
200
+ task: 'chat',
201
+ repository: `${ctx.owner}/${ctx.repo}`,
202
+ prNumber: ctx.prNumber,
203
+ commentId: ctx.commentId,
204
+ commentType: ctx.commentType,
205
+ eventName: ctx.eventName,
206
+ response,
207
+ }, null, 2);
208
+ }
173
209
  /**
174
210
  * Removes the 👀 reaction from the triggering comment.
175
211
  */
@@ -210,102 +246,126 @@ async function main() {
210
246
  commentId: ctx.commentId,
211
247
  };
212
248
  logger.setBaseContext(logContext);
213
- logger.info('Chat task started', {
214
- commentType: ctx.commentType,
215
- eventName: ctx.eventName,
216
- });
217
- const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
218
249
  const reactionId = ctx.reactionId;
219
250
  let exitCode = 0;
220
251
  let metricOutcome = 'failure';
221
- try {
222
- // Clone repository
223
- await cloneRepository({
224
- app,
225
- owner: ctx.owner,
226
- repo: ctx.repo,
227
- prNumber: ctx.prNumber,
228
- installationId: ctx.installationId,
229
- });
230
- // Gather thread context from GitHub API
231
- let threadContext;
232
- if (ctx.commentType === 'review') {
233
- threadContext = await gatherReviewCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.commentId);
234
- }
235
- else {
236
- threadContext = await gatherIssueCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.prNumber, ctx.commentId);
237
- }
238
- // Build prompt and invoke the chat model
239
- const promptTemplate = loadPromptTemplateFile({
240
- fileName: CHAT_PROMPT_FILE,
241
- promptLabel: 'chat',
242
- });
243
- const prompt = buildPrompt({
244
- promptTemplate,
245
- owner: ctx.owner,
246
- repo: ctx.repo,
247
- prNumber: ctx.prNumber,
248
- threadHistory: threadContext.threadHistory,
249
- userQuestion: threadContext.userQuestion,
250
- codeSnippet: threadContext.codeSnippet,
251
- prTitle: threadContext.prTitle,
252
- prAuthor: threadContext.prAuthor,
253
- });
254
- const response = await invokeChatModel(prompt);
255
- if (!response) {
256
- logger.warn('Chat model request returned no output');
257
- return;
258
- }
259
- // Post response
260
- await postResponse(writeOctokit, ctx, response);
261
- logger.info('Chat task completed');
262
- metricOutcome = 'success';
263
- }
264
- catch (error) {
265
- logger.error('Chat task failed', {
266
- error: error instanceof Error ? error.message : String(error),
267
- });
268
- // Post error message to the PR
252
+ await withPhase('chat', {
253
+ task: 'chat',
254
+ 'gen_ai.operation.name': 'invoke_agent',
255
+ 'gen_ai.agent.name': 'doistbot.chat',
256
+ 'pr.number': ctx.prNumber,
257
+ 'comment.id': ctx.commentId,
258
+ 'comment.type': ctx.commentType,
259
+ 'event.name': ctx.eventName,
260
+ dry_run: ctx.dryRun,
261
+ }, async (span) => {
262
+ // Inside the span so auth/setup failures are captured by the root span.
263
+ const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
269
264
  try {
270
- const errorMessage = 'Sorry, I encountered an error while processing your request. Please try again or contact the team maintaining the bot.';
265
+ // Clone repository
266
+ await cloneRepository({
267
+ app,
268
+ owner: ctx.owner,
269
+ repo: ctx.repo,
270
+ prNumber: ctx.prNumber,
271
+ installationId: ctx.installationId,
272
+ });
273
+ // Gather thread context from GitHub API
274
+ let threadContext;
271
275
  if (ctx.commentType === 'review') {
272
- await writeOctokit.rest.pulls.createReplyForReviewComment({
273
- owner: ctx.owner,
274
- repo: ctx.repo,
275
- pull_number: ctx.prNumber,
276
- comment_id: ctx.commentId,
277
- body: errorMessage,
278
- });
276
+ threadContext = await gatherReviewCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.commentId);
279
277
  }
280
278
  else {
281
- await writeOctokit.rest.issues.createComment({
282
- owner: ctx.owner,
283
- repo: ctx.repo,
284
- issue_number: ctx.prNumber,
285
- body: errorMessage,
279
+ threadContext = await gatherIssueCommentContext(readOctokit, ctx.owner, ctx.repo, ctx.prNumber, ctx.commentId);
280
+ }
281
+ // Build prompt and invoke the chat model
282
+ const promptTemplate = loadPromptTemplateFile({
283
+ fileName: CHAT_PROMPT_FILE,
284
+ promptLabel: 'chat',
285
+ });
286
+ const prompt = buildPrompt({
287
+ promptTemplate,
288
+ owner: ctx.owner,
289
+ repo: ctx.repo,
290
+ prNumber: ctx.prNumber,
291
+ threadHistory: threadContext.threadHistory,
292
+ userQuestion: threadContext.userQuestion,
293
+ codeSnippet: threadContext.codeSnippet,
294
+ prTitle: threadContext.prTitle,
295
+ prAuthor: threadContext.prAuthor,
296
+ });
297
+ const modelResult = await invokeChatModel(prompt);
298
+ if (!modelResult.ok) {
299
+ span.setStatus({ code: SpanStatusCode.ERROR, message: modelResult.reason });
300
+ logger.warn('Chat model produced no usable response', {
301
+ reason: modelResult.reason,
286
302
  });
303
+ return;
304
+ }
305
+ const response = modelResult.output;
306
+ if (ctx.dryRun) {
307
+ logger.info('Dry run enabled, printing chat response instead of posting to GitHub');
308
+ process.stdout.write(`${formatChatDryRunOutput(ctx, response)}\n`);
287
309
  }
310
+ else {
311
+ await postResponse(writeOctokit, ctx, response);
312
+ }
313
+ metricOutcome = 'success';
288
314
  }
289
- catch (replyError) {
290
- logger.error('Failed to post error message', {
291
- error: replyError instanceof Error ? replyError.message : String(replyError),
292
- });
315
+ catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ logger.error('Chat task failed', { error: message });
318
+ // The catch swallows the error to post a user reply, so set the
319
+ // span status explicitly — withPhase only auto-errors on rethrow.
320
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
321
+ if (ctx.dryRun) {
322
+ logger.info('Dry run enabled, skipping chat error response post');
323
+ }
324
+ else {
325
+ try {
326
+ const errorMessage = 'Sorry, I encountered an error while processing your request. Please try again or contact the team maintaining the bot.';
327
+ if (ctx.commentType === 'review') {
328
+ await writeOctokit.rest.pulls.createReplyForReviewComment({
329
+ owner: ctx.owner,
330
+ repo: ctx.repo,
331
+ pull_number: ctx.prNumber,
332
+ comment_id: ctx.commentId,
333
+ body: errorMessage,
334
+ });
335
+ }
336
+ else {
337
+ await writeOctokit.rest.issues.createComment({
338
+ owner: ctx.owner,
339
+ repo: ctx.repo,
340
+ issue_number: ctx.prNumber,
341
+ body: errorMessage,
342
+ });
343
+ }
344
+ }
345
+ catch (replyError) {
346
+ logger.error('Failed to post error message', {
347
+ error: replyError instanceof Error
348
+ ? replyError.message
349
+ : String(replyError),
350
+ });
351
+ }
352
+ }
353
+ exitCode = 1;
293
354
  }
294
- exitCode = 1;
295
- }
296
- finally {
297
- // Always remove reaction
298
- if (reactionId) {
299
- await removeReaction(writeOctokit, ctx, reactionId);
355
+ finally {
356
+ // Local dry runs must stay side-effect free.
357
+ if (reactionId && !ctx.dryRun) {
358
+ await removeReaction(writeOctokit, ctx, reactionId);
359
+ }
360
+ await recordTaskMetrics({
361
+ type: 'chat',
362
+ repository: `${ctx.owner}/${ctx.repo}`,
363
+ model: 'zai',
364
+ outcome: metricOutcome,
365
+ durationMs: performance.now() - startedAt,
366
+ });
300
367
  }
301
- await recordTaskMetrics({
302
- type: 'chat',
303
- repository: `${ctx.owner}/${ctx.repo}`,
304
- model: 'google',
305
- outcome: metricOutcome,
306
- durationMs: performance.now() - startedAt,
307
- });
308
- }
368
+ });
309
369
  if (exitCode !== 0) {
310
370
  process.exit(exitCode);
311
371
  }