co-maintainer 0.5.0 → 0.5.1-beta.2
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/LICENSE +21 -21
- package/README.md +5 -3
- package/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +55 -0
- package/dist/src/ai/anthropic.js +262 -0
- package/dist/src/ai/batch.d.ts +2 -2
- package/dist/src/ai/batch.js +1 -1
- package/dist/src/ai/estimate.d.ts +4 -0
- package/dist/src/ai/estimate.js +17 -2
- package/dist/src/ai/mermaid_loop.js +7 -4
- package/dist/src/ai/openai.d.ts +45 -0
- package/dist/src/ai/openai.js +206 -0
- package/dist/src/ai/opencode.d.ts +10 -0
- package/dist/src/ai/opencode.js +76 -0
- package/dist/src/ai/openrouter.d.ts +23 -7
- package/dist/src/ai/openrouter.js +39 -23
- package/dist/src/ai/pricing.js +1 -1
- package/dist/src/ai/provider.d.ts +9 -0
- package/dist/src/ai/provider.js +42 -3
- package/dist/src/ai/verify.d.ts +9 -1
- package/dist/src/ai/verify.js +81 -3
- package/dist/src/cli/args.d.ts +1 -2
- package/dist/src/cli/args.js +38 -26
- package/dist/src/cli/commands/config.d.ts +1 -1
- package/dist/src/cli/commands/config.js +10 -4
- package/dist/src/cli/commands/probe.js +3 -11
- package/dist/src/cli/commands/registry.d.ts +1 -9
- package/dist/src/cli/commands/registry.js +75 -10
- package/dist/src/cli/commands/review.js +18 -13
- package/dist/src/cli/commands/rollback.d.ts +1 -0
- package/dist/src/cli/commands/rollback.js +42 -0
- package/dist/src/cli/commands/serve.js +2 -3
- package/dist/src/cli/commands/set.js +71 -7
- package/dist/src/cli/commands/view.js +10 -9
- package/dist/src/cli/error.d.ts +4 -4
- package/dist/src/cli/error.js +4 -4
- package/dist/src/cli/main.d.ts +4 -4
- package/dist/src/cli/main.js +13 -8
- package/dist/src/cli/prompt.js +1 -1
- package/dist/src/cli/review_args.d.ts +9 -3
- package/dist/src/cli/review_args.js +28 -5
- package/dist/src/cli/review_github.d.ts +31 -0
- package/dist/src/cli/review_github.js +128 -0
- package/dist/src/cli/review_result.d.ts +27 -16
- package/dist/src/cli/review_result.js +7 -7
- package/dist/src/config.d.ts +28 -6
- package/dist/src/config.js +15 -1
- package/dist/src/git/default_branch.d.ts +1 -1
- package/dist/src/git/default_branch.js +1 -1
- package/dist/src/github/app.js +2 -2
- package/dist/src/github/app_manifest.d.ts +1 -7
- package/dist/src/github/app_manifest.js +2 -7
- package/dist/src/github/client.d.ts +4 -1
- package/dist/src/github/client.js +14 -2
- package/dist/src/github/gh.js +10 -7
- package/dist/src/github/pat.js +4 -4
- package/dist/src/github/permissions.d.ts +36 -0
- package/dist/src/github/permissions.js +334 -0
- package/dist/src/knowledge/facts.js +1 -1
- package/dist/src/knowledge/guide.d.ts +3 -3
- package/dist/src/knowledge/guide.js +5 -5
- package/dist/src/knowledge/probe.js +1 -2
- package/dist/src/knowledge/sections.d.ts +3 -3
- package/dist/src/knowledge/sections.js +3 -3
- package/dist/src/knowledge/skill.d.ts +1 -1
- package/dist/src/knowledge/skill.js +4 -4
- package/dist/src/knowledge/synthesis.d.ts +1 -1
- package/dist/src/knowledge/synthesis.js +5 -5
- package/dist/src/knowledge/types.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.js +1 -1
- package/dist/src/local/codegraph_prepare.js +1 -1
- package/dist/src/local/git_ops.d.ts +1 -1
- package/dist/src/local/git_ops.js +1 -1
- package/dist/src/local/git_parse.d.ts +2 -2
- package/dist/src/local/git_parse.js +2 -2
- package/dist/src/local/git_revision.d.ts +1 -1
- package/dist/src/local/git_revision.js +1 -1
- package/dist/src/local/git_untracked.d.ts +1 -1
- package/dist/src/local/git_untracked.js +1 -1
- package/dist/src/local/review_local.js +17 -14
- package/dist/src/local/review_lock.d.ts +1 -1
- package/dist/src/local/review_lock.js +1 -1
- package/dist/src/pr/checkout.js +2 -2
- package/dist/src/pr/findings.js +1 -1
- package/dist/src/pr/findings_json.d.ts +2 -2
- package/dist/src/pr/findings_json.js +1 -1
- package/dist/src/pr/github_revision.js +1 -1
- package/dist/src/pr/review_copy.d.ts +2 -2
- package/dist/src/pr/review_copy.js +2 -2
- package/dist/src/pr/reviewer.d.ts +6 -0
- package/dist/src/pr/reviewer.js +49 -39
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +84 -23
- package/dist/src/remote/fixtures.js +1 -1
- package/dist/src/remote/http.d.ts +10 -1
- package/dist/src/remote/http.js +15 -3
- package/dist/src/remote/schema.d.ts +3 -3
- package/dist/src/remote/schema.js +3 -3
- package/dist/src/remote/server/body.d.ts +1 -1
- package/dist/src/remote/server/body.js +1 -1
- package/dist/src/remote/server/byok_keys.d.ts +15 -0
- package/dist/src/remote/server/byok_keys.js +40 -0
- package/dist/src/remote/server/guides.js +2 -2
- package/dist/src/remote/server/routes.js +8 -1
- package/dist/src/remote/validate.js +15 -1
- package/dist/src/review/blocking.d.ts +2 -2
- package/dist/src/review/blocking.js +2 -2
- package/dist/src/review/carry_over.d.ts +2 -2
- package/dist/src/review/carry_over.js +2 -2
- package/dist/src/review/guides.js +1 -1
- package/dist/src/server/api/remote_tokens.js +2 -0
- package/dist/src/server/api/repos.js +51 -0
- package/dist/src/server/api/settings.js +10 -0
- package/dist/src/server/errors.d.ts +1 -1
- package/dist/src/server/http.js +1 -1
- package/dist/src/server/pages/activity.d.ts +2 -0
- package/dist/src/server/pages/activity.js +18 -4
- package/dist/src/server/pages/add_repo.js +61 -1
- package/dist/src/server/pages/analytics.js +18 -8
- package/dist/src/server/pages/cost.d.ts +21 -0
- package/dist/src/server/pages/cost.js +72 -0
- package/dist/src/server/pages/pr.js +5 -4
- package/dist/src/server/pages/repo.js +3 -2
- package/dist/src/server/pages/repo_prs.js +3 -2
- package/dist/src/server/pages/repo_remote.js +3 -2
- package/dist/src/server/pages/repo_settings.js +326 -12
- package/dist/src/server/pages/router.js +3 -2
- package/dist/src/server/pages/settings.js +50 -6
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/credentials.js +8 -11
- package/dist/src/services/dashboard.d.ts +22 -9
- package/dist/src/services/dashboard.js +16 -2
- package/dist/src/services/probe.js +1 -1
- package/dist/src/services/remote_review.js +142 -35
- package/dist/src/services/remote_review_abort.d.ts +5 -1
- package/dist/src/services/remote_review_abort.js +9 -1
- package/dist/src/services/review.d.ts +1 -1
- package/dist/src/services/review.js +19 -5
- package/dist/src/services/review_policy.d.ts +116 -0
- package/dist/src/services/review_policy.js +489 -0
- package/dist/src/services/setup.d.ts +4 -3
- package/dist/src/services/setup.js +18 -16
- package/dist/src/services/setup_checklist.js +2 -2
- package/dist/src/services/webhook.d.ts +23 -0
- package/dist/src/services/webhook.js +141 -34
- package/dist/src/store/app_db.d.ts +5 -0
- package/dist/src/store/app_db.js +69 -13
- package/dist/src/store/backup.d.ts +31 -0
- package/dist/src/store/backup.js +158 -0
- package/dist/src/store/migrations.d.ts +1 -1
- package/dist/src/store/migrations.js +21 -1
- package/dist/src/store/remote_review_inputs.d.ts +3 -0
- package/dist/src/store/remote_review_inputs.js +3 -3
- package/dist/src/store/remote_tokens.d.ts +1 -1
- package/dist/src/store/remote_tokens.js +1 -1
- package/dist/src/store/repos.d.ts +2 -2
- package/dist/src/store/repos.js +1 -1
- package/dist/src/store/reviews.d.ts +21 -13
- package/dist/src/store/reviews.js +60 -15
- package/dist/src/store/rows.d.ts +10 -0
- package/dist/src/tools/codegraph.d.ts +4 -4
- package/dist/src/tools/codegraph.js +5 -5
- package/dist/src/tools/codegraph_exec.d.ts +1 -1
- package/dist/src/tools/codegraph_exec.js +1 -1
- package/dist/src/types.d.ts +12 -6
- package/dist/src/util/cost.d.ts +52 -0
- package/dist/src/util/cost.js +70 -0
- package/dist/src/util/log.d.ts +1 -1
- package/dist/src/util/log.js +2 -2
- package/dist/src/util/redact.js +2 -0
- package/dist/src/util/run_summary.d.ts +7 -2
- package/dist/src/util/run_summary.js +28 -2
- package/dist/src/util/time.d.ts +1 -1
- package/dist/src/util/time.js +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { VERSION } from "../version.js";
|
|
2
|
+
import { CliError } from "../cli/error.js";
|
|
2
3
|
import { resolvedFromFirstReview, revisionStats, sortResolvedFindings, summaryCounts, toJsonFinding, } from "../cli/review_result.js";
|
|
3
4
|
import { parseFindings } from "../pr/findings.js";
|
|
4
5
|
import { codegraphToolHandlersForBridge } from "../pr/codegraph_tools.js";
|
|
@@ -7,23 +8,51 @@ import { buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, increme
|
|
|
7
8
|
import { remoteBridgeToolHandlers } from "../remote/tool_bridge.js";
|
|
8
9
|
import { revisionFromSubmitJson } from "../remote/revision_from_submit.js";
|
|
9
10
|
import { closeRemoteSession, openRemoteSession, setRemoteSyncResult, } from "../remote/server/sessions.js";
|
|
10
|
-
import { readConfig } from "../config.js";
|
|
11
|
+
import { readConfig, remoteByokPolicy } from "../config.js";
|
|
12
|
+
import { dropByokKey, getByokKey, hasByokKey, registerByokKey, } from "../remote/server/byok_keys.js";
|
|
11
13
|
import { reviewBlockingFrom } from "../review/blocking.js";
|
|
12
14
|
import { loadGuides } from "../review/guides.js";
|
|
13
15
|
import { withLogSink } from "../util/log.js";
|
|
14
16
|
import { cancel, registerHandler } from "./jobs.js";
|
|
15
17
|
import { recordAiCost } from "./setup.js";
|
|
18
|
+
import { addResponseCost, costColumns, costUsage, emptyTally, settle, } from "../util/cost.js";
|
|
16
19
|
import { findRepoByFullName } from "../store/repos.js";
|
|
17
20
|
import { deleteRemoteReviewInput, findRemoteReviewInputByRequest, getRemoteReviewInput, insertRemoteReviewInput, } from "../store/remote_review_inputs.js";
|
|
18
21
|
import { insertFinding, listCarryableFindingsForReview, } from "../store/findings.js";
|
|
19
22
|
import { deleteSubjectRevision, getOrCreateRemoteSubject, getSubjectRevision, parseRevisionFiles, parseVisiblePaths, saveSubjectRevision, } from "../store/subjects.js";
|
|
20
23
|
import { getReviewByJobId, insertRemoteReview, setReviewStatus, } from "../store/reviews.js";
|
|
21
24
|
import { insertJob, listJobsByQueueKey, setJobStatus } from "../store/jobs.js";
|
|
25
|
+
import { getAppDb } from "../store/app_db.js";
|
|
22
26
|
const REPO_UNAVAILABLE = "Sorry, we could not access this repository.";
|
|
27
|
+
/** A remote review failure whose `code` the client can branch on. `runJob`
|
|
28
|
+
* stores `error.message`, so the text is what reaches the client's abort
|
|
29
|
+
* message. */
|
|
30
|
+
class RemoteReviewError extends Error {
|
|
31
|
+
code;
|
|
32
|
+
constructor(code, message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "RemoteReviewError";
|
|
35
|
+
this.code = code;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
23
38
|
function remoteQueueKey(repo, branch, tokenId) {
|
|
24
39
|
return `remote:${repo}:${branch}:${tokenId}`;
|
|
25
40
|
}
|
|
26
|
-
|
|
41
|
+
/** True only for a provider *authentication* rejection: the providers raise
|
|
42
|
+
* `openrouter_unauthorized`, `openai_unauthorized`, `anthropic_unauthorized`
|
|
43
|
+
* and `opencode_unauthorized`. The list is explicit so an unrelated error that
|
|
44
|
+
* happens to end in `_unauthorized` is not relabelled as a bad client key.
|
|
45
|
+
*/
|
|
46
|
+
function isProviderAuthError(error) {
|
|
47
|
+
return (error instanceof CliError &&
|
|
48
|
+
[
|
|
49
|
+
"openrouter_unauthorized",
|
|
50
|
+
"openai_unauthorized",
|
|
51
|
+
"anthropic_unauthorized",
|
|
52
|
+
"opencode_unauthorized",
|
|
53
|
+
].includes(error.code));
|
|
54
|
+
}
|
|
55
|
+
function reviewOptionsForRepo(repo, useCodegraph, aiToken) {
|
|
27
56
|
const config = readConfig();
|
|
28
57
|
return {
|
|
29
58
|
command: "review",
|
|
@@ -37,7 +66,7 @@ function reviewOptionsForRepo(repo, useCodegraph) {
|
|
|
37
66
|
auth: config.auth ?? "gh",
|
|
38
67
|
githubPat: config.githubPat,
|
|
39
68
|
ai: config.ai ?? "openrouter",
|
|
40
|
-
aiToken: config.token,
|
|
69
|
+
aiToken: aiToken ?? config.token,
|
|
41
70
|
lowModel: config.lowModel,
|
|
42
71
|
highModel: config.highModel,
|
|
43
72
|
synthesisVersion: 16,
|
|
@@ -54,10 +83,16 @@ function supersedeActiveRemoteJobs(queueKey, supersededBy) {
|
|
|
54
83
|
for (const job of listJobsByQueueKey(queueKey, ["queued", "running"])) {
|
|
55
84
|
if (job.id === supersededBy)
|
|
56
85
|
continue;
|
|
86
|
+
// A superseded job never becomes terminal through `runJob`, so its BYOK
|
|
87
|
+
// key must be dropped here or it would sit in memory for the process
|
|
88
|
+
// lifetime, and its input row must go too so a later retry is not blocked
|
|
89
|
+
// by a dead row.
|
|
90
|
+
dropByokKey(job.id);
|
|
57
91
|
if (job.status === "running") {
|
|
58
92
|
cancel(job.id, "superseded");
|
|
59
93
|
}
|
|
60
94
|
else {
|
|
95
|
+
deleteRemoteReviewInput(job.id);
|
|
61
96
|
setJobStatus(job.id, "canceled", {
|
|
62
97
|
superseded_by: supersededBy,
|
|
63
98
|
cancel_reason: "superseded",
|
|
@@ -69,6 +104,16 @@ export function submitRemoteReview(token, body) {
|
|
|
69
104
|
const requestId = String(body.requestId);
|
|
70
105
|
const existing = findRemoteReviewInputByRequest(token.id, requestId);
|
|
71
106
|
if (existing) {
|
|
107
|
+
// A retried submit must not change who pays: the key is not in the body
|
|
108
|
+
// this time, so re-register it from the first request's recorded policy.
|
|
109
|
+
const bill = existing.billed_to === "byok" ? "byok" : "server";
|
|
110
|
+
if (bill === "byok" && !hasByokKey(existing.job_id)) {
|
|
111
|
+
return {
|
|
112
|
+
error: "Your key was not kept. Submit the review again.",
|
|
113
|
+
status: 409,
|
|
114
|
+
code: "byok_key_lost",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
72
117
|
const review = getReviewByJobId(existing.job_id);
|
|
73
118
|
if (review) {
|
|
74
119
|
return { jobId: existing.job_id, reviewId: review.id };
|
|
@@ -78,6 +123,24 @@ export function submitRemoteReview(token, body) {
|
|
|
78
123
|
if (!repoRow || repoRow.active !== 1) {
|
|
79
124
|
return { error: REPO_UNAVAILABLE, status: 404, code: "repo_unavailable" };
|
|
80
125
|
}
|
|
126
|
+
const byokRaw = body.byok;
|
|
127
|
+
const submittedKey = byokRaw && typeof byokRaw.key === "string" ? byokRaw.key : undefined;
|
|
128
|
+
const policy = remoteByokPolicy();
|
|
129
|
+
if (policy === "off" && submittedKey !== undefined) {
|
|
130
|
+
return {
|
|
131
|
+
error: "This server does not accept your own key.",
|
|
132
|
+
status: 403,
|
|
133
|
+
code: "byok_not_allowed",
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (policy === "require" && submittedKey === undefined) {
|
|
137
|
+
return {
|
|
138
|
+
error: "This server requires your own key. Run with --remote-byok.",
|
|
139
|
+
status: 403,
|
|
140
|
+
code: "byok_required",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const billedTo = submittedKey !== undefined ? "byok" : "server";
|
|
81
144
|
if (typeof body.branch !== "string") {
|
|
82
145
|
return {
|
|
83
146
|
error: "branch must be a string",
|
|
@@ -102,26 +165,50 @@ export function submitRemoteReview(token, body) {
|
|
|
102
165
|
capabilitiesJson: JSON.stringify(capabilities),
|
|
103
166
|
requestId,
|
|
104
167
|
tokenId: token.id,
|
|
168
|
+
billedTo,
|
|
105
169
|
});
|
|
170
|
+
// The review and job rows go in one transaction so a failure cannot leave a
|
|
171
|
+
// partially written submission behind: no review with no job, and no queued
|
|
172
|
+
// job whose input row points a retry at a job that never really started.
|
|
173
|
+
// The key is registered only after COMMIT, so it never outlives a failed
|
|
174
|
+
// setup.
|
|
106
175
|
const config = readConfig();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
const db = getAppDb();
|
|
177
|
+
db.exec("BEGIN");
|
|
178
|
+
try {
|
|
179
|
+
insertRemoteReview({
|
|
180
|
+
id: reviewId,
|
|
181
|
+
subjectId: subject.id,
|
|
182
|
+
repo: repoRow.full_name,
|
|
183
|
+
branch,
|
|
184
|
+
tokenId: token.id,
|
|
185
|
+
tokenName: token.name,
|
|
186
|
+
jobId,
|
|
187
|
+
scope: "remote",
|
|
188
|
+
model: config.highModel ?? "unknown",
|
|
189
|
+
});
|
|
190
|
+
insertJob({
|
|
191
|
+
id: jobId,
|
|
192
|
+
type: "remote_review",
|
|
193
|
+
repo: repoRow.full_name,
|
|
194
|
+
queueKey,
|
|
195
|
+
args: { subjectId: subject.id, requestId, reviewId },
|
|
196
|
+
});
|
|
197
|
+
db.exec("COMMIT");
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
db.exec("ROLLBACK");
|
|
201
|
+
// Nothing of this submission survives, so the input row must go too, or a
|
|
202
|
+
// retry with the same request id would find a row whose key is gone and be
|
|
203
|
+
// told byok_key_lost.
|
|
204
|
+
deleteRemoteReviewInput(jobId);
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
// The key is pulled out of the body before anything persists it: it lives
|
|
208
|
+
// only in this process's memory, tied to the job.
|
|
209
|
+
if (billedTo === "byok" && submittedKey !== undefined) {
|
|
210
|
+
registerByokKey(jobId, submittedKey);
|
|
211
|
+
}
|
|
125
212
|
openRemoteSession({
|
|
126
213
|
jobId,
|
|
127
214
|
reviewId,
|
|
@@ -147,22 +234,31 @@ export function registerRemoteReviewHandler() {
|
|
|
147
234
|
setReviewStatus(reviewId, "running");
|
|
148
235
|
log("info", `remote review started for ${job.repo}`);
|
|
149
236
|
let completed = false;
|
|
237
|
+
// A BYOK review runs on the client's key, never the server's. If the key
|
|
238
|
+
// is gone (a restart between submit and run), stop with a typed message
|
|
239
|
+
// instead of silently falling back to the server key.
|
|
240
|
+
const billedTo = input.billed_to === "byok" ? "byok" : "server";
|
|
241
|
+
const byokKey = billedTo === "byok" ? getByokKey(job.id) : undefined;
|
|
150
242
|
try {
|
|
243
|
+
if (billedTo === "byok" && byokKey === undefined) {
|
|
244
|
+
throw new RemoteReviewError("byok_key_lost", "Your key was not kept across a server restart. Submit the review again.");
|
|
245
|
+
}
|
|
151
246
|
const revision = revisionFromSubmitJson(JSON.parse(input.revision_json));
|
|
152
247
|
const caps = JSON.parse(input.capabilities_json);
|
|
153
248
|
const allowed = new Set((caps.tools ?? [])
|
|
154
249
|
.map((tool) => tool.name)
|
|
155
250
|
.filter((name) => typeof name === "string" && name));
|
|
156
251
|
const useCodegraph = allowed.size > 0;
|
|
157
|
-
const options = reviewOptionsForRepo(job.repo, useCodegraph);
|
|
252
|
+
const options = reviewOptionsForRepo(job.repo, useCodegraph, byokKey);
|
|
158
253
|
if (!options.aiToken || options.ai === "none") {
|
|
159
254
|
throw new Error("server AI is not configured for remote review");
|
|
160
255
|
}
|
|
256
|
+
const billed = byokKey !== undefined ? "byok" : "server";
|
|
161
257
|
const extras = {};
|
|
162
258
|
if (subjectId) {
|
|
163
259
|
const subjectRevision = getSubjectRevision(subjectId);
|
|
164
260
|
// A guide rebuilt after the previous review invalidates its verdicts
|
|
165
|
-
// and its "unchanged" suppression
|
|
261
|
+
// and its "unchanged" suppression: start fresh so a
|
|
166
262
|
// stale finding cannot mask a new one.
|
|
167
263
|
const guideRebuilt = subjectRevision !== undefined &&
|
|
168
264
|
guideRebuiltSince(subjectRevision.guide_built_at, (await loadGuides(job.repo)).guideBuiltAt);
|
|
@@ -198,15 +294,11 @@ export function registerRemoteReviewHandler() {
|
|
|
198
294
|
const started = performance.now();
|
|
199
295
|
let tokensIn = 0;
|
|
200
296
|
let tokensOut = 0;
|
|
201
|
-
|
|
202
|
-
let costKnown = true;
|
|
297
|
+
const costTally = emptyTally();
|
|
203
298
|
const result = await withLogSink((phase, message) => log("info", `[${phase}] ${message}`), () => reviewWorkspaceRevision(revision, options, "remote", async (response) => {
|
|
204
299
|
tokensIn += response.tokensIn;
|
|
205
300
|
tokensOut += response.tokensOut;
|
|
206
|
-
|
|
207
|
-
costKnown = false;
|
|
208
|
-
else
|
|
209
|
-
costUsd = (costUsd ?? 0) + response.cost;
|
|
301
|
+
addResponseCost(costTally, response);
|
|
210
302
|
await recordAiCost(job.repo, "remote_review", response);
|
|
211
303
|
}, undefined, (message) => log("info", message), extras));
|
|
212
304
|
if (signal.aborted)
|
|
@@ -227,11 +319,7 @@ export function registerRemoteReviewHandler() {
|
|
|
227
319
|
codegraph: { state: result.codegraphState },
|
|
228
320
|
summary: summaryCounts(findings, blockingMode),
|
|
229
321
|
findings: findings.map((row) => toJsonFinding(row, blockingMode)),
|
|
230
|
-
usage: {
|
|
231
|
-
tokensIn,
|
|
232
|
-
tokensOut,
|
|
233
|
-
costUsd: costKnown ? costUsd : null,
|
|
234
|
-
},
|
|
322
|
+
usage: { tokensIn, tokensOut, ...costUsage(costTally) },
|
|
235
323
|
remote: { jobId: job.id, reviewId, clientVersion: VERSION },
|
|
236
324
|
});
|
|
237
325
|
setReviewStatus(reviewId, "done", {
|
|
@@ -240,7 +328,8 @@ export function registerRemoteReviewHandler() {
|
|
|
240
328
|
duration_ms: durationMs,
|
|
241
329
|
tokens_in: tokensIn,
|
|
242
330
|
tokens_out: tokensOut,
|
|
243
|
-
|
|
331
|
+
billed_to: billed,
|
|
332
|
+
...costColumns(settle(costTally)),
|
|
244
333
|
});
|
|
245
334
|
if (subjectId) {
|
|
246
335
|
saveSubjectRevision({
|
|
@@ -271,11 +360,29 @@ export function registerRemoteReviewHandler() {
|
|
|
271
360
|
completed = true;
|
|
272
361
|
log("info", `remote review finished (${findings.length} findings)`);
|
|
273
362
|
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
// Only an authentication rejection proves the client's key is bad, so
|
|
365
|
+
// only that becomes `byok_rejected`. A provider outage, a parse error
|
|
366
|
+
// or a local failure must surface as itself rather than telling the
|
|
367
|
+
// user their key was refused. `byok_key_lost` is thrown before this
|
|
368
|
+
// try, so it is not remapped.
|
|
369
|
+
if (byokKey !== undefined &&
|
|
370
|
+
!signal.aborted &&
|
|
371
|
+
isProviderAuthError(error)) {
|
|
372
|
+
throw new RemoteReviewError("byok_rejected", "The AI provider rejected your own key.");
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
274
376
|
finally {
|
|
377
|
+
dropByokKey(job.id);
|
|
275
378
|
if (!completed) {
|
|
379
|
+
// Reached on failure and on the aborted early return above.
|
|
276
380
|
if (signal.aborted) {
|
|
277
381
|
setReviewStatus(reviewId, "aborted");
|
|
278
382
|
}
|
|
383
|
+
else {
|
|
384
|
+
setReviewStatus(reviewId, "failed");
|
|
385
|
+
}
|
|
279
386
|
deleteRemoteReviewInput(job.id);
|
|
280
387
|
closeRemoteSession(job.id);
|
|
281
388
|
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
/** Queued remote reviews never enter the handler; sync the review row when the
|
|
2
|
-
* job is canceled from the dashboard or token deactivation.
|
|
2
|
+
* job is canceled from the dashboard or token deactivation. The BYOK key and
|
|
3
|
+
* the input row are dropped here too: a queued job canceled before it ever
|
|
4
|
+
* runs must not leave the client's key in memory, and a later retry with the
|
|
5
|
+
* same request id must be free to start over instead of finding a dead row
|
|
6
|
+
* whose key is gone. */
|
|
3
7
|
export declare function abortQueuedRemoteReviewRow(jobId: string): void;
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { getReviewByJobId, setReviewStatus } from "../store/reviews.js";
|
|
2
|
+
import { deleteRemoteReviewInput } from "../store/remote_review_inputs.js";
|
|
3
|
+
import { dropByokKey } from "../remote/server/byok_keys.js";
|
|
2
4
|
/** Queued remote reviews never enter the handler; sync the review row when the
|
|
3
|
-
* job is canceled from the dashboard or token deactivation.
|
|
5
|
+
* job is canceled from the dashboard or token deactivation. The BYOK key and
|
|
6
|
+
* the input row are dropped here too: a queued job canceled before it ever
|
|
7
|
+
* runs must not leave the client's key in memory, and a later retry with the
|
|
8
|
+
* same request id must be free to start over instead of finding a dead row
|
|
9
|
+
* whose key is gone. */
|
|
4
10
|
export function abortQueuedRemoteReviewRow(jobId) {
|
|
11
|
+
dropByokKey(jobId);
|
|
12
|
+
deleteRemoteReviewInput(jobId);
|
|
5
13
|
const review = getReviewByJobId(jobId);
|
|
6
14
|
if (!review || review.kind !== "remote")
|
|
7
15
|
return;
|
|
@@ -14,7 +14,7 @@ export declare function splitInline(findings: ParsedFinding[], files: Json[]): {
|
|
|
14
14
|
leftover: ParsedFinding[];
|
|
15
15
|
};
|
|
16
16
|
export declare function reviewEvent(findingsCount: number): "REQUEST_CHANGES" | "COMMENT";
|
|
17
|
-
/** The GitHub review event for a set of stored findings
|
|
17
|
+
/** The GitHub review event for a set of stored findings.
|
|
18
18
|
*
|
|
19
19
|
* `model` keeps the 0.4.13 behavior: any finding requests changes, because the
|
|
20
20
|
* model's label already decided. `severity` ignores that label and requests
|
|
@@ -10,6 +10,7 @@ import { stripSuggestion, suggestionAnchor, withoutSuggestionLine, } from "../pr
|
|
|
10
10
|
import { githubPullRequestToRevision } from "../pr/github_revision.js";
|
|
11
11
|
import { anchorTextFromPatch, buildCarryPromptSection, classifyCarryItems, guideRebuiltSince, incrementalDiffPaths, parsePreviousVerdicts, resolveCarryOutcomes, } from "../review/carry_over.js";
|
|
12
12
|
import { runReviewEngine } from "../review/engine.js";
|
|
13
|
+
import { addResponseCost, costColumns, emptyTally, settle, } from "../util/cost.js";
|
|
13
14
|
import { loadGuides } from "../review/guides.js";
|
|
14
15
|
import { reviewBlockingFrom, isBlocking, DEFAULT_REVIEW_BLOCKING, } from "../review/blocking.js";
|
|
15
16
|
import { matchRepeat } from "../pr/rounds.js";
|
|
@@ -26,7 +27,7 @@ export function humanCopy(text) {
|
|
|
26
27
|
// The semicolon is prose and always goes. The em dash is left alone here:
|
|
27
28
|
// this function also sanitizes a finding title, and a title posted before
|
|
28
29
|
// 0.5.0 uses ` — ` as its path/symbol separator, which the legacy heading
|
|
29
|
-
// reader still needs (
|
|
30
|
+
// reader still needs (the copy guard keeps that one allowlisted exception).
|
|
30
31
|
return outsideCode(redact(text), (prose) => prose.replaceAll(";", "."));
|
|
31
32
|
}
|
|
32
33
|
function hasForbiddenCopy(text) {
|
|
@@ -66,7 +67,7 @@ export function splitInline(findings, files) {
|
|
|
66
67
|
export function reviewEvent(findingsCount) {
|
|
67
68
|
return findingsCount > 0 ? "REQUEST_CHANGES" : "COMMENT";
|
|
68
69
|
}
|
|
69
|
-
/** The GitHub review event for a set of stored findings
|
|
70
|
+
/** The GitHub review event for a set of stored findings.
|
|
70
71
|
*
|
|
71
72
|
* `model` keeps the 0.4.13 behavior: any finding requests changes, because the
|
|
72
73
|
* model's label already decided. `severity` ignores that label and requests
|
|
@@ -139,6 +140,8 @@ export function reviewOptions(repo, prNumber) {
|
|
|
139
140
|
ghConcurrent: 1,
|
|
140
141
|
aiConcurrent: 1,
|
|
141
142
|
auth: "gh",
|
|
143
|
+
// With no provider saved the App review stays on OpenRouter, the same
|
|
144
|
+
// fallback the CLI review uses.
|
|
142
145
|
ai: config.ai && config.ai !== "none" ? config.ai : "openrouter",
|
|
143
146
|
aiToken: config.token,
|
|
144
147
|
highModel: config.highModel,
|
|
@@ -457,6 +460,16 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
457
460
|
}
|
|
458
461
|
const options = reviewOptions(job.repo, job.pr_number);
|
|
459
462
|
const args = JSON.parse(job.args || "{}");
|
|
463
|
+
// A maintainer asked for this review by label or comment: acknowledge it.
|
|
464
|
+
// Best effort, a missing reaction must never fail the review itself.
|
|
465
|
+
if (args.requestReaction && !existing) {
|
|
466
|
+
try {
|
|
467
|
+
await post(github, args.requestReaction, { content: "eyes" });
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
log("info", `could not react to the review request: ${String(error)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
460
473
|
const pr = await github.request(`repos/${job.repo}/pulls/${job.pr_number}`);
|
|
461
474
|
const headSha = String(pr.head?.sha ?? "");
|
|
462
475
|
const mergeBase = String(pr.base?.sha ?? "");
|
|
@@ -546,7 +559,7 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
546
559
|
let carryPrevious = null;
|
|
547
560
|
const extras = {};
|
|
548
561
|
// A guide rebuilt after the previous review invalidates that review's
|
|
549
|
-
// verdicts and its "unchanged" suppression
|
|
562
|
+
// verdicts and its "unchanged" suppression. Treat the run as
|
|
550
563
|
// if it had started fresh: carry-over off, every changed file back in scope,
|
|
551
564
|
// so a stale finding can never mask a new one.
|
|
552
565
|
const guideRebuilt = subjectRevision !== undefined &&
|
|
@@ -575,7 +588,8 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
575
588
|
carryItems = classifyCarryItems(carryPrevious, revision, carryPrevious.visiblePaths, guidesBefore.guideBuiltAt);
|
|
576
589
|
extras.carryPrompt = buildCarryPromptSection(carryItems, revision);
|
|
577
590
|
}
|
|
578
|
-
const
|
|
591
|
+
const costTally = emptyTally();
|
|
592
|
+
const response = await runReviewEngine(github, options, async (call) => addResponseCost(costTally, call), snapshot, ai ?? aiFor(options), (message) => log("info", message), extras);
|
|
579
593
|
guideBuiltAt = response.guideBuiltAt;
|
|
580
594
|
const visiblePaths = new Set(response.visiblePaths);
|
|
581
595
|
if (carryPrevious) {
|
|
@@ -650,7 +664,7 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
650
664
|
closed_count: closedCount,
|
|
651
665
|
tokens_in: response.tokensIn,
|
|
652
666
|
tokens_out: response.tokensOut,
|
|
653
|
-
|
|
667
|
+
...costColumns(settle(costTally)),
|
|
654
668
|
});
|
|
655
669
|
saveSubjectRevision({
|
|
656
670
|
subjectId: subject.id,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** The review policy engine.
|
|
2
|
+
*
|
|
3
|
+
* A policy is an ordered list of rules. The first rule whose conditions all
|
|
4
|
+
* hold decides what happens to a pull request, and when none holds the policy's
|
|
5
|
+
* default action does. This file is pure: no database, no GitHub, no clock, so
|
|
6
|
+
* every branch of it is a table test. `webhook.ts` gathers the facts and acts on
|
|
7
|
+
* the answer. */
|
|
8
|
+
export declare const ASSOCIATIONS: readonly ["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER", "NONE"];
|
|
9
|
+
export type Association = (typeof ASSOCIATIONS)[number];
|
|
10
|
+
export declare const ACTIONS: readonly ["review", "on-request", "skip"];
|
|
11
|
+
export type PolicyAction = (typeof ACTIONS)[number];
|
|
12
|
+
export type RuleWhen = {
|
|
13
|
+
association?: Association[];
|
|
14
|
+
fork?: boolean;
|
|
15
|
+
draft?: boolean;
|
|
16
|
+
bot?: boolean;
|
|
17
|
+
/** The pull request has at least one of these labels. */
|
|
18
|
+
labels?: string[];
|
|
19
|
+
targetBranch?: string[];
|
|
20
|
+
changedLines?: {
|
|
21
|
+
min?: number;
|
|
22
|
+
max?: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
export type PolicyRule = {
|
|
26
|
+
name?: string;
|
|
27
|
+
when: RuleWhen;
|
|
28
|
+
action: PolicyAction;
|
|
29
|
+
/** A fixed skip reason. Only the rules derived from the old per repository
|
|
30
|
+
* switches set it, so their skips read exactly as they always did. */
|
|
31
|
+
reason?: string;
|
|
32
|
+
};
|
|
33
|
+
export type ReviewPolicy = {
|
|
34
|
+
rules: PolicyRule[];
|
|
35
|
+
default: PolicyAction;
|
|
36
|
+
defaultReason?: string;
|
|
37
|
+
requestLabel: string;
|
|
38
|
+
requestCommand: string;
|
|
39
|
+
requesters: Association[];
|
|
40
|
+
/** `head`: a request covers the commit it was made on. `pull-request`: the
|
|
41
|
+
* first request covers every later push. */
|
|
42
|
+
approvalScope: "head" | "pull-request";
|
|
43
|
+
/** Reviews a pull request may get from the webhook. Unset means no limit. */
|
|
44
|
+
maxRounds?: number;
|
|
45
|
+
};
|
|
46
|
+
export type PolicyInput = Partial<ReviewPolicy>;
|
|
47
|
+
/** What is known about a pull request. A fact GitHub did not send is left out,
|
|
48
|
+
* and a rule that asks about it does not match. */
|
|
49
|
+
export type PolicyFacts = {
|
|
50
|
+
association?: string;
|
|
51
|
+
fork?: boolean;
|
|
52
|
+
draft: boolean;
|
|
53
|
+
bot: boolean;
|
|
54
|
+
labels?: string[];
|
|
55
|
+
targetBranch?: string;
|
|
56
|
+
changedLines?: number;
|
|
57
|
+
};
|
|
58
|
+
export type PolicyDecision = {
|
|
59
|
+
action: PolicyAction;
|
|
60
|
+
/** 1 based position of the rule that decided, `null` for the default. */
|
|
61
|
+
rule: number | null;
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
export declare const DEFAULT_POLICY_SETTINGS: {
|
|
65
|
+
readonly requestLabel: "co-maintainer:review";
|
|
66
|
+
readonly requestCommand: "/co-maintainer review";
|
|
67
|
+
readonly requesters: ("OWNER" | "MEMBER" | "COLLABORATOR" | "CONTRIBUTOR" | "FIRST_TIME_CONTRIBUTOR" | "FIRST_TIMER" | "NONE")[];
|
|
68
|
+
readonly approvalScope: "head";
|
|
69
|
+
};
|
|
70
|
+
export declare const TEMPLATES: Record<string, PolicyInput>;
|
|
71
|
+
export declare const TEMPLATE_NAMES: string[];
|
|
72
|
+
export declare function withDefaults(input: PolicyInput): ReviewPolicy;
|
|
73
|
+
/** The policy the three old per repository switches amount to, so a repository
|
|
74
|
+
* nobody has given a policy keeps behaving exactly as before. Switched off
|
|
75
|
+
* means everything is skipped, and the drafts and bots switches only matter
|
|
76
|
+
* while it is on. */
|
|
77
|
+
export declare function legacyPolicy(settings: {
|
|
78
|
+
auto_review: number;
|
|
79
|
+
skip_drafts: number;
|
|
80
|
+
skip_bots: number;
|
|
81
|
+
}): ReviewPolicy;
|
|
82
|
+
export declare function evaluatePolicy(policy: ReviewPolicy, facts: PolicyFacts): PolicyDecision;
|
|
83
|
+
/** Whether a pull request that a policy leaves waiting for a request may be
|
|
84
|
+
* reviewed without a new one. Only the `pull-request` scope carries an earlier
|
|
85
|
+
* request forward, and only once something was actually reviewed or queued. */
|
|
86
|
+
export declare function carriesEarlierRequest(policy: ReviewPolicy, hasEarlierReview: boolean): boolean;
|
|
87
|
+
/** Whether `body` starts with the request command as a whole word. */
|
|
88
|
+
export declare function isRequestCommand(body: string, command: string): boolean;
|
|
89
|
+
type Check<T> = {
|
|
90
|
+
ok: true;
|
|
91
|
+
value: T;
|
|
92
|
+
} | {
|
|
93
|
+
ok: false;
|
|
94
|
+
problem: string;
|
|
95
|
+
};
|
|
96
|
+
/** Turns stored or user supplied policy data into a policy, or says what is
|
|
97
|
+
* wrong with it. A string names a template, an object is a full policy where
|
|
98
|
+
* every setting but the rules can be left out. */
|
|
99
|
+
export declare function parsePolicy(value: unknown): Check<ReviewPolicy>;
|
|
100
|
+
/** What the dashboard shows next to each template, one plain sentence each. */
|
|
101
|
+
export declare const TEMPLATE_INFO: {
|
|
102
|
+
name: string;
|
|
103
|
+
label: string;
|
|
104
|
+
description: string;
|
|
105
|
+
}[];
|
|
106
|
+
/** The policy as short sentences a maintainer can read before saving it. */
|
|
107
|
+
export declare function describePolicy(policy: ReviewPolicy): string[];
|
|
108
|
+
/** What to store for a policy: the template's name when the policy is exactly
|
|
109
|
+
* that template with the default settings, the whole policy otherwise. */
|
|
110
|
+
export declare function storedPolicyValue(policy: ReviewPolicy): string | ReviewPolicy;
|
|
111
|
+
/** What a form or API caller sent, as the value to store. `null` clears the
|
|
112
|
+
* choice, and `"legacy"` (the simple switches) is only for one repository. */
|
|
113
|
+
export declare function policyToStore(value: unknown, options: {
|
|
114
|
+
allowLegacy: boolean;
|
|
115
|
+
}): Check<string | null>;
|
|
116
|
+
export {};
|