@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.
- package/dist/actions/auth.js +78 -24
- package/dist/actions/doctor.js +14 -7
- package/dist/actions/review.js +67 -10
- package/dist/auth.js +51 -6
- package/dist/config.js +77 -5
- package/dist/env.js +1 -0
- package/dist/runtime.js +1 -1
- package/dist/terminal.js +2 -0
- package/package.json +4 -4
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +8 -20
- package/sandbox/dist/core/check-run.js +16 -0
- package/sandbox/dist/core/datadog-metrics.js +6 -52
- package/sandbox/dist/core/logger-span-processor.js +78 -0
- package/sandbox/dist/core/pi.js +285 -174
- package/sandbox/dist/core/prompt-template.js +45 -3
- package/sandbox/dist/core/repository-map.js +24 -0
- package/sandbox/dist/core/tracing-setup.js +15 -0
- package/sandbox/dist/core/tracing.js +80 -0
- package/sandbox/dist/main.js +4 -1
- package/sandbox/dist/providers/credentials.js +12 -2
- package/sandbox/dist/providers/gemini.js +5 -1
- package/sandbox/dist/providers/helpers.js +63 -17
- package/sandbox/dist/providers/index.js +4 -0
- package/sandbox/dist/providers/openrouter-pi.js +25 -0
- package/sandbox/dist/providers/pi-review.js +19 -2
- package/sandbox/dist/tasks/chat/chat.js +173 -102
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
- package/sandbox/dist/tasks/issue-summarize/model.js +120 -49
- package/sandbox/dist/tasks/issue-summarize/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-summarize/summarize.js +104 -88
- package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
- package/sandbox/dist/tasks/issue-triage/context.js +25 -1
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +33 -10
- package/sandbox/dist/tasks/issue-triage/fix-dispatch.js +154 -101
- package/sandbox/dist/tasks/issue-triage/fix-loop/runners.js +15 -6
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
- package/sandbox/dist/tasks/issue-triage/model.js +120 -36
- package/sandbox/dist/tasks/issue-triage/output.js +27 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +5 -4
- package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-triage/resolution.js +26 -0
- package/sandbox/dist/tasks/issue-triage/routing.js +9 -9
- package/sandbox/dist/tasks/issue-triage/side-effects.js +9 -0
- package/sandbox/dist/tasks/issue-triage/triage.js +290 -420
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
- package/sandbox/dist/tasks/review/check-run.js +2 -1
- package/sandbox/dist/tasks/review/conversation-context.js +2 -1
- package/sandbox/dist/tasks/review/engines/dedupe.js +110 -44
- package/sandbox/dist/tasks/review/engines/multi-focus.js +254 -113
- package/sandbox/dist/tasks/review/engines/shared.js +43 -6
- package/sandbox/dist/tasks/review/github-comment-format.js +72 -10
- package/sandbox/dist/tasks/review/local.js +7 -4
- package/sandbox/dist/tasks/review/review-summary.js +48 -7
- package/sandbox/dist/tasks/review/review.js +267 -186
- package/sandbox/dist/tasks/review/runner.js +33 -7
- package/sandbox/dist/tasks/review/summary-model.js +189 -20
- package/sandbox/dist/tasks/review/thinking-level.js +4 -0
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +4 -8
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +8 -20
- 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: '
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
114
|
+
return null;
|
|
70
115
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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:
|
|
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
|
-
|
|
184
|
+
mediaReferences: mediaReferences.length,
|
|
200
185
|
});
|
|
201
186
|
}
|
|
202
187
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|