@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
|
@@ -51,7 +51,7 @@ Keep it human and compact: the opener should be 1 sentence, or 2 only if the PR
|
|
|
51
51
|
Respond with only valid JSON in this exact shape:
|
|
52
52
|
{"summary":"<your summary>","comments":[]}`;
|
|
53
53
|
}
|
|
54
|
-
export async function summarizeReviewFindings({ credential, prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
|
|
54
|
+
export async function summarizeReviewFindings({ credential, provider = 'zai', modelName, fallbackModels = [], prTitle, prBody, findings, reviewObservations, workspaceDir, requestReview = requestReviewSummary, }) {
|
|
55
55
|
const prompt = buildReviewSummaryPrompt({
|
|
56
56
|
prTitle,
|
|
57
57
|
prBody,
|
|
@@ -62,15 +62,56 @@ export async function summarizeReviewFindings({ credential, prTitle, prBody, fin
|
|
|
62
62
|
findingsCount: findings.length,
|
|
63
63
|
reviewObservationsCount: reviewObservations?.length ?? 0,
|
|
64
64
|
});
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const summaryModels = [
|
|
66
|
+
{
|
|
67
|
+
provider,
|
|
68
|
+
credential,
|
|
69
|
+
...(modelName ? { modelName } : {}),
|
|
70
|
+
},
|
|
71
|
+
...fallbackModels,
|
|
72
|
+
];
|
|
73
|
+
for (const [index, summaryModel] of summaryModels.entries()) {
|
|
74
|
+
// The `review.summary` span lives in requestReviewSummary, around the
|
|
75
|
+
// actual model invoke — so a missing-credential config error returns
|
|
76
|
+
// before any span and never shows a fake model attempt.
|
|
77
|
+
const result = await requestReview({
|
|
78
|
+
credential: summaryModel.credential,
|
|
79
|
+
provider: summaryModel.provider,
|
|
80
|
+
...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
|
|
81
|
+
prompt,
|
|
82
|
+
workspaceDir,
|
|
83
|
+
});
|
|
84
|
+
if (result.status === 'failed') {
|
|
85
|
+
logger.warn('Review summary synthesis failed', {
|
|
86
|
+
findingsCount: findings.length,
|
|
87
|
+
reviewObservationsCount: reviewObservations?.length ?? 0,
|
|
88
|
+
failureReason: result.reason,
|
|
89
|
+
provider: summaryModel.provider,
|
|
90
|
+
...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
|
|
91
|
+
attempt: index + 1,
|
|
92
|
+
attemptsTotal: summaryModels.length,
|
|
93
|
+
...(result.error ? { error: result.error } : {}),
|
|
94
|
+
...(result.timedOut !== undefined ? { timedOut: result.timedOut } : {}),
|
|
95
|
+
...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}),
|
|
96
|
+
...(result.outputChars !== undefined ? { outputChars: result.outputChars } : {}),
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const summary = result.summary.trim();
|
|
101
|
+
logger.info('Review summary synthesis succeeded', {
|
|
69
102
|
findingsCount: findings.length,
|
|
103
|
+
reviewObservationsCount: reviewObservations?.length ?? 0,
|
|
104
|
+
provider: summaryModel.provider,
|
|
105
|
+
...(summaryModel.modelName ? { modelName: summaryModel.modelName } : {}),
|
|
106
|
+
attempt: index + 1,
|
|
107
|
+
attemptsTotal: summaryModels.length,
|
|
108
|
+
durationMs: result.durationMs,
|
|
109
|
+
outputChars: result.outputChars,
|
|
110
|
+
summaryChars: summary.length,
|
|
70
111
|
});
|
|
71
|
-
return
|
|
112
|
+
return summary;
|
|
72
113
|
}
|
|
73
|
-
return
|
|
114
|
+
return null;
|
|
74
115
|
}
|
|
75
116
|
export function buildFallbackSummary({ prTitle, findings, }) {
|
|
76
117
|
const safeTitle = prTitle.trim();
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
|
-
import {
|
|
2
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
3
|
+
import { getDefaultReviewModels } from 'doistbot-repo-config';
|
|
3
4
|
import { recordTaskMetrics } from '../../core/datadog-metrics.js';
|
|
4
5
|
import { logger } from '../../core/logging.js';
|
|
5
|
-
import { createOpenRouterCredential } from '../../providers/credentials.js';
|
|
6
6
|
import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
|
|
7
|
+
import { withPhase } from '../../core/tracing.js';
|
|
8
|
+
import { createOpenRouterCredential } from '../../providers/credentials.js';
|
|
7
9
|
import { getReviewCheckRunTitle, isAnotherReviewInProgress, postReviewFailureCheckRun, postReviewInProgressCheckRun, postReviewSuccessCheckRun, postStaleReviewPointerIfHeadMoved, } from './check-run.js';
|
|
8
10
|
import { fetchReviewConversationContext } from './conversation-context.js';
|
|
9
11
|
import {} from './diff.js';
|
|
10
12
|
import { formatReviewForTerminal } from './format.js';
|
|
11
|
-
import { formatGithubReviewComments } from './github-comment-format.js';
|
|
13
|
+
import { formatGithubReviewComments, formatGithubReviewSummary } from './github-comment-format.js';
|
|
14
|
+
import { REVIEW_FOCUS_DEFINITIONS } from './multi-focus-prompt.js';
|
|
12
15
|
import { appendReviewFeedbackLink } from './review-feedback.js';
|
|
13
16
|
import { runSharedReview } from './runner.js';
|
|
14
17
|
const REVIEW_EVENT = 'COMMENT';
|
|
15
|
-
const REQUIRED_PRODUCTION_REVIEW_MODELS =
|
|
16
|
-
|
|
17
|
-
// Parsed/defaulted in webhook-handler before being passed as task env.
|
|
18
|
-
const raw = process.env.REVIEW_MODELS_ENABLED;
|
|
19
|
-
return parseConfiguredReviewModelsEnabled(raw, { defaultModels: [] });
|
|
20
|
-
}
|
|
18
|
+
const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
|
|
19
|
+
const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
|
|
21
20
|
function optionalEnv(name) {
|
|
22
21
|
const value = process.env[name]?.trim();
|
|
23
22
|
return value ? value : undefined;
|
|
@@ -29,16 +28,65 @@ function parseContext() {
|
|
|
29
28
|
branch: requiredEnv('BRANCH'),
|
|
30
29
|
baseBranch: requiredEnv('BASE_BRANCH'),
|
|
31
30
|
headSha: optionalEnv('HEAD_SHA'),
|
|
31
|
+
geminiApiKey: optionalEnv('GEMINI_API_KEY'),
|
|
32
32
|
openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
|
|
33
33
|
openaiApiKey: optionalEnv('OPENAI_KEY'),
|
|
34
|
-
reviewModelsEnabled:
|
|
34
|
+
reviewModelsEnabled: parseReviewModelsEnv(process.env.REVIEW_MODELS_ENABLED),
|
|
35
|
+
reviewFocusesEnabled: parseReviewFocusesEnv(process.env.REVIEW_FOCUSES_ENABLED),
|
|
36
|
+
reviewRoutingCategory: optionalEnv('REVIEW_ROUTING_CATEGORY'),
|
|
37
|
+
reviewRoutingRuleId: optionalEnv('REVIEW_ROUTING_RULE_ID'),
|
|
38
|
+
reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
|
|
39
|
+
reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
|
|
35
40
|
dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
|
|
36
41
|
};
|
|
37
42
|
}
|
|
43
|
+
export function parseReviewModelsEnv(value) {
|
|
44
|
+
return parseCsvAllowList({
|
|
45
|
+
value,
|
|
46
|
+
allowedValues: REQUIRED_PRODUCTION_REVIEW_MODELS,
|
|
47
|
+
fallback: REQUIRED_PRODUCTION_REVIEW_MODELS,
|
|
48
|
+
envName: 'REVIEW_MODELS_ENABLED',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function parseReviewFocusesEnv(value) {
|
|
52
|
+
return parseCsvAllowList({
|
|
53
|
+
value,
|
|
54
|
+
allowedValues: REVIEW_FOCUSES,
|
|
55
|
+
fallback: REVIEW_FOCUSES,
|
|
56
|
+
envName: 'REVIEW_FOCUSES_ENABLED',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function parseCsvAllowList({ value, allowedValues, fallback, envName, }) {
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
return [...fallback];
|
|
62
|
+
}
|
|
63
|
+
const allowed = new Set(allowedValues);
|
|
64
|
+
const requested = value
|
|
65
|
+
.split(',')
|
|
66
|
+
.map((item) => item.trim())
|
|
67
|
+
.filter((item) => item.length > 0);
|
|
68
|
+
if (requested.length === 0) {
|
|
69
|
+
throw new Error(`${envName} must include at least one supported value when set.`);
|
|
70
|
+
}
|
|
71
|
+
const unsupported = requested.filter((item) => !allowed.has(item));
|
|
72
|
+
if (unsupported.length > 0) {
|
|
73
|
+
throw new Error(`${envName} contains unsupported value(s): ${[...new Set(unsupported)].join(', ')}. ` +
|
|
74
|
+
`Supported values: ${allowedValues.join(', ')}.`);
|
|
75
|
+
}
|
|
76
|
+
return [...new Set(requested)];
|
|
77
|
+
}
|
|
78
|
+
function optionalNumberEnv(name) {
|
|
79
|
+
const value = optionalEnv(name);
|
|
80
|
+
if (value === undefined) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
85
|
+
}
|
|
38
86
|
function getConversationSummaryApiKeys(ctx) {
|
|
39
87
|
const apiKeys = {};
|
|
40
|
-
if (ctx.
|
|
41
|
-
apiKeys.gemini =
|
|
88
|
+
if (ctx.geminiApiKey) {
|
|
89
|
+
apiKeys.gemini = ctx.geminiApiKey;
|
|
42
90
|
}
|
|
43
91
|
if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
|
|
44
92
|
apiKeys.openai = ctx.openaiApiKey;
|
|
@@ -57,209 +105,242 @@ async function main() {
|
|
|
57
105
|
logger.info('Review task started', {
|
|
58
106
|
dryRun: ctx.dryRun,
|
|
59
107
|
reviewModelsEnabled: ctx.reviewModelsEnabled,
|
|
108
|
+
reviewFocusesEnabled: ctx.reviewFocusesEnabled,
|
|
109
|
+
reviewRoutingCategory: ctx.reviewRoutingCategory,
|
|
110
|
+
reviewRoutingRuleId: ctx.reviewRoutingRuleId,
|
|
60
111
|
});
|
|
61
|
-
const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
|
|
62
|
-
// GitHub's Check Runs API requires App-installation auth; calling it with
|
|
63
|
-
// writeOctokit (the doistbot PAT) returns 403. Centralize the choice here
|
|
64
|
-
// so it can't drift as new Check Run call sites get added.
|
|
65
|
-
// See: https://docs.github.com/rest/checks/runs#create-a-check-run
|
|
66
|
-
const checkRunOctokit = readOctokit;
|
|
67
112
|
let exitCode = 0;
|
|
68
113
|
let reviewCompletedSuccessfully = false;
|
|
69
114
|
let reviewMetricModels = [];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// way back to the original. Closes the in-progress-during-push gap
|
|
93
|
-
// the webhook-side fix (#311) can't cover on its own — see the helper
|
|
94
|
-
// doc-comment for the full timeline this handles.
|
|
95
|
-
await postStaleReviewPointerIfHeadMoved({
|
|
96
|
-
octokit: checkRunOctokit,
|
|
97
|
-
owner: ctx.owner,
|
|
98
|
-
repo: ctx.repo,
|
|
99
|
-
pullNumber: ctx.prNumber,
|
|
100
|
-
reviewedSha: ctx.headSha,
|
|
101
|
-
reviewedTitle: getReviewCheckRunTitle(input),
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
try {
|
|
105
|
-
if (!ctx.dryRun && ctx.headSha) {
|
|
106
|
-
// Concurrency lock + "review running" signal both come from the
|
|
107
|
-
// same Check Run: an `in_progress` run is posted at start (and
|
|
108
|
-
// upserted to the final conclusion when the review completes).
|
|
109
|
-
//
|
|
110
|
-
// Caveat: the lock is keyed by the *triggering* SHA, but the
|
|
111
|
-
// review pipeline itself (cloneRepository / pulls.listFiles)
|
|
112
|
-
// operates on the PR's current head — they're not pinned to
|
|
113
|
-
// ctx.headSha. So a new commit landing during a review can slip
|
|
114
|
-
// past this lock and produce a duplicate review of the same final
|
|
115
|
-
// PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
|
|
116
|
-
// and the case is rare; pinning the pipeline is its own follow-up
|
|
117
|
-
// (see PR #274's "Deferred" section, finding C2).
|
|
118
|
-
//
|
|
119
|
-
// There's also a small TOCTOU window between this check and the
|
|
120
|
-
// in_progress post — same race the prior comment-based lock had,
|
|
121
|
-
// and same bound (one duplicate review when it fires).
|
|
122
|
-
const otherInProgress = await isAnotherReviewInProgress({
|
|
115
|
+
await withPhase('review', {
|
|
116
|
+
task: 'review',
|
|
117
|
+
'gen_ai.operation.name': 'invoke_agent',
|
|
118
|
+
'gen_ai.agent.name': 'doistbot.review',
|
|
119
|
+
'pr.number': ctx.prNumber,
|
|
120
|
+
dry_run: ctx.dryRun,
|
|
121
|
+
}, async (span) => {
|
|
122
|
+
// Inside the span so installation-auth/setup failures are captured
|
|
123
|
+
// by the root `review` trace.
|
|
124
|
+
const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
|
|
125
|
+
// GitHub's Check Runs API requires App-installation auth; calling it
|
|
126
|
+
// with writeOctokit (the doistbot PAT) returns 403. Centralize the
|
|
127
|
+
// choice here so it can't drift as new Check Run call sites get added.
|
|
128
|
+
// See: https://docs.github.com/rest/checks/runs#create-a-check-run
|
|
129
|
+
const checkRunOctokit = readOctokit;
|
|
130
|
+
// Centralizes the boilerplate for posting the final Check Run from the
|
|
131
|
+
// ~7 cleanup paths below. Skips silently when no headSha is available
|
|
132
|
+
// (rare; logged at startup) or when running under dryRun.
|
|
133
|
+
async function postFinalCheckRun(input) {
|
|
134
|
+
if (ctx.dryRun || !ctx.headSha)
|
|
135
|
+
return;
|
|
136
|
+
const baseArgs = {
|
|
123
137
|
octokit: checkRunOctokit,
|
|
124
138
|
owner: ctx.owner,
|
|
125
139
|
repo: ctx.repo,
|
|
126
140
|
sha: ctx.headSha,
|
|
141
|
+
pullNumber: ctx.prNumber,
|
|
127
142
|
deliveryId: ctx.deliveryId,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
checkRunId: ctx.reviewDispatchLockCheckRunId,
|
|
144
|
+
};
|
|
145
|
+
if (input.conclusion === 'success') {
|
|
146
|
+
await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
await postReviewFailureCheckRun(baseArgs);
|
|
132
150
|
}
|
|
133
|
-
|
|
151
|
+
// If the PR head moved during the review, also drop a neutral
|
|
152
|
+
// stale-pointer Check Run on the current head so reviewers can
|
|
153
|
+
// find their way back to the original. Closes the in-progress-
|
|
154
|
+
// during-push gap the webhook-side fix (#311) can't cover on its
|
|
155
|
+
// own — see the helper doc-comment for the full timeline.
|
|
156
|
+
await postStaleReviewPointerIfHeadMoved({
|
|
134
157
|
octokit: checkRunOctokit,
|
|
135
158
|
owner: ctx.owner,
|
|
136
159
|
repo: ctx.repo,
|
|
137
|
-
sha: ctx.headSha,
|
|
138
160
|
pullNumber: ctx.prNumber,
|
|
139
|
-
|
|
161
|
+
reviewedSha: ctx.headSha,
|
|
162
|
+
reviewedTitle: getReviewCheckRunTitle(input),
|
|
140
163
|
});
|
|
141
164
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
165
|
+
try {
|
|
166
|
+
if (!ctx.dryRun && ctx.headSha) {
|
|
167
|
+
// Concurrency lock + "review running" signal both come from the
|
|
168
|
+
// same Check Run: an `in_progress` run is posted at start (and
|
|
169
|
+
// upserted to the final conclusion when the review completes).
|
|
170
|
+
//
|
|
171
|
+
// Caveat: the lock is keyed by the *triggering* SHA, but the
|
|
172
|
+
// review pipeline itself (cloneRepository / pulls.listFiles)
|
|
173
|
+
// operates on the PR's current head — they're not pinned to
|
|
174
|
+
// ctx.headSha. So a new commit landing during a review can slip
|
|
175
|
+
// past this lock and produce a duplicate review of the same final
|
|
176
|
+
// PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
|
|
177
|
+
// and the case is rare; pinning the pipeline is its own follow-up
|
|
178
|
+
// (see PR #274's "Deferred" section, finding C2).
|
|
179
|
+
//
|
|
180
|
+
// There's also a small TOCTOU window between this check and the
|
|
181
|
+
// in_progress post — same race the prior comment-based lock had,
|
|
182
|
+
// and same bound (one duplicate review when it fires).
|
|
183
|
+
if (!ctx.reviewDispatchLockAcquired) {
|
|
184
|
+
const otherInProgress = await isAnotherReviewInProgress({
|
|
185
|
+
octokit: checkRunOctokit,
|
|
186
|
+
owner: ctx.owner,
|
|
187
|
+
repo: ctx.repo,
|
|
188
|
+
sha: ctx.headSha,
|
|
189
|
+
deliveryId: ctx.deliveryId,
|
|
190
|
+
});
|
|
191
|
+
if (otherInProgress) {
|
|
192
|
+
logger.info('Another review is already in progress on this SHA, skipping');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
await postReviewInProgressCheckRun({
|
|
197
|
+
octokit: checkRunOctokit,
|
|
198
|
+
owner: ctx.owner,
|
|
199
|
+
repo: ctx.repo,
|
|
200
|
+
sha: ctx.headSha,
|
|
201
|
+
pullNumber: ctx.prNumber,
|
|
202
|
+
deliveryId: ctx.deliveryId,
|
|
203
|
+
checkRunId: ctx.reviewDispatchLockCheckRunId,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else if (ctx.dryRun) {
|
|
207
|
+
logger.info('Dry run enabled, skipping check-run posting');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// No HEAD_SHA available — can't post Check Runs (which need a
|
|
211
|
+
// SHA) and can't lock. Reviews proceed unguarded; a rare
|
|
212
|
+
// simultaneous /review may double-bill on the same PR. Log so
|
|
213
|
+
// this is visible.
|
|
214
|
+
logger.warn('HEAD_SHA missing; review will proceed without a Check Run lock or status posting');
|
|
215
|
+
}
|
|
216
|
+
await cloneRepository({
|
|
217
|
+
app,
|
|
166
218
|
owner: ctx.owner,
|
|
167
219
|
repo: ctx.repo,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
220
|
+
prNumber: ctx.prNumber,
|
|
221
|
+
installationId: ctx.installationId,
|
|
222
|
+
});
|
|
223
|
+
const [prResponse, files] = await Promise.all([
|
|
224
|
+
readOctokit.rest.pulls.get({
|
|
225
|
+
owner: ctx.owner,
|
|
226
|
+
repo: ctx.repo,
|
|
227
|
+
pull_number: ctx.prNumber,
|
|
228
|
+
}),
|
|
229
|
+
readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
|
|
230
|
+
owner: ctx.owner,
|
|
231
|
+
repo: ctx.repo,
|
|
232
|
+
pull_number: ctx.prNumber,
|
|
233
|
+
per_page: 100,
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
const pr = prResponse.data;
|
|
237
|
+
const prAuthor = pr.user?.login ?? 'unknown';
|
|
238
|
+
function loadPrConversation() {
|
|
239
|
+
return fetchReviewConversationContext({
|
|
240
|
+
octokit: readOctokit,
|
|
241
|
+
owner: ctx.owner,
|
|
242
|
+
repo: ctx.repo,
|
|
243
|
+
pullNumber: ctx.prNumber,
|
|
244
|
+
prAuthor: pr.user?.login ?? undefined,
|
|
245
|
+
summaryApiKeys: getConversationSummaryApiKeys(ctx),
|
|
246
|
+
workspaceDir: WORKSPACE_DIR,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
reviewMetricModels = [...ctx.reviewModelsEnabled];
|
|
250
|
+
const reviewApiKeys = {};
|
|
251
|
+
if (ctx.geminiApiKey) {
|
|
252
|
+
reviewApiKeys.gemini = ctx.geminiApiKey;
|
|
253
|
+
}
|
|
254
|
+
if (ctx.openRouterApiKey) {
|
|
255
|
+
reviewApiKeys.deepseek = createOpenRouterCredential(ctx.openRouterApiKey);
|
|
256
|
+
reviewApiKeys.zai = createOpenRouterCredential(ctx.openRouterApiKey);
|
|
257
|
+
}
|
|
258
|
+
if (ctx.openaiApiKey) {
|
|
259
|
+
reviewApiKeys.openai = ctx.openaiApiKey;
|
|
260
|
+
}
|
|
261
|
+
const reviewResult = await runSharedReview({
|
|
177
262
|
owner: ctx.owner,
|
|
178
263
|
repo: ctx.repo,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
264
|
+
prNumber: ctx.prNumber,
|
|
265
|
+
branch: ctx.branch,
|
|
266
|
+
baseBranch: ctx.baseBranch,
|
|
267
|
+
prTitle: pr.title ?? '',
|
|
268
|
+
prBody: pr.body ?? '',
|
|
269
|
+
loadPrConversation,
|
|
270
|
+
prAuthor,
|
|
271
|
+
files: files,
|
|
272
|
+
apiKeys: reviewApiKeys,
|
|
273
|
+
reviewModelsEnabled: ctx.reviewModelsEnabled,
|
|
274
|
+
reviewFocusesEnabled: ctx.reviewFocusesEnabled,
|
|
275
|
+
requiredReviewModels: ctx.reviewModelsEnabled,
|
|
182
276
|
workspaceDir: WORKSPACE_DIR,
|
|
183
277
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
});
|
|
209
|
-
reviewMetricModels = reviewResult.metricModels;
|
|
210
|
-
if (reviewResult.status === 'skipped') {
|
|
211
|
-
if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
|
|
212
|
-
await postFinalCheckRun({
|
|
213
|
-
conclusion: 'success',
|
|
214
|
-
review: { summary: '', comments: [] },
|
|
278
|
+
reviewMetricModels = reviewResult.metricModels;
|
|
279
|
+
if (reviewResult.status === 'skipped') {
|
|
280
|
+
if (reviewResult.reason === 'no_diff' || reviewResult.reason === 'empty_diff') {
|
|
281
|
+
await postFinalCheckRun({
|
|
282
|
+
conclusion: 'success',
|
|
283
|
+
review: { summary: '', comments: [] },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// no_models / no_review / empty_review post a failing
|
|
288
|
+
// check run, so the span must end ERROR to match — a
|
|
289
|
+
// return-based failure won't trip withPhase's throw path.
|
|
290
|
+
span.setStatus({
|
|
291
|
+
code: SpanStatusCode.ERROR,
|
|
292
|
+
message: `review skipped: ${reviewResult.reason}`,
|
|
293
|
+
});
|
|
294
|
+
await postFinalCheckRun({ conclusion: 'failure' });
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const { review } = reviewResult;
|
|
299
|
+
if (ctx.dryRun) {
|
|
300
|
+
logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
|
|
301
|
+
commentsCount: review.comments.length,
|
|
215
302
|
});
|
|
303
|
+
process.stdout.write(`${formatReviewForTerminal({
|
|
304
|
+
review,
|
|
305
|
+
})}\n`);
|
|
216
306
|
}
|
|
217
307
|
else {
|
|
218
|
-
await
|
|
308
|
+
await submitReview({
|
|
309
|
+
octokit: writeOctokit,
|
|
310
|
+
owner: ctx.owner,
|
|
311
|
+
repo: ctx.repo,
|
|
312
|
+
pull_number: ctx.prNumber,
|
|
313
|
+
deliveryId: ctx.deliveryId,
|
|
314
|
+
review,
|
|
315
|
+
});
|
|
316
|
+
await postFinalCheckRun({ conclusion: 'success', review });
|
|
219
317
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const { review } = reviewResult;
|
|
223
|
-
if (ctx.dryRun) {
|
|
224
|
-
logger.info('Dry run enabled, printing review output instead of posting to GitHub', {
|
|
225
|
-
commentsCount: review.comments.length,
|
|
226
|
-
});
|
|
227
|
-
process.stdout.write(`${formatReviewForTerminal({
|
|
228
|
-
review,
|
|
229
|
-
})}\n`);
|
|
318
|
+
logger.info('Review task completed');
|
|
319
|
+
reviewCompletedSuccessfully = true;
|
|
230
320
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
repo: ctx.repo,
|
|
236
|
-
pull_number: ctx.prNumber,
|
|
237
|
-
deliveryId: ctx.deliveryId,
|
|
238
|
-
review,
|
|
321
|
+
catch (error) {
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
logger.error('Review failed', {
|
|
324
|
+
error: message,
|
|
239
325
|
});
|
|
240
|
-
await postFinalCheckRun({ conclusion: '
|
|
326
|
+
await postFinalCheckRun({ conclusion: 'failure' });
|
|
327
|
+
// Set the span status before the run exits; deferring process.exit
|
|
328
|
+
// until after withPhase lets phase.end flush.
|
|
329
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message });
|
|
330
|
+
exitCode = 1;
|
|
241
331
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
finally {
|
|
253
|
-
if (reviewMetricModels.length > 0) {
|
|
254
|
-
await recordTaskMetrics({
|
|
255
|
-
type: 'review',
|
|
256
|
-
repository: `${ctx.owner}/${ctx.repo}`,
|
|
257
|
-
model: reviewMetricModels,
|
|
258
|
-
outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
|
|
259
|
-
durationMs: performance.now() - startedAt,
|
|
260
|
-
});
|
|
332
|
+
finally {
|
|
333
|
+
if (reviewMetricModels.length > 0) {
|
|
334
|
+
await recordTaskMetrics({
|
|
335
|
+
type: 'review',
|
|
336
|
+
repository: `${ctx.owner}/${ctx.repo}`,
|
|
337
|
+
model: reviewMetricModels,
|
|
338
|
+
outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
|
|
339
|
+
durationMs: performance.now() - startedAt,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
261
342
|
}
|
|
262
|
-
}
|
|
343
|
+
});
|
|
263
344
|
if (exitCode !== 0) {
|
|
264
345
|
process.exit(exitCode);
|
|
265
346
|
}
|
|
@@ -272,7 +353,7 @@ async function submitReview({ octokit, owner, repo, pull_number, deliveryId, rev
|
|
|
272
353
|
event: REVIEW_EVENT,
|
|
273
354
|
};
|
|
274
355
|
if (review.summary?.length) {
|
|
275
|
-
const summaryWithFeedbackLink = appendReviewFeedbackLink(review.summary, {
|
|
356
|
+
const summaryWithFeedbackLink = appendReviewFeedbackLink(formatGithubReviewSummary(review.summary), {
|
|
276
357
|
owner,
|
|
277
358
|
repo,
|
|
278
359
|
prNumber: pull_number,
|