@doist/doistbot-cli 1.0.5 → 1.0.7

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 (61) hide show
  1. package/dist/actions/auth.js +78 -24
  2. package/dist/actions/doctor.js +14 -7
  3. package/dist/actions/review.js +67 -10
  4. package/dist/auth.js +51 -6
  5. package/dist/config.js +77 -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 +285 -174
  16. package/sandbox/dist/core/prompt-template.js +45 -3
  17. package/sandbox/dist/core/repository-map.js +24 -0
  18. package/sandbox/dist/core/tracing-setup.js +15 -0
  19. package/sandbox/dist/core/tracing.js +80 -0
  20. package/sandbox/dist/main.js +4 -1
  21. package/sandbox/dist/providers/credentials.js +12 -2
  22. package/sandbox/dist/providers/gemini.js +5 -1
  23. package/sandbox/dist/providers/helpers.js +63 -17
  24. package/sandbox/dist/providers/index.js +4 -0
  25. package/sandbox/dist/providers/openrouter-pi.js +25 -0
  26. package/sandbox/dist/providers/pi-review.js +19 -2
  27. package/sandbox/dist/tasks/chat/chat.js +173 -102
  28. package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
  29. package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
  30. package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
  31. package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
  32. package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
  33. package/sandbox/dist/tasks/issue-triage/context.js +25 -1
  34. package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
  35. package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
  36. package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
  37. package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
  38. package/sandbox/dist/tasks/issue-triage/model.js +120 -36
  39. package/sandbox/dist/tasks/issue-triage/output.js +27 -0
  40. package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
  41. package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
  42. package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
  43. package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
  44. package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
  45. package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
  46. package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
  47. package/sandbox/dist/tasks/review/check-run.js +2 -1
  48. package/sandbox/dist/tasks/review/conversation-context.js +2 -1
  49. package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
  50. package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
  51. package/sandbox/dist/tasks/review/engines/shared.js +43 -6
  52. package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
  53. package/sandbox/dist/tasks/review/local.js +7 -4
  54. package/sandbox/dist/tasks/review/review-summary.js +48 -7
  55. package/sandbox/dist/tasks/review/review.js +267 -186
  56. package/sandbox/dist/tasks/review/runner.js +33 -7
  57. package/sandbox/dist/tasks/review/summary-model.js +189 -20
  58. package/sandbox/dist/tasks/review/thinking-level.js +4 -0
  59. package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
  60. package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
  61. package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
@@ -1,38 +1,62 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { logger } from '../../core/logging.js';
2
- import { buildPiImageContent, createPiInvoker } from '../../core/pi.js';
3
+ import { buildPiImageContent, createPiInvoker, DEFAULT_OPENROUTER_MODELS } from '../../core/pi.js';
4
+ import { withPhase } from '../../core/tracing.js';
3
5
  const ISSUE_SUMMARIZE_TIMEOUT_MS = 5 * 60 * 1000;
4
6
  const ISSUE_SUMMARIZE_MULTIMODAL_MODEL = 'google/gemini-2.5-pro';
7
+ const SUMMARIZE_TEXT_PROVIDER = 'zai';
8
+ // Single source of truth: inherit the repo-wide ZAI default so a default-model
9
+ // bump in core/pi.ts moves summarize too, and the executed model matches the
10
+ // recorded gen_ai.request.model.
11
+ const SUMMARIZE_TEXT_MODEL = DEFAULT_OPENROUTER_MODELS.zai;
12
+ const SUMMARIZE_MULTIMODAL_PROVIDER = 'gcp.gemini';
13
+ // One model span per actual model.invoke. The final summary uses
14
+ // `issue-summarize.model`; each chunk summary uses `issue-summarize.chunk`.
15
+ const SUMMARIZE_MODEL_PHASE = 'issue-summarize.model';
16
+ const SUMMARIZE_CHUNK_PHASE = 'issue-summarize.chunk';
5
17
  export function createIssueSummarizeModel(apiKey) {
6
18
  return createPiInvoker({
7
- provider: 'gemini',
19
+ provider: 'zai',
8
20
  apiKey,
9
21
  piProviderId: 'openrouter',
10
22
  cwd: process.cwd(),
11
23
  capabilityPreset: 'read-only',
12
24
  });
13
25
  }
14
- async function invokeIssueSummarizeTextModel({ model, prompt, }) {
15
- const result = await model.invoke({
16
- prompt,
17
- timeoutMs: ISSUE_SUMMARIZE_TIMEOUT_MS,
18
- });
19
- if (!result.ok) {
20
- logger.warn('Issue summarize model request failed', {
21
- error: result.error,
22
- timedOut: result.timedOut,
26
+ async function invokeIssueSummarizeTextModel({ model, prompt, phaseName, }) {
27
+ return withPhase(phaseName, {
28
+ 'gen_ai.operation.name': 'chat',
29
+ 'gen_ai.provider.name': SUMMARIZE_TEXT_PROVIDER,
30
+ 'gen_ai.request.model': SUMMARIZE_TEXT_MODEL,
31
+ }, async (span) => {
32
+ const result = await model.invoke({
33
+ prompt,
34
+ modelName: SUMMARIZE_TEXT_MODEL,
35
+ timeoutMs: ISSUE_SUMMARIZE_TIMEOUT_MS,
23
36
  });
24
- return null;
25
- }
26
- return result.output.trim() || null;
27
- }
28
- export async function invokeIssueSummarizeModel({ model, prompt, mediaImages, }) {
29
- if (mediaImages.length === 0) {
30
- logger.info('Using text-only summarize model request');
31
- return await invokeIssueSummarizeTextModel({ model, prompt });
32
- }
33
- logger.info('Using multimodal summarize model request', {
34
- mediaImages: mediaImages.length,
37
+ if (!result.ok) {
38
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
39
+ logger.warn('Issue summarize model request failed', {
40
+ error: result.error,
41
+ timedOut: result.timedOut,
42
+ });
43
+ return null;
44
+ }
45
+ const output = result.output.trim() || null;
46
+ if (!output) {
47
+ span.setStatus({
48
+ code: SpanStatusCode.ERROR,
49
+ message: 'summarize model produced no output',
50
+ });
51
+ }
52
+ return output;
35
53
  });
54
+ }
55
+ // One model span for the multimodal model.invoke (provider/model known at
56
+ // start). On failure it returns null and the caller falls back to a separate
57
+ // text request — which emits its own model span, so a fallback shows two real
58
+ // model calls rather than one.
59
+ async function invokeMultimodalSummarizeModel({ model, prompt, mediaImages, }) {
36
60
  const mediaContext = mediaImages
37
61
  .map((mediaImage, index) => [
38
62
  `Media item ${index + 1} of ${mediaImages.length}`,
@@ -43,40 +67,72 @@ export async function invokeIssueSummarizeModel({ model, prompt, mediaImages, })
43
67
  mediaImage.sourceContext,
44
68
  ].join('\n'))
45
69
  .join('\n\n');
46
- try {
47
- const result = await model.invoke({
48
- modelName: ISSUE_SUMMARIZE_MULTIMODAL_MODEL,
49
- prompt: `${prompt}\n\n<media_context>\n${mediaContext}\n</media_context>`,
50
- timeoutMs: ISSUE_SUMMARIZE_TIMEOUT_MS,
51
- images: mediaImages.map((mediaImage) => buildPiImageContent({
52
- dataBase64: mediaImage.dataBase64,
53
- contentType: mediaImage.contentType,
54
- })),
55
- });
56
- if (!result.ok) {
57
- logger.warn('Issue summarize multimodal request failed', {
58
- error: result.error,
59
- timedOut: result.timedOut,
60
- mediaImages: mediaImages.length,
70
+ return withPhase(SUMMARIZE_MODEL_PHASE, {
71
+ 'gen_ai.operation.name': 'chat',
72
+ 'gen_ai.provider.name': SUMMARIZE_MULTIMODAL_PROVIDER,
73
+ 'gen_ai.request.model': ISSUE_SUMMARIZE_MULTIMODAL_MODEL,
74
+ }, async (span) => {
75
+ try {
76
+ const result = await model.invoke({
77
+ modelName: ISSUE_SUMMARIZE_MULTIMODAL_MODEL,
78
+ prompt: `${prompt}\n\n<media_context>\n${mediaContext}\n</media_context>`,
79
+ timeoutMs: ISSUE_SUMMARIZE_TIMEOUT_MS,
80
+ images: mediaImages.map((mediaImage) => buildPiImageContent({
81
+ dataBase64: mediaImage.dataBase64,
82
+ contentType: mediaImage.contentType,
83
+ })),
61
84
  });
62
- return await invokeIssueSummarizeTextModel({ model, prompt });
85
+ if (!result.ok) {
86
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
87
+ logger.warn('Issue summarize multimodal request failed', {
88
+ error: result.error,
89
+ timedOut: result.timedOut,
90
+ mediaImages: mediaImages.length,
91
+ });
92
+ return null;
93
+ }
94
+ const text = result.output.trim();
95
+ if (!text) {
96
+ span.setStatus({
97
+ code: SpanStatusCode.ERROR,
98
+ message: 'multimodal summarize produced no output',
99
+ });
100
+ logger.warn('Issue summarize multimodal request returned empty output', {
101
+ mediaImages: mediaImages.length,
102
+ });
103
+ return null;
104
+ }
105
+ return text;
63
106
  }
64
- const text = result.output.trim();
65
- if (!text) {
66
- logger.warn('Issue summarize multimodal request returned empty output', {
107
+ catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
110
+ logger.warn('Issue summarize multimodal request errored', {
111
+ error: message,
67
112
  mediaImages: mediaImages.length,
68
113
  });
69
- return await invokeIssueSummarizeTextModel({ model, prompt });
114
+ return null;
70
115
  }
71
- return text;
72
- }
73
- catch (error) {
74
- logger.warn('Issue summarize multimodal request errored', {
75
- error: error instanceof Error ? error.message : String(error),
76
- mediaImages: mediaImages.length,
116
+ });
117
+ }
118
+ export async function invokeIssueSummarizeModel({ model, prompt, mediaImages, }) {
119
+ if (mediaImages.length === 0) {
120
+ logger.info('Using text-only summarize model request');
121
+ return await invokeIssueSummarizeTextModel({
122
+ model,
123
+ prompt,
124
+ phaseName: SUMMARIZE_MODEL_PHASE,
77
125
  });
78
- return await invokeIssueSummarizeTextModel({ model, prompt });
79
126
  }
127
+ logger.info('Using multimodal summarize model request', {
128
+ mediaImages: mediaImages.length,
129
+ });
130
+ const multimodal = await invokeMultimodalSummarizeModel({ model, prompt, mediaImages });
131
+ if (multimodal) {
132
+ return multimodal;
133
+ }
134
+ // Multimodal failed — fall back to a separate text request (own model span).
135
+ return await invokeIssueSummarizeTextModel({ model, prompt, phaseName: SUMMARIZE_MODEL_PHASE });
80
136
  }
81
137
  function buildChunkSummaryPrompt({ chunk, chunkIndex, selectedChunkCount, totalChunks, }) {
82
138
  return `You are preparing an intermediate summary for a GitHub issue discussion.
@@ -97,6 +153,14 @@ ${chunk}
97
153
  </chunk_content>`;
98
154
  }
99
155
  export async function summarizeChunks({ model, chunks, totalChunks, }) {
156
+ // Workflow phase: it orchestrates one model request PER chunk, so it is not
157
+ // itself a single gen-AI operation and carries no gen_ai.* attrs.
158
+ return withPhase('issue-summarize.chunks', {
159
+ 'chunks.selected': chunks.length,
160
+ 'chunks.total': totalChunks,
161
+ }, async (span) => summarizeChunksInner({ model, chunks, totalChunks }, span));
162
+ }
163
+ async function summarizeChunksInner({ model, chunks, totalChunks, }, span) {
100
164
  const summaries = [];
101
165
  let missingChunkSummaries = 0;
102
166
  logger.info('Summarizing issue thread in chunks', {
@@ -113,6 +177,7 @@ export async function summarizeChunks({ model, chunks, totalChunks, }) {
113
177
  const chunkSummary = await invokeIssueSummarizeTextModel({
114
178
  model,
115
179
  prompt: chunkPrompt,
180
+ phaseName: SUMMARIZE_CHUNK_PHASE,
116
181
  });
117
182
  if (chunkSummary?.trim()) {
118
183
  summaries.push(`### Chunk ${index + 1}\n${chunkSummary.trim()}`);
@@ -121,7 +186,13 @@ export async function summarizeChunks({ model, chunks, totalChunks, }) {
121
186
  missingChunkSummaries += 1;
122
187
  }
123
188
  }
189
+ span.setAttributes({ 'chunks.summarized': summaries.length });
124
190
  if (summaries.length === 0) {
191
+ // No chunk produced a model summary — we fall back to raw chunk content.
192
+ span.setStatus({
193
+ code: SpanStatusCode.ERROR,
194
+ message: 'chunk summarization produced no model output',
195
+ });
125
196
  logger.warn('Chunk summarization returned no model output, using raw chunk content', {
126
197
  selectedChunks: chunks.length,
127
198
  totalChunks,
@@ -1,8 +1,8 @@
1
- import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
1
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
2
2
  const PROMPT_FILE = 'issue-summarize-prompt.md';
3
3
  export function loadPromptTemplate() {
4
4
  return loadPromptTemplateFile({
5
- fileName: PROMPT_FILE,
5
+ fileName: resolvePromptFile(import.meta.url, PROMPT_FILE),
6
6
  promptLabel: 'issue summarize',
7
7
  });
8
8
  }
@@ -1,8 +1,10 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api';
1
2
  import { App } from 'octokit';
2
3
  import { fetchIssue, fetchIssueComments } from '../../core/github-issues.js';
3
4
  import { logger } from '../../core/logging.js';
4
5
  import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
5
6
  import { resolveWriteOctokit } from '../../core/shared.js';
7
+ import { withPhase } from '../../core/tracing.js';
6
8
  import { parseSummarizeContext } from './context.js';
7
9
  import { buildMediaPromptImages, extractMediaReferences, formatMediaContext, inspectMediaReferences, } from './media.js';
8
10
  import { createIssueSummarizeModel, invokeIssueSummarizeModel, summarizeChunks } from './model.js';
@@ -109,108 +111,122 @@ async function main() {
109
111
  repository: `${ctx.owner}/${ctx.repo}`,
110
112
  issueNumber: ctx.issueNumber,
111
113
  });
112
- logger.info('Issue summarize task started', {
113
- dryRun: ctx.dryRun,
114
- issueNumber: ctx.issueNumber,
115
- });
116
- const { readOctokit, writeOctokit } = await createGitHubClients(ctx);
117
- try {
118
- const issueParams = {
119
- owner: ctx.owner,
120
- repo: ctx.repo,
121
- issueNumber: ctx.issueNumber,
122
- mediaType: { format: 'full' },
123
- };
124
- const [issue, comments] = await Promise.all([
125
- fetchIssue(readOctokit, issueParams),
126
- fetchIssueComments(readOctokit, issueParams),
127
- ]);
128
- logger.info('Loaded issue summarize context', {
129
- issueState: issue.state ?? 'unknown',
130
- commentsCount: comments.length,
131
- });
132
- const entries = buildThreadEntries({
133
- issue,
134
- comments,
135
- triggerCommentId: ctx.commentId,
136
- });
137
- logger.info('Prepared issue summarize thread entries', {
138
- issueNumber: issue.number,
139
- entryCount: entries.length,
140
- });
141
- const mediaReferences = extractMediaReferences(entries);
142
- const mediaInspections = await inspectMediaReferences(mediaReferences);
143
- const mediaInspectionStats = summarizeMediaInspectionStats(mediaInspections);
144
- logger.info('Inspected issue summarize media references', {
145
- mediaReferences: mediaReferences.length,
146
- mediaInspections: mediaInspections.length,
147
- mediaFetched: mediaInspectionStats.fetched,
148
- mediaFetchFailed: mediaInspectionStats.fetchFailed,
149
- mediaNotImage: mediaInspectionStats.notMedia,
150
- });
151
- const summary = await summarizeIssue({
152
- ctx,
153
- issue,
154
- entries,
155
- mediaReferences,
156
- mediaInspections,
157
- });
158
- logger.info('Generated issue summary', {
159
- issueNumber: ctx.issueNumber,
160
- summaryChars: summary.length,
161
- });
162
- const summaryComment = `${SUMMARY_MARKER}\n\n${summary}`;
163
- if (ctx.dryRun) {
164
- logger.info('Dry run enabled, printing summarize output instead of posting comment', {
165
- issueNumber: ctx.issueNumber,
166
- });
167
- process.stdout.write(`${summaryComment}\n`);
168
- }
169
- else {
170
- await writeOctokit.rest.issues.createComment({
114
+ let exitCode = 0;
115
+ await withPhase('issue-summarize', {
116
+ task: 'issue-summarize',
117
+ 'gen_ai.operation.name': 'invoke_agent',
118
+ 'gen_ai.agent.name': 'doistbot.issue-summarize',
119
+ 'issue.number': ctx.issueNumber,
120
+ 'comment.id': ctx.commentId,
121
+ dry_run: ctx.dryRun,
122
+ }, async (span) => {
123
+ // Inside the span so auth/setup failures are captured by the root span.
124
+ const { readOctokit, writeOctokit } = await createGitHubClients(ctx);
125
+ try {
126
+ const issueParams = {
171
127
  owner: ctx.owner,
172
128
  repo: ctx.repo,
173
- issue_number: ctx.issueNumber,
174
- body: summaryComment,
175
- });
176
- logger.info('Posted issue summary comment', {
177
129
  issueNumber: ctx.issueNumber,
130
+ mediaType: { format: 'full' },
131
+ };
132
+ const [issue, comments] = await Promise.all([
133
+ fetchIssue(readOctokit, issueParams),
134
+ fetchIssueComments(readOctokit, issueParams),
135
+ ]);
136
+ logger.info('Loaded issue summarize context', {
137
+ issueState: issue.state ?? 'unknown',
138
+ commentsCount: comments.length,
139
+ });
140
+ const entries = buildThreadEntries({
141
+ issue,
142
+ comments,
143
+ triggerCommentId: ctx.commentId,
144
+ });
145
+ logger.info('Prepared issue summarize thread entries', {
146
+ issueNumber: issue.number,
147
+ entryCount: entries.length,
148
+ });
149
+ const mediaReferences = extractMediaReferences(entries);
150
+ const mediaInspections = await inspectMediaReferences(mediaReferences);
151
+ const mediaInspectionStats = summarizeMediaInspectionStats(mediaInspections);
152
+ logger.info('Inspected issue summarize media references', {
178
153
  mediaReferences: mediaReferences.length,
154
+ mediaInspections: mediaInspections.length,
155
+ mediaFetched: mediaInspectionStats.fetched,
156
+ mediaFetchFailed: mediaInspectionStats.fetchFailed,
157
+ mediaNotImage: mediaInspectionStats.notMedia,
179
158
  });
180
- }
181
- }
182
- catch (error) {
183
- logger.error('Issue summarize task failed', {
184
- issueNumber: ctx.issueNumber,
185
- error: error instanceof Error ? error.message : String(error),
186
- });
187
- if (!ctx.dryRun) {
188
- try {
159
+ const summary = await summarizeIssue({
160
+ ctx,
161
+ issue,
162
+ entries,
163
+ mediaReferences,
164
+ mediaInspections,
165
+ });
166
+ logger.info('Generated issue summary', {
167
+ issueNumber: ctx.issueNumber,
168
+ summaryChars: summary.length,
169
+ });
170
+ const summaryComment = `${SUMMARY_MARKER}\n\n${summary}`;
171
+ if (ctx.dryRun) {
172
+ logger.info('Dry run enabled, printing summarize output instead of posting comment', { issueNumber: ctx.issueNumber });
173
+ process.stdout.write(`${summaryComment}\n`);
174
+ }
175
+ else {
189
176
  await writeOctokit.rest.issues.createComment({
190
177
  owner: ctx.owner,
191
178
  repo: ctx.repo,
192
179
  issue_number: ctx.issueNumber,
193
- body: 'Sorry, I encountered an error while summarizing this issue. Please try again or contact the team maintaining the bot.',
180
+ body: summaryComment,
194
181
  });
195
- }
196
- catch (replyError) {
197
- logger.warn('Failed to post summarize error response', {
182
+ logger.info('Posted issue summary comment', {
198
183
  issueNumber: ctx.issueNumber,
199
- error: replyError instanceof Error ? replyError.message : String(replyError),
184
+ mediaReferences: mediaReferences.length,
200
185
  });
201
186
  }
202
187
  }
203
- process.exit(1);
204
- }
205
- finally {
206
- await removeIssueCommentReactionIfPresent({
207
- octokit: writeOctokit,
208
- owner: ctx.owner,
209
- repo: ctx.repo,
210
- commentId: ctx.commentId,
211
- reactionId: ctx.reactionId,
212
- warnMessage: 'Failed to remove summarize reaction',
213
- });
188
+ catch (error) {
189
+ const message = error instanceof Error ? error.message : String(error);
190
+ logger.error('Issue summarize task failed', {
191
+ issueNumber: ctx.issueNumber,
192
+ error: message,
193
+ });
194
+ // Set the span status before the run exits; deferring process.exit
195
+ // until after withPhase lets phase.end flush.
196
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
197
+ if (!ctx.dryRun) {
198
+ try {
199
+ await writeOctokit.rest.issues.createComment({
200
+ owner: ctx.owner,
201
+ repo: ctx.repo,
202
+ issue_number: ctx.issueNumber,
203
+ body: 'Sorry, I encountered an error while summarizing this issue. Please try again or contact the team maintaining the bot.',
204
+ });
205
+ }
206
+ catch (replyError) {
207
+ logger.warn('Failed to post summarize error response', {
208
+ issueNumber: ctx.issueNumber,
209
+ error: replyError instanceof Error
210
+ ? replyError.message
211
+ : String(replyError),
212
+ });
213
+ }
214
+ }
215
+ exitCode = 1;
216
+ }
217
+ finally {
218
+ await removeIssueCommentReactionIfPresent({
219
+ octokit: writeOctokit,
220
+ owner: ctx.owner,
221
+ repo: ctx.repo,
222
+ commentId: ctx.commentId,
223
+ reactionId: ctx.reactionId,
224
+ warnMessage: 'Failed to remove summarize reaction',
225
+ });
226
+ }
227
+ });
228
+ if (exitCode !== 0) {
229
+ process.exit(exitCode);
214
230
  }
215
231
  }
216
232
  if (process.env.VITEST !== 'true') {
@@ -0,0 +1,40 @@
1
+ import { logger } from '../../core/logging.js';
2
+ import { withPhase } from '../../core/tracing.js';
3
+ import { cloneRepository } from './shared.js';
4
+ export async function cloneTriageRepositories(params) {
5
+ return withPhase('triage.clone', { 'repos.count': params.repositories.length }, async (span) => {
6
+ const cloneFn = params.cloneRepositoryImpl ?? cloneRepository;
7
+ const clonedRepositories = [];
8
+ const failedRepositories = [];
9
+ for (const repository of params.repositories) {
10
+ try {
11
+ const cloneResult = await cloneFn({
12
+ app: params.app,
13
+ repository,
14
+ reposDir: params.reposDir,
15
+ fallbackInstallationId: params.fallbackInstallationId,
16
+ });
17
+ clonedRepositories.push(cloneResult);
18
+ }
19
+ catch (err) {
20
+ const message = err instanceof Error ? err.message : String(err);
21
+ // Keep per-repo diagnostics (auth/git errors) visible even
22
+ // when EVERY clone fails and the orchestrator bails early.
23
+ logger.warn('Repository clone failed', {
24
+ repository: repository.fullName,
25
+ error: message,
26
+ });
27
+ failedRepositories.push({
28
+ repository: repository.fullName,
29
+ error: message,
30
+ });
31
+ }
32
+ }
33
+ span.setAttributes({
34
+ 'clone.success_count': clonedRepositories.length,
35
+ 'clone.failed_count': failedRepositories.length,
36
+ 'repos.count': params.repositories.length,
37
+ });
38
+ return { clonedRepositories, failedRepositories };
39
+ });
40
+ }
@@ -1,4 +1,6 @@
1
1
  import { optionalPositiveInt, requiredEnv, requiredPositiveInt } from '../../core/shared.js';
2
+ import { withPhase } from '../../core/tracing.js';
3
+ import { extractLabelNames, fetchIssue, fetchIssueComments, } from './shared.js';
2
4
  function parseOptionalBoolean(value) {
3
5
  const normalized = value?.trim().toLowerCase();
4
6
  if (normalized === 'true')
@@ -28,6 +30,7 @@ export function parseIssueTriageContext(env = process.env) {
28
30
  privateKey: requiredEnv('PRIVATE_KEY', env),
29
31
  githubToken: requiredEnv('GITHUB_TOKEN', env),
30
32
  openaiApiKey: parseOpenAIApiKey(env),
33
+ geminiApiKey: env.GEMINI_API_KEY?.trim() || undefined,
31
34
  commentId,
32
35
  reactionId,
33
36
  placeholderCommentId: optionalPositiveInt(env.PLACEHOLDER_COMMENT_ID),
@@ -35,6 +38,27 @@ export function parseIssueTriageContext(env = process.env) {
35
38
  dryRun: env.DRY_RUN?.toLowerCase() === 'true',
36
39
  triggerType,
37
40
  enableAutoFixOverride: parseOptionalBoolean(env.ENABLE_AUTO_FIX),
38
- openRouterApiKey: env.OPENROUTER_API_KEY?.trim() || undefined,
39
41
  };
40
42
  }
43
+ export async function loadTriageContext(params) {
44
+ return withPhase('triage.context', { 'issue.number': params.issueNumber }, async (span) => {
45
+ const [issue, comments] = await Promise.all([
46
+ fetchIssue(params.octokit, {
47
+ owner: params.owner,
48
+ repo: params.repo,
49
+ issueNumber: params.issueNumber,
50
+ }),
51
+ fetchIssueComments(params.octokit, {
52
+ owner: params.owner,
53
+ repo: params.repo,
54
+ issueNumber: params.issueNumber,
55
+ }),
56
+ ]);
57
+ const labelNames = extractLabelNames(issue.labels);
58
+ span.setAttributes({
59
+ 'comments.count': comments.length,
60
+ 'labels.count': labelNames.length,
61
+ });
62
+ return { issue, comments, labelNames };
63
+ });
64
+ }
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { logger } from '../../core/logging.js';
3
- import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
3
+ import { fillPromptTemplate, loadPromptTemplateFile, resolvePromptFile, } from '../../core/prompt-template.js';
4
4
  import { validateDiff } from './diff-validator.js';
5
5
  import { CODEX_CANNOT_FIX_REASON, FIX_LOOP_FAILED_REASON_PREFIX } from './fix-failure-reasons.js';
6
6
  import { CX_HERO_MANUAL_HANDLE, resolveHeroGroup } from './hero-group-map.js';
@@ -24,7 +24,7 @@ export function buildFixAssessmentSkipReason(assessment) {
24
24
  }
25
25
  function loadFixPromptTemplate() {
26
26
  return loadPromptTemplateFile({
27
- fileName: FIX_PROMPT_FILE,
27
+ fileName: resolvePromptFile(import.meta.url, FIX_PROMPT_FILE),
28
28
  promptLabel: 'issue triage fix',
29
29
  });
30
30
  }
@@ -164,8 +164,20 @@ export function buildFollowUpCommentBody({ prUrl, targetRepoFullName, fixAssessm
164
164
  `The PR targets \`${targetRepoFullName}\`. Please review it carefully before merging.`,
165
165
  ].join('\n');
166
166
  }
167
- function isNonRetryableFailure(reason) {
168
- return reason.includes(CODEX_CANNOT_FIX_REASON);
167
+ // Detect the "## Cannot Fix" verdict and pull Codex's own explanation in one pass,
168
+ // so the failure comment can tell the human *why* it bailed, not just that it did.
169
+ // Returns null when the marker is absent (not a cannot-fix verdict), the explanation
170
+ // string when present, or '' when the model gave the verdict with no explanation.
171
+ // The full tail is intentional — the prompt makes "## Cannot Fix" the terminal output.
172
+ // ponytail: 2000-char cap keeps the comment readable; raise it if reasons get truncated.
173
+ export function extractCannotFixDetail(output) {
174
+ const idx = output.indexOf(CANNOT_FIX_MARKER);
175
+ if (idx === -1)
176
+ return null;
177
+ return output
178
+ .slice(idx + CANNOT_FIX_MARKER.length)
179
+ .trim()
180
+ .slice(0, 2000);
169
181
  }
170
182
  async function findSourceIssueCommentByMarker({ octokit, ctx, marker, }) {
171
183
  const comments = await octokit.paginate(octokit.rest.issues.listComments, {
@@ -187,8 +199,6 @@ export async function shouldPostAutoFixFailureComment(fixResult, { dryRun, octok
187
199
  // a "didn't produce a fix PR" comment would be misleading.
188
200
  if (fixResult.prNumber !== undefined)
189
201
  return false;
190
- if (isNonRetryableFailure(fixResult.reason))
191
- return false;
192
202
  if (fixResult.reason.startsWith(FIX_LOOP_FAILED_REASON_PREFIX)) {
193
203
  try {
194
204
  const hasFixLoopStartComment = await sourceIssueHasFixLoopStartComment({ octokit, ctx });
@@ -231,9 +241,11 @@ export function buildAutoFixFailureCommentBody({ reason, targetRepoFullName, })
231
241
  '',
232
242
  '**Reason:**',
233
243
  '',
234
- '```',
244
+ // Tilde fence, not backticks: the reason often carries Codex's own
245
+ // backtick-fenced output (logs, CLI), which would close a ``` block early.
246
+ '~~~',
235
247
  reason,
236
- '```',
248
+ '~~~',
237
249
  '',
238
250
  'You can retry by commenting `@doistbot /fix`.',
239
251
  ].join('\n');
@@ -365,6 +377,9 @@ export async function runFixCore(input) {
365
377
  ? SPECULATIVE_CODEX_TIMEOUT_MS
366
378
  : AUTO_FIX_CODEX_TIMEOUT_MS;
367
379
  const codexResult = await invokeTriageModel({
380
+ // Distinct phase name: this is fix generation, not triage analysis.
381
+ // Callers (e.g. issue-fix-retry) override to keep it in their namespace.
382
+ phaseName: input.modelPhaseName ?? 'triage.fix-attempt.model',
368
383
  prompt,
369
384
  apiKey: ctx.openaiApiKey,
370
385
  cwd: targetRepo.localPath,
@@ -391,13 +406,19 @@ export async function runFixCore(input) {
391
406
  outputChars: fixDescription.length,
392
407
  });
393
408
  // Check if Codex reported it cannot fix
394
- if (fixDescription.includes(CANNOT_FIX_MARKER)) {
409
+ const cannotFixDetail = extractCannotFixDetail(fixDescription);
410
+ if (cannotFixDetail !== null) {
395
411
  logger.info('Fix Codex reported cannot fix', {
396
412
  task: 'issue-triage',
397
413
  issueNumber: ctx.issueNumber,
398
414
  output: fixDescription.slice(0, 500),
399
415
  });
400
- return { ok: false, reason: CODEX_CANNOT_FIX_REASON };
416
+ return {
417
+ ok: false,
418
+ reason: cannotFixDetail
419
+ ? `${CODEX_CANNOT_FIX_REASON}\n\n${cannotFixDetail}`
420
+ : CODEX_CANNOT_FIX_REASON,
421
+ };
401
422
  }
402
423
  // Capture the diff
403
424
  const diff = captureGitDiff(targetRepo.localPath);
@@ -499,6 +520,7 @@ export async function attemptFix(input) {
499
520
  issueUrl: input.issueUrl,
500
521
  ghToken: input.ghToken,
501
522
  force,
523
+ modelPhaseName: input.modelPhaseName,
502
524
  });
503
525
  if (!coreResult.ok) {
504
526
  return {
@@ -520,6 +542,7 @@ export async function attemptFix(input) {
520
542
  repository: targetRepo.repository.fullName,
521
543
  prCreated: false,
522
544
  reason: 'Dry run — PR not created.',
545
+ dryRun: true,
523
546
  };
524
547
  }
525
548
  let openedPr;