@doist/doistbot-cli 1.0.6 → 1.0.8
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 +43 -11
- package/dist/actions/doctor.js +4 -2
- package/dist/actions/review.js +11 -14
- package/dist/auth.js +23 -6
- package/dist/config.js +58 -12
- package/package.json +3 -3
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/_node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/dist/core/pi.js +31 -1
- package/sandbox/dist/core/prompt-template.js +45 -3
- package/sandbox/dist/core/repository-map.js +24 -0
- package/sandbox/dist/core/tracing.js +9 -0
- package/sandbox/dist/providers/helpers.js +51 -0
- package/sandbox/dist/providers/pi-review.js +7 -1
- package/sandbox/dist/tasks/chat/chat.js +20 -8
- package/sandbox/dist/tasks/issue-summarize/model.js +119 -48
- 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/fix-attempt.js +3 -2
- package/sandbox/dist/tasks/issue-triage/hero-group-map.js +2 -0
- package/sandbox/dist/tasks/issue-triage/pr-creator.js +2 -2
- package/sandbox/dist/tasks/issue-triage/prompt.js +2 -2
- package/sandbox/dist/tasks/issue-triage/triage.js +2 -2
- package/sandbox/dist/tasks/persist-logs/llm-query-planner.js +3 -3
- package/sandbox/dist/tasks/review/engines/dedupe.js +107 -42
- package/sandbox/dist/tasks/review/engines/multi-focus.js +229 -119
- package/sandbox/dist/tasks/review/engines/shared.js +36 -1
- package/sandbox/dist/tasks/review/github-comment-format.js +12 -4
- package/sandbox/dist/tasks/review/local.js +6 -1
- package/sandbox/dist/tasks/review/review-summary.js +3 -0
- package/sandbox/dist/tasks/review/review.js +253 -186
- package/sandbox/dist/tasks/review/runner.js +10 -5
- package/sandbox/dist/tasks/review/summary-model.js +40 -18
- package/sandbox/node_modules/doistbot-repo-config/dist/index.d.ts +1 -1
- package/sandbox/node_modules/doistbot-repo-config/dist/index.js +1 -1
- package/sandbox/src/tasks/review/prompts/review-multi-focus-base-prompt.md +1 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DEFAULT_GEMINI_FLASH_MODEL } from '../../../core/pi.js';
|
|
2
|
+
import { getReviewProviderCredential, hasReviewProviderCredential, resolveReviewProviderCredential, } from '../../../providers/credentials.js';
|
|
2
3
|
import { getProvider } from '../../../providers/index.js';
|
|
3
4
|
import { getReviewThinkingLevel } from '../thinking-level.js';
|
|
4
5
|
export function getApiKeys(ctx) {
|
|
@@ -6,6 +7,11 @@ export function getApiKeys(ctx) {
|
|
|
6
7
|
for (const model of ctx.reviewModelsEnabled) {
|
|
7
8
|
apiKeys[model] = ctx.apiKeys[model];
|
|
8
9
|
}
|
|
10
|
+
// Gemini is a support credential for fallback, dedupe, and utility work. It
|
|
11
|
+
// does not need to be part of the routed primary model set.
|
|
12
|
+
if (ctx.apiKeys.gemini) {
|
|
13
|
+
apiKeys.gemini = ctx.apiKeys.gemini;
|
|
14
|
+
}
|
|
9
15
|
return apiKeys;
|
|
10
16
|
}
|
|
11
17
|
export function getAvailableModels(apiKeys) {
|
|
@@ -20,6 +26,23 @@ export function getAvailableModels(apiKeys) {
|
|
|
20
26
|
models.push('zai');
|
|
21
27
|
return models;
|
|
22
28
|
}
|
|
29
|
+
export function getEnabledReviewModels(apiKeys, reviewModelsEnabled) {
|
|
30
|
+
return reviewModelsEnabled.filter((model) => hasReviewProviderCredential(apiKeys[model]));
|
|
31
|
+
}
|
|
32
|
+
export function getOpenRouterReviewCredential(...apiKeySets) {
|
|
33
|
+
for (const apiKeys of apiKeySets) {
|
|
34
|
+
if (!apiKeys) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
for (const credential of Object.values(apiKeys)) {
|
|
38
|
+
const resolved = resolveReviewProviderCredential(credential);
|
|
39
|
+
if (resolved?.piProviderId === 'openrouter') {
|
|
40
|
+
return credential;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
23
46
|
export function getProviderFailures(results) {
|
|
24
47
|
return results
|
|
25
48
|
.filter((result) => result.error)
|
|
@@ -48,3 +71,15 @@ export async function requestModelReview({ model, prompt, apiKeys, workspaceDir,
|
|
|
48
71
|
return null;
|
|
49
72
|
}
|
|
50
73
|
}
|
|
74
|
+
// Resolves the gemini credential and stamps the flash model id onto it, so the
|
|
75
|
+
// fallback pass runs flash via the existing apiKeys path (no extra param).
|
|
76
|
+
export function buildGeminiFlashCredential(credential) {
|
|
77
|
+
const resolved = resolveReviewProviderCredential(credential);
|
|
78
|
+
if (!resolved)
|
|
79
|
+
return undefined;
|
|
80
|
+
// No piProviderId → always the Google provider (never OpenRouter) for Gemini.
|
|
81
|
+
return {
|
|
82
|
+
apiKey: resolved.apiKey,
|
|
83
|
+
modelName: DEFAULT_GEMINI_FLASH_MODEL,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -8,7 +8,9 @@ export const PRIORITY_BADGE_URLS = {
|
|
|
8
8
|
const PRIORITY_PREFIX_PATTERN = /^\s*\[(P[0-3])\]([^\S\r\n]*)(\r?\n)?/i;
|
|
9
9
|
const PRIORITY_TAG_PATTERN = /\[(P[0-3])\]/gi;
|
|
10
10
|
const PRIORITY_METADATA_PATTERN = /<!--\s*\[(P[0-3])\]\s*-->\s*(?:\[\1\]\s*)?/gi;
|
|
11
|
-
const
|
|
11
|
+
const PRIORITY_BADGE_HTML_SOURCE = String.raw `(?:<a\b[^>]*>\s*)?<img\b(?=[^>]*\balt=["']P[0-3]["'])(?=[^>]*\bsrc=["']https:\/\/res\.cloudinary\.com\/dqrcfnzm9\/image\/upload\/v\d+\/p[0-3]_[^"']+\.svg["'])[^>]*>(?:\s*<\/a>)?`;
|
|
12
|
+
const PRIORITY_BADGE_WITH_METADATA_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}[^\\S\\r\\n]*<!--\\s*\\[(P[0-3])\\]\\s*-->([^\\S\\r\\n]*)`, 'gi');
|
|
13
|
+
const PRIORITY_BADGE_HTML_PATTERN = new RegExp(`${PRIORITY_BADGE_HTML_SOURCE}([^\\S\\r\\n]*)`, 'gi');
|
|
12
14
|
const PRIORITY_BADGE_ALT_PATTERN = /\balt=["'](P[0-3])["']/i;
|
|
13
15
|
const INLINE_BADGE_SEPARATOR = ' ';
|
|
14
16
|
export function renderPriorityBadge(severity) {
|
|
@@ -20,7 +22,7 @@ function renderPriorityMetadata(severity) {
|
|
|
20
22
|
return `<!--[${severity}] -->`;
|
|
21
23
|
}
|
|
22
24
|
function renderPriorityBadgeWithMetadata(severity) {
|
|
23
|
-
return `${
|
|
25
|
+
return `${renderPriorityBadge(severity)}${renderPriorityMetadata(severity)}`;
|
|
24
26
|
}
|
|
25
27
|
function getOpeningFence(line) {
|
|
26
28
|
const fence = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
|
|
@@ -70,13 +72,19 @@ export function formatGithubReviewSummary(body) {
|
|
|
70
72
|
.join('');
|
|
71
73
|
}
|
|
72
74
|
export function normalizeGithubPriorityBadges(body) {
|
|
73
|
-
const
|
|
75
|
+
const normalizedBadgeMetadata = body.replace(PRIORITY_BADGE_WITH_METADATA_PATTERN, (badge, metadataSeverity, separator) => {
|
|
76
|
+
const badgeSeverity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
|
|
77
|
+
const severity = metadataSeverity.toUpperCase();
|
|
78
|
+
return badgeSeverity === severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
|
|
79
|
+
});
|
|
80
|
+
const normalizedBadges = normalizedBadgeMetadata.replace(PRIORITY_BADGE_HTML_PATTERN, (badge, separator) => {
|
|
74
81
|
const severity = badge.match(PRIORITY_BADGE_ALT_PATTERN)?.[1]?.toUpperCase();
|
|
75
82
|
return severity ? `[${severity}]${separator ? ' ' : ''}` : badge;
|
|
76
83
|
});
|
|
77
|
-
|
|
84
|
+
const normalizedMetadata = normalizedBadges.replace(PRIORITY_METADATA_PATTERN, (_match, severity) => {
|
|
78
85
|
return `[${severity.toUpperCase()}] `;
|
|
79
86
|
});
|
|
87
|
+
return normalizedMetadata;
|
|
80
88
|
}
|
|
81
89
|
export function formatGithubReviewComments(comments) {
|
|
82
90
|
return comments.map((comment) => ({
|
|
@@ -37,7 +37,12 @@ async function runLocalReviewWithLogging(input) {
|
|
|
37
37
|
prAuthor: author,
|
|
38
38
|
files,
|
|
39
39
|
apiKeys: input.apiKeys,
|
|
40
|
-
reviewModelsEnabled: getDefaultReviewModels(),
|
|
40
|
+
reviewModelsEnabled: input.reviewModelsEnabled ?? getDefaultReviewModels(),
|
|
41
|
+
...(input.reviewModelsByFocus ? { reviewModelsByFocus: input.reviewModelsByFocus } : {}),
|
|
42
|
+
...(input.geminiFallbackFocuses
|
|
43
|
+
? { geminiFallbackFocuses: input.geminiFallbackFocuses }
|
|
44
|
+
: {}),
|
|
45
|
+
...(input.reviewSummaryModel ? { reviewSummaryModel: input.reviewSummaryModel } : {}),
|
|
41
46
|
lowPriorityFindingPlacement: 'inline',
|
|
42
47
|
workspaceDir: input.workspaceDir,
|
|
43
48
|
onProgress: input.onProgress,
|
|
@@ -71,6 +71,9 @@ export async function summarizeReviewFindings({ credential, provider = 'zai', mo
|
|
|
71
71
|
...fallbackModels,
|
|
72
72
|
];
|
|
73
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.
|
|
74
77
|
const result = await requestReview({
|
|
75
78
|
credential: summaryModel.credential,
|
|
76
79
|
provider: summaryModel.provider,
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
|
+
import { SpanStatusCode } from '@opentelemetry/api';
|
|
2
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
6
|
import { cloneRepository, createGitHubClients, parseBaseContext, requiredEnv, WORKSPACE_DIR, } from '../../core/shared.js';
|
|
7
|
+
import { withPhase } from '../../core/tracing.js';
|
|
6
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
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
18
|
const REQUIRED_PRODUCTION_REVIEW_MODELS = getDefaultReviewModels();
|
|
19
|
+
const REVIEW_FOCUSES = REVIEW_FOCUS_DEFINITIONS.map((focus) => focus.name);
|
|
16
20
|
function optionalEnv(name) {
|
|
17
21
|
const value = process.env[name]?.trim();
|
|
18
22
|
return value ? value : undefined;
|
|
@@ -27,12 +31,50 @@ function parseContext() {
|
|
|
27
31
|
geminiApiKey: optionalEnv('GEMINI_API_KEY'),
|
|
28
32
|
openRouterApiKey: optionalEnv('OPENROUTER_API_KEY'),
|
|
29
33
|
openaiApiKey: optionalEnv('OPENAI_KEY'),
|
|
30
|
-
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'),
|
|
31
38
|
reviewDispatchLockAcquired: process.env.REVIEW_DISPATCH_LOCK_ACQUIRED === 'true',
|
|
32
39
|
reviewDispatchLockCheckRunId: optionalNumberEnv('REVIEW_DISPATCH_LOCK_CHECK_RUN_ID'),
|
|
33
40
|
dryRun: process.env.DRY_RUN?.toLowerCase() === 'true',
|
|
34
41
|
};
|
|
35
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
|
+
}
|
|
36
78
|
function optionalNumberEnv(name) {
|
|
37
79
|
const value = optionalEnv(name);
|
|
38
80
|
if (value === undefined) {
|
|
@@ -43,7 +85,7 @@ function optionalNumberEnv(name) {
|
|
|
43
85
|
}
|
|
44
86
|
function getConversationSummaryApiKeys(ctx) {
|
|
45
87
|
const apiKeys = {};
|
|
46
|
-
if (ctx.
|
|
88
|
+
if (ctx.geminiApiKey) {
|
|
47
89
|
apiKeys.gemini = ctx.geminiApiKey;
|
|
48
90
|
}
|
|
49
91
|
if (ctx.reviewModelsEnabled.includes('openai') && ctx.openaiApiKey) {
|
|
@@ -63,83 +105,35 @@ async function main() {
|
|
|
63
105
|
logger.info('Review task started', {
|
|
64
106
|
dryRun: ctx.dryRun,
|
|
65
107
|
reviewModelsEnabled: ctx.reviewModelsEnabled,
|
|
108
|
+
reviewFocusesEnabled: ctx.reviewFocusesEnabled,
|
|
109
|
+
reviewRoutingCategory: ctx.reviewRoutingCategory,
|
|
110
|
+
reviewRoutingRuleId: ctx.reviewRoutingRuleId,
|
|
66
111
|
});
|
|
67
|
-
const { app, readOctokit, writeOctokit } = await createGitHubClients(ctx);
|
|
68
|
-
// GitHub's Check Runs API requires App-installation auth; calling it with
|
|
69
|
-
// writeOctokit (the doistbot PAT) returns 403. Centralize the choice here
|
|
70
|
-
// so it can't drift as new Check Run call sites get added.
|
|
71
|
-
// See: https://docs.github.com/rest/checks/runs#create-a-check-run
|
|
72
|
-
const checkRunOctokit = readOctokit;
|
|
73
112
|
let exitCode = 0;
|
|
74
113
|
let reviewCompletedSuccessfully = false;
|
|
75
114
|
let reviewMetricModels = [];
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
// pointer Check Run on the current head so reviewers can find their
|
|
99
|
-
// way back to the original. Closes the in-progress-during-push gap
|
|
100
|
-
// the webhook-side fix (#311) can't cover on its own — see the helper
|
|
101
|
-
// doc-comment for the full timeline this handles.
|
|
102
|
-
await postStaleReviewPointerIfHeadMoved({
|
|
103
|
-
octokit: checkRunOctokit,
|
|
104
|
-
owner: ctx.owner,
|
|
105
|
-
repo: ctx.repo,
|
|
106
|
-
pullNumber: ctx.prNumber,
|
|
107
|
-
reviewedSha: ctx.headSha,
|
|
108
|
-
reviewedTitle: getReviewCheckRunTitle(input),
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
if (!ctx.dryRun && ctx.headSha) {
|
|
113
|
-
// Concurrency lock + "review running" signal both come from the
|
|
114
|
-
// same Check Run: an `in_progress` run is posted at start (and
|
|
115
|
-
// upserted to the final conclusion when the review completes).
|
|
116
|
-
//
|
|
117
|
-
// Caveat: the lock is keyed by the *triggering* SHA, but the
|
|
118
|
-
// review pipeline itself (cloneRepository / pulls.listFiles)
|
|
119
|
-
// operates on the PR's current head — they're not pinned to
|
|
120
|
-
// ctx.headSha. So a new commit landing during a review can slip
|
|
121
|
-
// past this lock and produce a duplicate review of the same final
|
|
122
|
-
// PR state. Cost is bounded (≤2× LLM per fast-push-during-review)
|
|
123
|
-
// and the case is rare; pinning the pipeline is its own follow-up
|
|
124
|
-
// (see PR #274's "Deferred" section, finding C2).
|
|
125
|
-
//
|
|
126
|
-
// There's also a small TOCTOU window between this check and the
|
|
127
|
-
// in_progress post — same race the prior comment-based lock had,
|
|
128
|
-
// and same bound (one duplicate review when it fires).
|
|
129
|
-
if (!ctx.reviewDispatchLockAcquired) {
|
|
130
|
-
const otherInProgress = await isAnotherReviewInProgress({
|
|
131
|
-
octokit: checkRunOctokit,
|
|
132
|
-
owner: ctx.owner,
|
|
133
|
-
repo: ctx.repo,
|
|
134
|
-
sha: ctx.headSha,
|
|
135
|
-
deliveryId: ctx.deliveryId,
|
|
136
|
-
});
|
|
137
|
-
if (otherInProgress) {
|
|
138
|
-
logger.info('Another review is already in progress on this SHA, skipping');
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
await postReviewInProgressCheckRun({
|
|
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 = {
|
|
143
137
|
octokit: checkRunOctokit,
|
|
144
138
|
owner: ctx.owner,
|
|
145
139
|
repo: ctx.repo,
|
|
@@ -147,133 +141,206 @@ async function main() {
|
|
|
147
141
|
pullNumber: ctx.prNumber,
|
|
148
142
|
deliveryId: ctx.deliveryId,
|
|
149
143
|
checkRunId: ctx.reviewDispatchLockCheckRunId,
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
owner: ctx.owner,
|
|
165
|
-
repo: ctx.repo,
|
|
166
|
-
prNumber: ctx.prNumber,
|
|
167
|
-
installationId: ctx.installationId,
|
|
168
|
-
});
|
|
169
|
-
const [prResponse, files] = await Promise.all([
|
|
170
|
-
readOctokit.rest.pulls.get({
|
|
171
|
-
owner: ctx.owner,
|
|
172
|
-
repo: ctx.repo,
|
|
173
|
-
pull_number: ctx.prNumber,
|
|
174
|
-
}),
|
|
175
|
-
readOctokit.paginate(readOctokit.rest.pulls.listFiles, {
|
|
176
|
-
owner: ctx.owner,
|
|
177
|
-
repo: ctx.repo,
|
|
178
|
-
pull_number: ctx.prNumber,
|
|
179
|
-
per_page: 100,
|
|
180
|
-
}),
|
|
181
|
-
]);
|
|
182
|
-
const pr = prResponse.data;
|
|
183
|
-
const prAuthor = pr.user?.login ?? 'unknown';
|
|
184
|
-
function loadPrConversation() {
|
|
185
|
-
return fetchReviewConversationContext({
|
|
186
|
-
octokit: readOctokit,
|
|
144
|
+
};
|
|
145
|
+
if (input.conclusion === 'success') {
|
|
146
|
+
await postReviewSuccessCheckRun({ ...baseArgs, review: input.review });
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
await postReviewFailureCheckRun(baseArgs);
|
|
150
|
+
}
|
|
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({
|
|
157
|
+
octokit: checkRunOctokit,
|
|
187
158
|
owner: ctx.owner,
|
|
188
159
|
repo: ctx.repo,
|
|
189
160
|
pullNumber: ctx.prNumber,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
workspaceDir: WORKSPACE_DIR,
|
|
161
|
+
reviewedSha: ctx.headSha,
|
|
162
|
+
reviewedTitle: getReviewCheckRunTitle(input),
|
|
193
163
|
});
|
|
194
164
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
await
|
|
227
|
-
|
|
228
|
-
|
|
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,
|
|
229
204
|
});
|
|
230
205
|
}
|
|
206
|
+
else if (ctx.dryRun) {
|
|
207
|
+
logger.info('Dry run enabled, skipping check-run posting');
|
|
208
|
+
}
|
|
231
209
|
else {
|
|
232
|
-
|
|
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');
|
|
233
215
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
216
|
+
await cloneRepository({
|
|
217
|
+
app,
|
|
218
|
+
owner: ctx.owner,
|
|
219
|
+
repo: ctx.repo,
|
|
220
|
+
prNumber: ctx.prNumber,
|
|
221
|
+
installationId: ctx.installationId,
|
|
240
222
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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({
|
|
248
262
|
owner: ctx.owner,
|
|
249
263
|
repo: ctx.repo,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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,
|
|
276
|
+
workspaceDir: WORKSPACE_DIR,
|
|
253
277
|
});
|
|
254
|
-
|
|
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,
|
|
302
|
+
});
|
|
303
|
+
process.stdout.write(`${formatReviewForTerminal({
|
|
304
|
+
review,
|
|
305
|
+
})}\n`);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
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 });
|
|
317
|
+
}
|
|
318
|
+
logger.info('Review task completed');
|
|
319
|
+
reviewCompletedSuccessfully = true;
|
|
255
320
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
logger.error('Review failed', {
|
|
261
|
-
error: error instanceof Error ? error.message : String(error),
|
|
262
|
-
});
|
|
263
|
-
await postFinalCheckRun({ conclusion: 'failure' });
|
|
264
|
-
exitCode = 1;
|
|
265
|
-
}
|
|
266
|
-
finally {
|
|
267
|
-
if (reviewMetricModels.length > 0) {
|
|
268
|
-
await recordTaskMetrics({
|
|
269
|
-
type: 'review',
|
|
270
|
-
repository: `${ctx.owner}/${ctx.repo}`,
|
|
271
|
-
model: reviewMetricModels,
|
|
272
|
-
outcome: reviewCompletedSuccessfully ? 'success' : 'failure',
|
|
273
|
-
durationMs: performance.now() - startedAt,
|
|
321
|
+
catch (error) {
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
logger.error('Review failed', {
|
|
324
|
+
error: message,
|
|
274
325
|
});
|
|
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;
|
|
275
331
|
}
|
|
276
|
-
|
|
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
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
277
344
|
if (exitCode !== 0) {
|
|
278
345
|
process.exit(exitCode);
|
|
279
346
|
}
|