@doist/doistbot-cli 1.0.4 → 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.
- package/dist/actions/auth.js +62 -28
- package/dist/actions/doctor.js +16 -11
- package/dist/actions/review.js +77 -11
- package/dist/auth.js +53 -8
- package/dist/config.js +40 -6
- package/dist/env.js +1 -1
- package/dist/file-store.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/terminal.js +2 -7
- package/package.json +7 -7
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +9 -85
- package/sandbox/dist/core/bot-logins.js +0 -13
- package/sandbox/dist/core/check-run.js +16 -0
- package/sandbox/dist/core/datadog-metrics.js +6 -54
- package/sandbox/dist/core/logger-span-processor.js +78 -0
- package/sandbox/dist/core/pi.js +270 -186
- package/sandbox/dist/core/tracing-setup.js +15 -0
- package/sandbox/dist/core/tracing.js +71 -0
- package/sandbox/dist/main.js +4 -1
- package/sandbox/dist/providers/config.js +1 -1
- package/sandbox/dist/providers/credentials.js +40 -0
- package/sandbox/dist/providers/gemini.js +8 -10
- package/sandbox/dist/providers/helpers.js +18 -38
- package/sandbox/dist/providers/index.js +4 -3
- package/sandbox/dist/providers/openai.js +1 -8
- package/sandbox/dist/providers/openrouter-pi.js +25 -0
- package/sandbox/dist/providers/pi-review.js +28 -55
- package/sandbox/dist/providers/schemas.js +0 -39
- package/sandbox/dist/tasks/chat/chat.js +162 -101
- package/sandbox/dist/tasks/issue-fix-retry/fix-retry.js +210 -185
- package/sandbox/dist/tasks/issue-summarize/context.js +1 -1
- package/sandbox/dist/tasks/issue-summarize/model.js +4 -2
- package/sandbox/dist/tasks/issue-summarize/summarize.js +1 -1
- package/sandbox/dist/tasks/issue-triage/clone.js +40 -0
- package/sandbox/dist/tasks/issue-triage/context.js +25 -2
- package/sandbox/dist/tasks/issue-triage/fix-attempt.js +31 -8
- 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/model.js +120 -36
- package/sandbox/dist/tasks/issue-triage/output.js +27 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +3 -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 +288 -418
- package/sandbox/dist/tasks/persist-logs/dd-proxy.js +2 -2
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +1 -1
- package/sandbox/dist/tasks/persist-logs/persist-logs.js +1 -1
- package/sandbox/dist/tasks/review/check-run.js +2 -1
- package/sandbox/dist/tasks/review/conversation-context.js +15 -8
- package/sandbox/dist/tasks/review/engines/dedupe.js +19 -9
- package/sandbox/dist/tasks/review/engines/multi-focus.js +62 -36
- package/sandbox/dist/tasks/review/engines/shared.js +15 -61
- package/sandbox/dist/tasks/review/github-comment-format.js +64 -10
- package/sandbox/dist/tasks/review/local.js +2 -29
- package/sandbox/dist/tasks/review/prompt.js +2 -49
- package/sandbox/dist/tasks/review/review-summary.js +120 -0
- package/sandbox/dist/tasks/review/review.js +49 -69
- package/sandbox/dist/tasks/review/runner.js +57 -52
- package/sandbox/dist/tasks/review/summary-model.js +172 -16
- package/sandbox/dist/tasks/review/thinking-level.js +4 -2
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +5 -23
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +9 -85
- package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
- package/sandbox/_node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
- package/sandbox/dist/providers/anthropic.js +0 -30
- package/sandbox/dist/tasks/review/author-format.js +0 -14
- package/sandbox/dist/tasks/review/author-profile.js +0 -67
- package/sandbox/dist/tasks/review/council/normalizer.js +0 -104
- package/sandbox/dist/tasks/review/council/orchestrator.js +0 -194
- package/sandbox/dist/tasks/review/council/provider-health.js +0 -31
- package/sandbox/dist/tasks/review/council/summary.js +0 -192
- package/sandbox/dist/tasks/review/council/types.js +0 -4
- package/sandbox/dist/tasks/review/council/voter.js +0 -384
- package/sandbox/dist/tasks/review/engines/council.js +0 -126
- package/sandbox/dist/tasks/review/engines/index.js +0 -12
- package/sandbox/dist/tasks/review/review-engine.js +0 -5
- package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.d.ts +0 -11
- package/sandbox/node_modules/doistbot-repo-config/dist/review-engine.js +0 -41
- package/sandbox/review-prompt.md +0 -209
- package/sandbox/vote-prompt.md +0 -69
|
@@ -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;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
1
2
|
import { logger } from '../../core/logging.js';
|
|
3
|
+
import { withPhase } from '../../core/tracing.js';
|
|
2
4
|
import { DefaultLoopEffects } from './fix-loop/effects.js';
|
|
3
5
|
import { runFixLoop } from './fix-loop/runners.js';
|
|
4
6
|
import { attemptFix, buildFixAssessmentSkipReason, shouldAttemptFix } from './fix-attempt.js';
|
|
@@ -79,112 +81,163 @@ function prUrlFor(targetRepo, prNumber) {
|
|
|
79
81
|
return `https://github.com/${targetRepo.repository.fullName}/pull/${prNumber}`;
|
|
80
82
|
}
|
|
81
83
|
export async function runConfiguredFix(input) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
attempted
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
84
|
+
return withPhase('triage.fix-attempt', { 'gen_ai.operation.name': 'invoke_agent', 'gen_ai.agent.name': 'codex.fix' }, async (span) => {
|
|
85
|
+
function finish(result) {
|
|
86
|
+
const prCreated = 'prCreated' in result ? result.prCreated : false;
|
|
87
|
+
// Only a real PR number counts as PR-evidence — dry-run loop
|
|
88
|
+
// states can carry a placeholder prNumber of 0.
|
|
89
|
+
const prNumber = 'prNumber' in result && result.prNumber !== undefined && result.prNumber > 0
|
|
90
|
+
? result.prNumber
|
|
91
|
+
: undefined;
|
|
92
|
+
// Dry-run skips are expected outcomes, not failures — signaled
|
|
93
|
+
// by the stable dryRun flag on the result, never by parsing
|
|
94
|
+
// the human-readable reason copy.
|
|
95
|
+
const isDryRunSkip = 'dryRun' in result && result.dryRun === true;
|
|
96
|
+
let outcome;
|
|
97
|
+
if (prCreated) {
|
|
98
|
+
outcome = 'pr-created';
|
|
99
|
+
}
|
|
100
|
+
else if (isDryRunSkip) {
|
|
101
|
+
outcome = 'dry-run';
|
|
102
|
+
}
|
|
103
|
+
else if (prNumber !== undefined) {
|
|
104
|
+
// The loop opened a PR earlier (REVIEWING/ITERATING) but
|
|
105
|
+
// ended needing a human: a PR exists, the autonomous
|
|
106
|
+
// attempt did not finish.
|
|
107
|
+
outcome = 'pr-opened-needs-human';
|
|
108
|
+
}
|
|
109
|
+
else if (result.attempted) {
|
|
110
|
+
outcome = 'attempted-no-pr';
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
outcome = 'not-attempted';
|
|
114
|
+
}
|
|
115
|
+
span.setAttributes({
|
|
116
|
+
'fix.attempted': result.attempted,
|
|
117
|
+
'fix.repository': result.repository,
|
|
118
|
+
'fix.pr_created': prCreated,
|
|
119
|
+
...(prNumber !== undefined ? { 'fix.pr_number': prNumber } : {}),
|
|
120
|
+
'fix.outcome': outcome,
|
|
121
|
+
});
|
|
122
|
+
// A real attempt that did not deliver a ready PR is a failure
|
|
123
|
+
// (including pr-opened-needs-human — someone must take over);
|
|
124
|
+
// a legitimate skip or a dry-run is not. withPhase does not
|
|
125
|
+
// force OK on a normal return, so this ERROR survives to
|
|
126
|
+
// phase.end.
|
|
127
|
+
if (result.attempted && !prCreated && !isDryRunSkip && 'reason' in result) {
|
|
128
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: result.reason });
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
const target = resolveFixTarget(input);
|
|
133
|
+
if (!target.ok) {
|
|
134
|
+
return finish({
|
|
135
|
+
attempted: false,
|
|
136
|
+
repository: target.repository,
|
|
137
|
+
reason: target.reason,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const targetRepo = target.targetRepo;
|
|
141
|
+
let repoConfig;
|
|
142
|
+
try {
|
|
143
|
+
repoConfig = await input.loadRepoConfig(targetRepo.repository);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
logger.warn('Fix loop config load failed', {
|
|
147
|
+
task: 'fix-loop',
|
|
148
|
+
repository: targetRepo.repository.fullName,
|
|
149
|
+
error: errorReason(error),
|
|
150
|
+
});
|
|
151
|
+
return finish({
|
|
152
|
+
attempted: true,
|
|
153
|
+
repository: targetRepo.repository.fullName,
|
|
154
|
+
prCreated: false,
|
|
155
|
+
reason: `Fix loop config load failed: ${errorReason(error)}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const autoFixMode = getAutoFixMode(repoConfig, input.ctx.enableAutoFixOverride);
|
|
159
|
+
if (autoFixMode === 'disabled') {
|
|
160
|
+
return finish({
|
|
161
|
+
attempted: false,
|
|
162
|
+
repository: targetRepo.repository.fullName,
|
|
163
|
+
reason: 'Auto fix disabled by repository config.',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (autoFixMode === 'one-pass') {
|
|
167
|
+
return finish(await attemptFix(toAttemptFixInput(input)));
|
|
168
|
+
}
|
|
169
|
+
if (!shouldAttemptFix(input.fixAssessment)) {
|
|
170
|
+
return finish({
|
|
171
|
+
attempted: false,
|
|
172
|
+
repository: targetRepo.repository.fullName,
|
|
173
|
+
reason: buildFixAssessmentSkipReason(input.fixAssessment),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const session = {
|
|
177
|
+
kind: 'fix-loop',
|
|
178
|
+
ctx: input.ctx,
|
|
179
|
+
octokit: input.octokit,
|
|
180
|
+
triageOutput: input.triageOutput,
|
|
181
|
+
fixAssessment: input.fixAssessment,
|
|
182
|
+
targetRepo,
|
|
183
|
+
issueTitle: input.issueTitle,
|
|
184
|
+
issueUrl: input.issueUrl,
|
|
185
|
+
ghToken: input.ghToken,
|
|
186
|
+
pushToken: input.ctx.githubToken,
|
|
187
|
+
apiKeys: {
|
|
188
|
+
openai: input.ctx.openaiApiKey,
|
|
189
|
+
...(input.ctx.geminiApiKey !== undefined && { gemini: input.ctx.geminiApiKey }),
|
|
190
|
+
},
|
|
154
191
|
};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
192
|
+
let finalState;
|
|
193
|
+
try {
|
|
194
|
+
finalState = await runFixLoop({
|
|
195
|
+
effects: new DefaultLoopEffects(session, { dryRun: input.ctx.dryRun }),
|
|
196
|
+
...(input.ctx.dryRun ? { onTransition: logDryRunTransition } : {}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return finish({
|
|
159
201
|
attempted: true,
|
|
160
202
|
repository: targetRepo.repository.fullName,
|
|
161
203
|
prCreated: false,
|
|
162
|
-
reason:
|
|
163
|
-
};
|
|
204
|
+
reason: `${FIX_LOOP_FAILED_REASON_PREFIX} ${errorReason(error)}`,
|
|
205
|
+
});
|
|
164
206
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
207
|
+
if (finalState.kind === 'PUSHED_AWAITING_CI') {
|
|
208
|
+
if (input.ctx.dryRun) {
|
|
209
|
+
return finish({
|
|
210
|
+
attempted: true,
|
|
211
|
+
repository: targetRepo.repository.fullName,
|
|
212
|
+
prCreated: false,
|
|
213
|
+
reason: 'Dry run: PR not created.',
|
|
214
|
+
dryRun: true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const prNumber = finalState.prNumber;
|
|
218
|
+
const prUrl = prUrlFor(targetRepo, prNumber);
|
|
219
|
+
return finish({
|
|
220
|
+
attempted: true,
|
|
221
|
+
repository: targetRepo.repository.fullName,
|
|
222
|
+
prCreated: true,
|
|
223
|
+
prUrl,
|
|
224
|
+
prNumber,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
const reason = finalState.kind === 'DONE_NEEDS_HUMAN'
|
|
228
|
+
? finalState.reason
|
|
229
|
+
: `Fix loop ended in ${finalState.kind}`;
|
|
230
|
+
return finish({
|
|
168
231
|
attempted: true,
|
|
169
232
|
repository: targetRepo.repository.fullName,
|
|
170
|
-
prCreated:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
repository: targetRepo.repository.fullName,
|
|
181
|
-
prCreated: false,
|
|
182
|
-
reason,
|
|
183
|
-
// If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
|
|
184
|
-
// before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
|
|
185
|
-
// silent failure from an in-progress PR.
|
|
186
|
-
...('prNumber' in finalState && finalState.prNumber !== undefined
|
|
187
|
-
? { prNumber: finalState.prNumber }
|
|
188
|
-
: {}),
|
|
189
|
-
};
|
|
233
|
+
prCreated: false,
|
|
234
|
+
reason,
|
|
235
|
+
// If a PR was opened earlier in the loop (REVIEWING/ITERATING reached
|
|
236
|
+
// before DONE_NEEDS_HUMAN), surface it so callers can distinguish a
|
|
237
|
+
// silent failure from an in-progress PR.
|
|
238
|
+
...('prNumber' in finalState && finalState.prNumber !== undefined
|
|
239
|
+
? { prNumber: finalState.prNumber }
|
|
240
|
+
: {}),
|
|
241
|
+
});
|
|
242
|
+
});
|
|
190
243
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { iterationLabel, PROMOTION_COMMENT_MARKER } from 'doistbot-shared-contracts';
|
|
1
2
|
import { logger } from '../../../core/logging.js';
|
|
2
3
|
import { isFixLoopTerminal, isTerminal, next, } from './state-machine.js';
|
|
3
4
|
const CI_FAILED_LABEL = 'doistbot:ci-failed';
|
|
@@ -6,10 +7,13 @@ const AWAITING_CI_LABEL = 'doistbot:awaiting-ci';
|
|
|
6
7
|
// Stamped on terminal comments so postIssueComment can find-or-update an
|
|
7
8
|
// existing comment instead of double-posting when an earlier dispatch
|
|
8
9
|
// succeeded on GitHub but the response timed out.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
//
|
|
11
|
+
// The success and human-escalation paths use DISTINCT markers: merge tracking
|
|
12
|
+
// reads 👍/👎 reactions on the promotion (success) comment to derive the thumbs
|
|
13
|
+
// metric, so it must be uniquely identifiable. A shared marker would let a
|
|
14
|
+
// CI-failed-then-human-merged bot PR expose its failure comment as the reaction
|
|
15
|
+
// target instead.
|
|
16
|
+
const PROMOTION_NEEDS_HUMAN_COMMENT_MARKER = '<!--doistbot-promotion-needs-human-comment-->';
|
|
13
17
|
function formatFailedChecks(checks) {
|
|
14
18
|
return checks.map((c) => (c.url ? `- [${c.name}](${c.url})` : `- ${c.name}`)).join('\n');
|
|
15
19
|
}
|
|
@@ -176,7 +180,12 @@ export async function runPromotionFlow(effects, input) {
|
|
|
176
180
|
await effects.postIssueComment({
|
|
177
181
|
prNumber: input.prNumber,
|
|
178
182
|
marker: PROMOTION_COMMENT_MARKER,
|
|
179
|
-
|
|
183
|
+
// This comment doubles as the thumbs-feedback prompt: merge
|
|
184
|
+
// tracking reads 👍/👎 reactions on this exact comment (located
|
|
185
|
+
// via the marker above) when the PR merges. Keep the reaction
|
|
186
|
+
// ask on the comment people are invited to react to.
|
|
187
|
+
body: 'CI passed — promoting this PR out of draft and requesting hero review.\n\n' +
|
|
188
|
+
'<sub>🤖🫵 Please, react 👍 or 👎 on this comment to rate this bot-authored PR.</sub>',
|
|
180
189
|
});
|
|
181
190
|
await effects.promoteToReady(input.prNumber);
|
|
182
191
|
return state;
|
|
@@ -193,7 +202,7 @@ export async function runPromotionFlow(effects, input) {
|
|
|
193
202
|
: 'This PR needs human review.';
|
|
194
203
|
await effects.postIssueComment({
|
|
195
204
|
prNumber: input.prNumber,
|
|
196
|
-
marker:
|
|
205
|
+
marker: PROMOTION_NEEDS_HUMAN_COMMENT_MARKER,
|
|
197
206
|
body,
|
|
198
207
|
});
|
|
199
208
|
return state;
|
|
@@ -1,39 +1,123 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
|
+
import { DEFAULT_PI_MODELS, invokePiPrompt } from '../../core/pi.js';
|
|
3
|
+
import { recordEvent, startToolSpan, withPhase } from '../../core/tracing.js';
|
|
2
4
|
const ISSUE_TRIAGE_TIMEOUT_MS = 12 * 60 * 1000;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
const TRIAGE_PROVIDER = 'openai';
|
|
6
|
+
export async function invokeTriageModel({ prompt, apiKey, cwd, timeoutMs = ISSUE_TRIAGE_TIMEOUT_MS, extraEnv, thinkingLevel, modelName = DEFAULT_PI_MODELS[TRIAGE_PROVIDER],
|
|
7
|
+
// Callers on other paths (e.g. fix generation) pass their own phase name
|
|
8
|
+
// so model telemetry stays separable per flow.
|
|
9
|
+
phaseName = 'triage.model', }) {
|
|
10
|
+
return withPhase(phaseName, {
|
|
11
|
+
'gen_ai.operation.name': 'chat',
|
|
12
|
+
'gen_ai.provider.name': TRIAGE_PROVIDER,
|
|
13
|
+
'gen_ai.request.model': modelName,
|
|
14
|
+
}, async (span) => {
|
|
15
|
+
// pi emits tool start/end across separate onProgress callbacks, so
|
|
16
|
+
// tool spans are managed manually (the documented exception to
|
|
17
|
+
// "withPhase everywhere"). startToolSpan captures context.active()
|
|
18
|
+
// at call time, parenting each tool span under THIS model span.
|
|
19
|
+
const inFlightTools = new Map();
|
|
20
|
+
function toolKey(event) {
|
|
21
|
+
return event.toolCallId ?? event.toolName ?? '';
|
|
22
|
+
}
|
|
23
|
+
function onProgress(event) {
|
|
24
|
+
switch (event.type) {
|
|
25
|
+
case 'tool_start': {
|
|
26
|
+
const toolCallId = event.toolCallId;
|
|
27
|
+
const key = toolKey(event);
|
|
28
|
+
// Overlapping same-name tools without a toolCallId
|
|
29
|
+
// collide on the key; close the displaced span so it
|
|
30
|
+
// is never leaked un-ended.
|
|
31
|
+
const displaced = inFlightTools.get(key);
|
|
32
|
+
if (displaced) {
|
|
33
|
+
displaced.setStatus({
|
|
34
|
+
code: SpanStatusCode.ERROR,
|
|
35
|
+
message: 'replaced by overlapping same-name tool without toolCallId',
|
|
36
|
+
});
|
|
37
|
+
displaced.end();
|
|
38
|
+
}
|
|
39
|
+
const toolSpan = startToolSpan(event.toolName, {
|
|
40
|
+
'gen_ai.operation.name': 'execute_tool',
|
|
41
|
+
'gen_ai.tool.name': event.toolName,
|
|
42
|
+
...(toolCallId ? { 'gen_ai.tool.call.id': toolCallId } : {}),
|
|
43
|
+
});
|
|
44
|
+
inFlightTools.set(key, toolSpan);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
case 'tool_end': {
|
|
48
|
+
const key = toolKey(event);
|
|
49
|
+
const toolSpan = inFlightTools.get(key);
|
|
50
|
+
if (toolSpan) {
|
|
51
|
+
if (event.isError) {
|
|
52
|
+
toolSpan.setStatus({
|
|
53
|
+
code: SpanStatusCode.ERROR,
|
|
54
|
+
message: 'tool reported error',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
toolSpan.end();
|
|
58
|
+
inFlightTools.delete(key);
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
case 'thinking':
|
|
63
|
+
case 'responding': {
|
|
64
|
+
// Liveness signal for long model phases: pi already
|
|
65
|
+
// throttles these to one per 30s. Emitted as point-in-time
|
|
66
|
+
// events (correlated to this model span), NOT spans —
|
|
67
|
+
// they are heartbeats, not boundaries.
|
|
68
|
+
recordEvent('model.heartbeat', { state: event.type });
|
|
69
|
+
}
|
|
70
|
+
// agent_start/agent_end duplicate this phase span's own
|
|
71
|
+
// boundaries and tool_update is covered by the open tool
|
|
72
|
+
// span — intentionally no emission for those.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const startedAt = Date.now();
|
|
76
|
+
const result = await invokePiPrompt({
|
|
77
|
+
provider: TRIAGE_PROVIDER,
|
|
78
|
+
apiKey,
|
|
79
|
+
prompt,
|
|
80
|
+
cwd,
|
|
81
|
+
timeoutMs,
|
|
82
|
+
extraEnv,
|
|
83
|
+
capabilityPreset: 'shell-focused',
|
|
84
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
85
|
+
...(modelName ? { modelName } : {}),
|
|
86
|
+
onProgress,
|
|
87
|
+
});
|
|
88
|
+
// Force-end any tool spans pi never closed (defensive — pi's stream
|
|
89
|
+
// is ordered, but if a tool_end was lost we don't want hanging spans).
|
|
90
|
+
for (const [, toolSpan] of inFlightTools) {
|
|
91
|
+
toolSpan.setStatus({
|
|
92
|
+
code: SpanStatusCode.ERROR,
|
|
93
|
+
message: 'tool span did not receive matching end',
|
|
94
|
+
});
|
|
95
|
+
toolSpan.end();
|
|
96
|
+
}
|
|
97
|
+
inFlightTools.clear();
|
|
98
|
+
const durationMs = Date.now() - startedAt;
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
// Mark the model phase as failed. withPhase does NOT force OK on
|
|
101
|
+
// success, so this ERROR status survives to the phase.end line.
|
|
102
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
error: result.error,
|
|
106
|
+
durationMs,
|
|
107
|
+
timedOut: result.timedOut,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const output = result.output.trim();
|
|
111
|
+
if (!output) {
|
|
112
|
+
const message = 'Pi triage model did not produce an assistant response';
|
|
113
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: message,
|
|
117
|
+
durationMs,
|
|
118
|
+
timedOut: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, output, durationMs };
|
|
15
122
|
});
|
|
16
|
-
const durationMs = Date.now() - startedAt;
|
|
17
|
-
if (!result.ok) {
|
|
18
|
-
return {
|
|
19
|
-
ok: false,
|
|
20
|
-
error: result.error,
|
|
21
|
-
durationMs,
|
|
22
|
-
timedOut: result.timedOut,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
const output = result.output.trim();
|
|
26
|
-
if (!output) {
|
|
27
|
-
return {
|
|
28
|
-
ok: false,
|
|
29
|
-
error: 'Pi triage model did not produce an assistant response',
|
|
30
|
-
durationMs,
|
|
31
|
-
timedOut: false,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
return {
|
|
35
|
-
ok: true,
|
|
36
|
-
output,
|
|
37
|
-
durationMs,
|
|
38
|
-
};
|
|
39
123
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
1
2
|
import { fillPromptTemplate } from '../../core/prompt-template.js';
|
|
3
|
+
import { withPhase } from '../../core/tracing.js';
|
|
2
4
|
const REQUIRED_SECTIONS = [
|
|
3
5
|
'Summary',
|
|
4
6
|
'Findings',
|
|
@@ -121,3 +123,28 @@ export function parseFixAssessment(triageOutput) {
|
|
|
121
123
|
};
|
|
122
124
|
}
|
|
123
125
|
export { REQUIRED_HEADINGS };
|
|
126
|
+
export function validateTriageOutput(params) {
|
|
127
|
+
return withPhase('triage.output', { 'output.length': params.rawOutput.length }, async (span) => {
|
|
128
|
+
const normalized = ensureStructuredTriageOutput(params.rawOutput);
|
|
129
|
+
span.setAttributes({
|
|
130
|
+
'output.missing_sections.count': normalized.missingSections.length,
|
|
131
|
+
'output.quality_check.passed': normalized.missingSections.length === 0,
|
|
132
|
+
});
|
|
133
|
+
if (normalized.missingSections.length > 0) {
|
|
134
|
+
// Mirrors the failure path in current triage.ts (missingSections > 0).
|
|
135
|
+
const error = `Triage model returned incomplete output. Missing sections: ${normalized.missingSections.join(', ')}`;
|
|
136
|
+
// ensureStructuredTriageOutput does not throw; mark the span ERROR
|
|
137
|
+
// explicitly. withPhase does not force OK on a normal return, so this
|
|
138
|
+
// survives to phase.end.
|
|
139
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error });
|
|
140
|
+
return { ok: false, error };
|
|
141
|
+
}
|
|
142
|
+
const assessment = parseFixAssessment(normalized.output);
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
commentOutput: normalized.commentOutput,
|
|
146
|
+
output: normalized.output,
|
|
147
|
+
assessment,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { DOISTBOT_AUTO_FIX_LABEL, DOISTBOT_SPECULATIVE_FIX_LABEL } from 'doistbot-shared-contracts';
|
|
4
5
|
import { logger } from '../../core/logging.js';
|
|
5
6
|
import { fillPromptTemplate, loadPromptTemplateFile } from '../../core/prompt-template.js';
|
|
6
7
|
const PR_TEMPLATE_PATHS = [
|
|
@@ -299,8 +300,8 @@ export async function openDraftFixPR(input) {
|
|
|
299
300
|
});
|
|
300
301
|
try {
|
|
301
302
|
const prLabel = input.fixAssessment?.classification === 'speculative'
|
|
302
|
-
?
|
|
303
|
-
:
|
|
303
|
+
? DOISTBOT_SPECULATIVE_FIX_LABEL
|
|
304
|
+
: DOISTBOT_AUTO_FIX_LABEL;
|
|
304
305
|
await input.octokit.rest.issues.addLabels({
|
|
305
306
|
owner: input.repoOwner,
|
|
306
307
|
repo: input.repoName,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { resolveIssueRepositories, } from '../../core/repository-resolver.js';
|
|
2
|
+
import { recordDecision, withPhase } from '../../core/tracing.js';
|
|
3
|
+
export async function resolveTriageScope(params) {
|
|
4
|
+
return withPhase('triage.resolution', { 'labels.count': params.labelNames.length }, async (span) => {
|
|
5
|
+
const result = resolveIssueRepositories(params.labelNames);
|
|
6
|
+
recordDecision('triage.resolution.outcome', {
|
|
7
|
+
trigger: params.triggerType,
|
|
8
|
+
status: result.status,
|
|
9
|
+
confidence: result.confidence,
|
|
10
|
+
reason: result.reason,
|
|
11
|
+
decision_path: result.decisionPath,
|
|
12
|
+
parsed_products: result.parsedLabels.productLabels,
|
|
13
|
+
parsed_apps: result.parsedLabels.appLabels,
|
|
14
|
+
parsed_teams: result.parsedLabels.teamLabels,
|
|
15
|
+
unknown_product_labels: result.parsedLabels.unknownProductLabels,
|
|
16
|
+
unknown_app_labels: result.parsedLabels.unknownAppLabels,
|
|
17
|
+
unknown_team_labels: result.parsedLabels.unknownTeamLabels,
|
|
18
|
+
});
|
|
19
|
+
span.setAttributes({
|
|
20
|
+
'resolution.status': result.status,
|
|
21
|
+
'resolution.confidence': result.confidence,
|
|
22
|
+
'resolution.repos.count': result.status === 'resolved' ? result.repositories.length : 0,
|
|
23
|
+
});
|
|
24
|
+
return result;
|
|
25
|
+
});
|
|
26
|
+
}
|