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