@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
@@ -1,9 +1,11 @@
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';
5
6
  import { removeIssueCommentReactionIfPresent } from '../../core/reactions.js';
6
7
  import { resolveIssueRepositories } from '../../core/repository-resolver.js';
8
+ import { recordDecision, withPhase } from '../../core/tracing.js';
7
9
  import { parseIssueTriageContext } from '../issue-triage/context.js';
8
10
  import { attemptFix } from '../issue-triage/fix-attempt.js';
9
11
  import { parseFixAssessment } from '../issue-triage/output.js';
@@ -19,217 +21,240 @@ async function main() {
19
21
  repository: `${ctx.owner}/${ctx.repo}`,
20
22
  issueNumber: ctx.issueNumber,
21
23
  });
22
- logger.info('Fix retry task started', {
24
+ // Root span for the whole run. The Codex fix attempt is wrapped in its own
25
+ // issue-fix-retry.fix-attempt phase below, keeping its nested model span in
26
+ // this task's namespace rather than triage's.
27
+ await withPhase('issue-fix-retry', {
23
28
  task: 'issue-fix-retry',
24
- issueNumber: ctx.issueNumber,
25
- commentId: ctx.commentId,
26
- });
27
- const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
28
- async function postComment(body) {
29
- if (ctx.dryRun)
30
- return;
31
- await writeOctokit.rest.issues.createComment({
32
- owner: ctx.owner,
33
- repo: ctx.repo,
34
- issue_number: ctx.issueNumber,
35
- body,
36
- });
37
- }
38
- try {
39
- const [issue, comments] = await Promise.all([
40
- fetchIssue(readOctokit, ctx),
41
- fetchIssueComments(readOctokit, ctx),
42
- ]);
43
- // Find the most recent triage comment from the bot (search backwards to get the latest one)
44
- let triageComment;
45
- for (let i = comments.length - 1; i >= 0; i--) {
46
- const comment = comments[i];
47
- if (comment &&
48
- isKnownDoistbotLogin(comment.user?.login) &&
49
- comment.body?.includes(TRIAGE_MARKER)) {
50
- triageComment = comment;
51
- break;
52
- }
53
- }
54
- if (!triageComment?.body) {
55
- logger.warn('No triage comment found for fix retry', {
56
- task: 'issue-fix-retry',
57
- issueNumber: ctx.issueNumber,
29
+ 'gen_ai.operation.name': 'invoke_agent',
30
+ 'gen_ai.agent.name': 'doistbot.issue-fix-retry',
31
+ 'issue.number': ctx.issueNumber,
32
+ 'comment.id': ctx.commentId,
33
+ dry_run: ctx.dryRun,
34
+ }, async (span) => {
35
+ const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
36
+ async function postComment(body) {
37
+ if (ctx.dryRun)
38
+ return;
39
+ await writeOctokit.rest.issues.createComment({
40
+ owner: ctx.owner,
41
+ repo: ctx.repo,
42
+ issue_number: ctx.issueNumber,
43
+ body,
58
44
  });
59
- await postComment('No previous triage found on this issue. Run `@doistbot /triage` first, then retry with `@doistbot /fix`.');
60
- return;
61
45
  }
62
- // Strip the triage marker to get the raw triage output
63
- const triageOutput = triageComment.body.replace(TRIAGE_MARKER, '').trim();
64
- const fixAssessment = parseFixAssessment(triageOutput);
65
- if (!fixAssessment) {
66
- logger.warn('No fix assessment found in triage output', {
46
+ try {
47
+ const [issue, comments] = await Promise.all([
48
+ fetchIssue(readOctokit, ctx),
49
+ fetchIssueComments(readOctokit, ctx),
50
+ ]);
51
+ // Find the most recent triage comment from the bot (search backwards to get the latest one)
52
+ let triageComment;
53
+ for (let i = comments.length - 1; i >= 0; i--) {
54
+ const comment = comments[i];
55
+ if (comment &&
56
+ isKnownDoistbotLogin(comment.user?.login) &&
57
+ comment.body?.includes(TRIAGE_MARKER)) {
58
+ triageComment = comment;
59
+ break;
60
+ }
61
+ }
62
+ if (!triageComment?.body) {
63
+ // User-actionable (no prior triage), not a malfunction → span stays ok.
64
+ logger.warn('No triage comment found for fix retry', {
65
+ task: 'issue-fix-retry',
66
+ issueNumber: ctx.issueNumber,
67
+ });
68
+ await postComment('No previous triage found on this issue. Run `@doistbot /triage` first, then retry with `@doistbot /fix`.');
69
+ return;
70
+ }
71
+ // Strip the triage marker to get the raw triage output
72
+ const triageOutput = triageComment.body.replace(TRIAGE_MARKER, '').trim();
73
+ const fixAssessment = parseFixAssessment(triageOutput);
74
+ if (!fixAssessment) {
75
+ logger.warn('No fix assessment found in triage output', {
76
+ task: 'issue-fix-retry',
77
+ issueNumber: ctx.issueNumber,
78
+ });
79
+ await postComment("The previous triage doesn't include an automated fix assessment. Try running `@doistbot /triage` again on a repo with auto-fix enabled.");
80
+ return;
81
+ }
82
+ if (fixAssessment.targetRepo) {
83
+ metricRepository = fixAssessment.targetRepo;
84
+ }
85
+ logger.info('Fix assessment found from previous triage', {
67
86
  task: 'issue-fix-retry',
68
87
  issueNumber: ctx.issueNumber,
88
+ classification: fixAssessment.classification,
89
+ confidence: fixAssessment.confidence,
90
+ scope: fixAssessment.scope,
69
91
  });
70
- await postComment("The previous triage doesn't include an automated fix assessment. Try running `@doistbot /triage` again on a repo with auto-fix enabled.");
71
- return;
72
- }
73
- if (fixAssessment.targetRepo) {
74
- metricRepository = fixAssessment.targetRepo;
75
- }
76
- logger.info('Fix assessment found from previous triage', {
77
- task: 'issue-fix-retry',
78
- issueNumber: ctx.issueNumber,
79
- classification: fixAssessment.classification,
80
- confidence: fixAssessment.confidence,
81
- scope: fixAssessment.scope,
82
- });
83
- // Resolve repositories to clone. Prefer the target repo from the assessment
84
- // (stable across label changes) and fall back to label-based resolution.
85
- const reposDir = prepareWorkspaceReposDirectory();
86
- const clonedRepositories = [];
87
- if (fixAssessment.targetRepo) {
88
- const resolution = resolveIssueRepositories(extractLabelNames(issue.labels));
89
- const repoTarget = resolution.status === 'resolved'
90
- ? resolution.repositories.find((r) => r.fullName === fixAssessment.targetRepo)
91
- : undefined;
92
- if (repoTarget) {
93
- try {
94
- const cloneResult = await cloneRepository({
95
- app,
96
- repository: repoTarget,
97
- reposDir,
98
- fallbackInstallationId: ctx.installationId,
99
- });
100
- clonedRepositories.push(cloneResult);
101
- metricRepository = cloneResult.repository.fullName;
92
+ // Resolve repositories to clone. Prefer the target repo from the assessment
93
+ // (stable across label changes) and fall back to label-based resolution.
94
+ const reposDir = prepareWorkspaceReposDirectory();
95
+ const clonedRepositories = [];
96
+ if (fixAssessment.targetRepo) {
97
+ const resolution = resolveIssueRepositories(extractLabelNames(issue.labels));
98
+ const repoTarget = resolution.status === 'resolved'
99
+ ? resolution.repositories.find((r) => r.fullName === fixAssessment.targetRepo)
100
+ : undefined;
101
+ if (repoTarget) {
102
+ try {
103
+ const cloneResult = await cloneRepository({
104
+ app,
105
+ repository: repoTarget,
106
+ reposDir,
107
+ fallbackInstallationId: ctx.installationId,
108
+ });
109
+ clonedRepositories.push(cloneResult);
110
+ metricRepository = cloneResult.repository.fullName;
111
+ }
112
+ catch (error) {
113
+ logger.warn('Fix retry clone failed for assessment target repo', {
114
+ task: 'issue-fix-retry',
115
+ repository: fixAssessment.targetRepo,
116
+ error: error instanceof Error ? error.message : String(error),
117
+ });
118
+ }
102
119
  }
103
- catch (error) {
104
- logger.warn('Fix retry clone failed for assessment target repo', {
120
+ }
121
+ // Fall back to label-based resolution if the assessment target wasn't cloned
122
+ if (clonedRepositories.length === 0) {
123
+ const labelNames = extractLabelNames(issue.labels);
124
+ const resolution = resolveIssueRepositories(labelNames);
125
+ if (resolution.status === 'unresolved') {
126
+ // User-actionable (labels don't map to a repo) → span stays ok.
127
+ logger.warn('Fix retry failed: could not resolve repositories from labels', {
105
128
  task: 'issue-fix-retry',
106
- repository: fixAssessment.targetRepo,
107
- error: error instanceof Error ? error.message : String(error),
129
+ issueNumber: ctx.issueNumber,
130
+ reason: resolution.reason,
108
131
  });
132
+ await postComment(`Could not resolve target repositories from the issue labels. Please check the labels and try again.\n\nReason: ${resolution.reason}`);
133
+ return;
134
+ }
135
+ if (resolution.repositories.length === 1) {
136
+ metricRepository = resolution.repositories[0].fullName;
137
+ }
138
+ for (const repository of resolution.repositories) {
139
+ try {
140
+ const cloneResult = await cloneRepository({
141
+ app,
142
+ repository,
143
+ reposDir,
144
+ fallbackInstallationId: ctx.installationId,
145
+ });
146
+ clonedRepositories.push(cloneResult);
147
+ }
148
+ catch (error) {
149
+ logger.warn('Fix retry clone failed', {
150
+ task: 'issue-fix-retry',
151
+ repository: repository.fullName,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ });
154
+ }
109
155
  }
110
156
  }
111
- }
112
- // Fall back to label-based resolution if the assessment target wasn't cloned
113
- if (clonedRepositories.length === 0) {
114
- const labelNames = extractLabelNames(issue.labels);
115
- const resolution = resolveIssueRepositories(labelNames);
116
- if (resolution.status === 'unresolved') {
117
- logger.warn('Fix retry failed: could not resolve repositories from labels', {
157
+ if (clonedRepositories.length === 0) {
158
+ // Infrastructure failure → mark the run failed.
159
+ const message = 'Fix retry failed: all repository clones failed';
160
+ logger.error(message, {
118
161
  task: 'issue-fix-retry',
119
162
  issueNumber: ctx.issueNumber,
120
- reason: resolution.reason,
121
163
  });
122
- await postComment(`Could not resolve target repositories from the issue labels. Please check the labels and try again.\n\nReason: ${resolution.reason}`);
164
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
165
+ await postComment('Fix retry failed — could not clone the target repositories. Please try again later.');
123
166
  return;
124
167
  }
125
- if (resolution.repositories.length === 1) {
126
- metricRepository = resolution.repositories[0].fullName;
127
- }
128
- for (const repository of resolution.repositories) {
129
- try {
130
- const cloneResult = await cloneRepository({
131
- app,
132
- repository,
133
- reposDir,
134
- fallbackInstallationId: ctx.installationId,
135
- });
136
- clonedRepositories.push(cloneResult);
137
- }
138
- catch (error) {
139
- logger.warn('Fix retry clone failed', {
140
- task: 'issue-fix-retry',
141
- repository: repository.fullName,
142
- error: error instanceof Error ? error.message : String(error),
168
+ const issueInstallationAuth = (await app.octokit.auth({
169
+ type: 'installation',
170
+ installationId: ctx.installationId,
171
+ }));
172
+ // Wrap the Codex fix attempt in a retry-namespaced phase; its
173
+ // model span (issue-fix-retry.fix-attempt.model) nests under it.
174
+ const fixResult = await withPhase('issue-fix-retry.fix-attempt', {
175
+ 'gen_ai.operation.name': 'invoke_agent',
176
+ 'gen_ai.agent.name': 'codex.fix',
177
+ }, () => attemptFix({
178
+ ctx,
179
+ octokit: writeOctokit,
180
+ triageOutput,
181
+ fixAssessment,
182
+ clonedRepos: clonedRepositories,
183
+ issueTitle: issue.title ?? 'Untitled issue',
184
+ issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
185
+ ghToken: issueInstallationAuth.token,
186
+ force: true,
187
+ modelPhaseName: 'issue-fix-retry.fix-attempt.model',
188
+ }));
189
+ metricRepository = fixResult.repository;
190
+ metricOutcome = fixResult.attempted && fixResult.prCreated ? 'success' : 'failure';
191
+ const reason = 'reason' in fixResult ? fixResult.reason : undefined;
192
+ const isDryRun = 'dryRun' in fixResult && fixResult.dryRun === true;
193
+ recordDecision('issue-fix-retry.outcome', {
194
+ attempted: fixResult.attempted,
195
+ pr_created: fixResult.attempted && fixResult.prCreated,
196
+ reason,
197
+ pr_url: 'prUrl' in fixResult ? fixResult.prUrl : undefined,
198
+ });
199
+ // No PR is a failed retry — unless it was a dry run (expected).
200
+ if (!fixResult.attempted || !fixResult.prCreated) {
201
+ if (!isDryRun) {
202
+ span.setStatus({
203
+ code: SpanStatusCode.ERROR,
204
+ message: reason ?? 'Fix retry produced no PR',
143
205
  });
144
206
  }
207
+ await postComment(`Fix retry failed to produce a PR.\n\nReason: ${reason ?? 'Unknown reason'}`);
145
208
  }
146
209
  }
147
- if (clonedRepositories.length === 0) {
148
- logger.error('Fix retry failed: all repository clones failed', {
210
+ catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ logger.error('Fix retry task failed', {
149
213
  task: 'issue-fix-retry',
150
214
  issueNumber: ctx.issueNumber,
215
+ error: message,
216
+ durationMs: performance.now() - startedAt,
151
217
  });
152
- await postComment('Fix retry failed — could not clone the target repositories. Please try again later.');
153
- return;
154
- }
155
- const issueInstallationAuth = (await app.octokit.auth({
156
- type: 'installation',
157
- installationId: ctx.installationId,
158
- }));
159
- const fixResult = await attemptFix({
160
- ctx,
161
- octokit: writeOctokit,
162
- triageOutput,
163
- fixAssessment,
164
- clonedRepos: clonedRepositories,
165
- issueTitle: issue.title ?? 'Untitled issue',
166
- issueUrl: issue.html_url ?? getIssueUrl(ctx.owner, ctx.repo, ctx.issueNumber),
167
- ghToken: issueInstallationAuth.token,
168
- force: true,
169
- });
170
- logger.info('Fix retry result', {
171
- task: 'issue-fix-retry',
172
- issueNumber: ctx.issueNumber,
173
- attempted: fixResult.attempted,
174
- prCreated: fixResult.attempted && fixResult.prCreated,
175
- reason: 'reason' in fixResult ? fixResult.reason : undefined,
176
- prUrl: 'prUrl' in fixResult ? fixResult.prUrl : undefined,
177
- durationMs: performance.now() - startedAt,
178
- });
179
- metricRepository = fixResult.repository;
180
- metricOutcome = fixResult.attempted && fixResult.prCreated ? 'success' : 'failure';
181
- // Inform the user if no PR was created
182
- if (!fixResult.attempted || !fixResult.prCreated) {
183
- const reason = 'reason' in fixResult ? fixResult.reason : 'Unknown reason';
184
- await postComment(`Fix retry failed to produce a PR.\n\nReason: ${reason}`);
185
- }
186
- }
187
- catch (error) {
188
- logger.error('Fix retry task failed', {
189
- task: 'issue-fix-retry',
190
- issueNumber: ctx.issueNumber,
191
- error: error instanceof Error ? error.message : String(error),
192
- durationMs: performance.now() - startedAt,
193
- });
194
- try {
195
- await postComment('Sorry, the fix retry encountered an error. Please try again or contact the team maintaining the bot.');
218
+ // The catch swallows the error (posts a user message), so set the
219
+ // span status explicitly — withPhase only auto-marks ERROR on rethrow.
220
+ span.setStatus({ code: SpanStatusCode.ERROR, message });
221
+ try {
222
+ await postComment('Sorry, the fix retry encountered an error. Please try again or contact the team maintaining the bot.');
223
+ }
224
+ catch (commentError) {
225
+ logger.warn('Failed to post fix retry failure response', {
226
+ task: 'issue-fix-retry',
227
+ issueNumber: ctx.issueNumber,
228
+ error: commentError instanceof Error
229
+ ? commentError.message
230
+ : String(commentError),
231
+ });
232
+ }
233
+ exitCode = 1;
196
234
  }
197
- catch (commentError) {
198
- logger.warn('Failed to post fix retry failure response', {
199
- task: 'issue-fix-retry',
200
- issueNumber: ctx.issueNumber,
201
- error: commentError instanceof Error ? commentError.message : String(commentError),
235
+ finally {
236
+ await removeIssueCommentReactionIfPresent({
237
+ octokit: writeOctokit,
238
+ owner: ctx.owner,
239
+ repo: ctx.repo,
240
+ commentId: ctx.commentId,
241
+ reactionId: ctx.reactionId,
242
+ warnMessage: 'Failed to remove fix retry reaction',
243
+ successMessage: 'Fix retry reaction removed',
244
+ logMeta: {
245
+ task: 'issue-fix-retry',
246
+ reactionId: ctx.reactionId,
247
+ },
248
+ });
249
+ await recordTaskMetrics({
250
+ type: 'fix',
251
+ repository: metricRepository,
252
+ model: 'openai',
253
+ outcome: metricOutcome,
254
+ durationMs: performance.now() - startedAt,
202
255
  });
203
256
  }
204
- exitCode = 1;
205
- }
206
- finally {
207
- await removeIssueCommentReactionIfPresent({
208
- octokit: writeOctokit,
209
- owner: ctx.owner,
210
- repo: ctx.repo,
211
- commentId: ctx.commentId,
212
- reactionId: ctx.reactionId,
213
- warnMessage: 'Failed to remove fix retry reaction',
214
- successMessage: 'Fix retry reaction removed',
215
- logMeta: {
216
- task: 'issue-fix-retry',
217
- reactionId: ctx.reactionId,
218
- },
219
- });
220
- await recordTaskMetrics({
221
- type: 'fix',
222
- repository: metricRepository,
223
- model: 'openai',
224
- outcome: metricOutcome,
225
- durationMs: performance.now() - startedAt,
226
- });
227
- logger.info('Fix retry task finalized', {
228
- task: 'issue-fix-retry',
229
- issueNumber: ctx.issueNumber,
230
- durationMs: performance.now() - startedAt,
231
- });
232
- }
257
+ });
233
258
  if (exitCode !== 0) {
234
259
  process.exit(exitCode);
235
260
  }
@@ -4,7 +4,7 @@ const ISSUE_SUMMARIZE_TIMEOUT_MS = 5 * 60 * 1000;
4
4
  const ISSUE_SUMMARIZE_MULTIMODAL_MODEL = 'google/gemini-2.5-pro';
5
5
  export function createIssueSummarizeModel(apiKey) {
6
6
  return createPiInvoker({
7
- provider: 'gemini',
7
+ provider: 'zai',
8
8
  apiKey,
9
9
  piProviderId: 'openrouter',
10
10
  cwd: process.cwd(),
@@ -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
+ }
@@ -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;