co-maintainer 0.5.1-beta.4 → 0.5.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/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +2 -2
- package/dist/src/ai/anthropic.js +12 -4
- package/dist/src/ai/openai.js +21 -20
- package/dist/src/ai/openrouter.js +13 -19
- package/dist/src/ai/provider.d.ts +9 -0
- package/dist/src/ai/provider.js +29 -1
- package/dist/src/cli/args.js +2 -2
- package/dist/src/cli/commands/config.d.ts +1 -1
- package/dist/src/cli/commands/config.js +2 -0
- package/dist/src/cli/commands/set.js +11 -2
- package/dist/src/cli/review_github.js +2 -1
- package/dist/src/config.d.ts +11 -4
- package/dist/src/config.js +16 -5
- package/dist/src/knowledge/synthesis.js +3 -0
- package/dist/src/server/api/settings.js +16 -1
- package/dist/src/services/review.js +9 -1
- package/dist/src/services/webhook.d.ts +6 -0
- package/dist/src/services/webhook.js +24 -0
- package/dist/src/store/backup.js +10 -3
- package/package.json +1 -1
package/dist/package.json
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
*
|
|
20
20
|
* `cache_control` is set on the system prompt, which every review sends as the
|
|
21
21
|
* same guide prefix. Anthropic caches the longest stable prefix, so the
|
|
22
|
-
* guides are billed at the cached rate from the second review on
|
|
23
|
-
* `
|
|
22
|
+
* guides are billed at the cached rate from the second review on. The cached
|
|
23
|
+
* tokens are counted in `tokensIn` with the rest of the input.
|
|
24
24
|
*
|
|
25
25
|
* ponytail: this talks to Messages without streaming. The original design
|
|
26
26
|
* called for the SDK's `stream()` + `finalMessage()` helper for large `max_tokens` (a single
|
package/dist/src/ai/anthropic.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getEnv } from "../util/runtime.js";
|
|
2
|
+
import { outputLimitError } from "./provider.js";
|
|
2
3
|
import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
|
|
3
4
|
/** Required on every Messages request and the only version the errors below
|
|
4
5
|
* are written against. */
|
|
@@ -26,8 +27,7 @@ export function anthropicError(model, status, body) {
|
|
|
26
27
|
if (status === 401 || status === 403) {
|
|
27
28
|
return new CliError("anthropic_unauthorized", "Anthropic rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
|
|
28
29
|
}
|
|
29
|
-
if ((status === 400 || status === 404) &&
|
|
30
|
-
(type === "not_found_error" || /model/i.test(detail))) {
|
|
30
|
+
if ((status === 400 || status === 404) && type === "not_found_error") {
|
|
31
31
|
return new CliError("anthropic_unknown_model", `Anthropic does not know the model ${model}.`, "Pick one at https://docs.anthropic.com/en/docs/about-claude/models and set it with co-maintainer set --high-model=...", EXIT_USAGE);
|
|
32
32
|
}
|
|
33
33
|
return new CliError("anthropic_failed", `Anthropic failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
|
|
@@ -198,10 +198,18 @@ export function parseMessagesBody(json, model) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
const usage = json.usage;
|
|
201
|
+
const text = texts.join("");
|
|
202
|
+
if (json.stop_reason === "max_tokens" && !text.trim() && !toolCalls.length) {
|
|
203
|
+
throw outputLimitError("anthropic", model);
|
|
204
|
+
}
|
|
201
205
|
return {
|
|
202
|
-
text
|
|
206
|
+
text,
|
|
203
207
|
...(toolCalls.length ? { toolCalls } : {}),
|
|
204
|
-
|
|
208
|
+
// Tokens read from or written to the prompt cache are input too, and with
|
|
209
|
+
// the guides cached they are most of it.
|
|
210
|
+
tokensIn: Number(usage?.input_tokens ?? 0) +
|
|
211
|
+
Number(usage?.cache_read_input_tokens ?? 0) +
|
|
212
|
+
Number(usage?.cache_creation_input_tokens ?? 0),
|
|
205
213
|
tokensOut: Number(usage?.output_tokens ?? 0),
|
|
206
214
|
// Anthropic reports only token counts, never a dollar cost, so this is
|
|
207
215
|
// always unknown (`costReasonText`'s "provider_did_not_report").
|
package/dist/src/ai/openai.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getEnv } from "../util/runtime.js";
|
|
2
|
+
import { outputLimitError, withoutRefusedPart } from "./provider.js";
|
|
2
3
|
import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
|
|
3
4
|
/** `CM_OPENAI_URL` points at a local fake in tests. Unset means the real
|
|
4
5
|
* endpoint, so production behavior is unchanged. */
|
|
@@ -22,7 +23,9 @@ export function openAiError(model, status, body) {
|
|
|
22
23
|
if (status === 401) {
|
|
23
24
|
return new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
+
// Only the code says the model is unknown. Other 400s mention "model" too,
|
|
27
|
+
// a context that is too long or a setting this model does not take.
|
|
28
|
+
if ((status === 400 || status === 404) && code === "model_not_found") {
|
|
26
29
|
return new CliError("openai_unknown_model", `OpenAI does not know the model ${model}.`, "Pick one at https://platform.openai.com/docs/models and set it with co-maintainer set --high-model=...", EXIT_USAGE);
|
|
27
30
|
}
|
|
28
31
|
return new CliError("openai_failed", `OpenAI failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
|
|
@@ -143,8 +146,12 @@ function parseResponsesBody(json, model) {
|
|
|
143
146
|
}
|
|
144
147
|
}
|
|
145
148
|
const usage = json.usage;
|
|
149
|
+
const text = texts.join("");
|
|
150
|
+
if (json.status === "incomplete" && !text.trim() && !toolCalls.length) {
|
|
151
|
+
throw outputLimitError("openai", model);
|
|
152
|
+
}
|
|
146
153
|
return {
|
|
147
|
-
text
|
|
154
|
+
text,
|
|
148
155
|
...(toolCalls.length ? { toolCalls } : {}),
|
|
149
156
|
tokensIn: Number(usage?.input_tokens ?? 0),
|
|
150
157
|
tokensOut: Number(usage?.output_tokens ?? 0),
|
|
@@ -182,25 +189,19 @@ export class OpenAiProvider {
|
|
|
182
189
|
}
|
|
183
190
|
}
|
|
184
191
|
async complete(request) {
|
|
185
|
-
let
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const detail = await response.text();
|
|
191
|
-
if (/text\.format|json_schema|structured/i.test(detail)) {
|
|
192
|
-
const { responseFormat: _dropped, ...withoutSchema } = request;
|
|
193
|
-
response = await this.post(responsesBody(this.model, withoutSchema));
|
|
194
|
-
if (response.ok) {
|
|
195
|
-
return parseResponsesBody((await response.json()), this.model);
|
|
196
|
-
}
|
|
197
|
-
throw openAiError(this.model, response.status, await response.text());
|
|
192
|
+
let current = request;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const response = await this.post(responsesBody(this.model, current));
|
|
195
|
+
if (response.ok) {
|
|
196
|
+
return parseResponsesBody((await response.json()), this.model);
|
|
198
197
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
const detail = await response.text();
|
|
199
|
+
const lighter = response.status === 400
|
|
200
|
+
? withoutRefusedPart(current, detail, /text\.format|json_schema|structured/i)
|
|
201
|
+
: undefined;
|
|
202
|
+
if (!lighter)
|
|
203
|
+
throw openAiError(this.model, response.status, detail);
|
|
204
|
+
current = lighter;
|
|
203
205
|
}
|
|
204
|
-
return parseResponsesBody((await response.json()), this.model);
|
|
205
206
|
}
|
|
206
207
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chatBody, parseChatResponse } from "./provider.js";
|
|
1
|
+
import { chatBody, parseChatResponse, withoutRefusedPart } from "./provider.js";
|
|
2
2
|
import { getEnv } from "../util/runtime.js";
|
|
3
3
|
import { CliError, EXIT_RUNTIME, EXIT_USAGE, networkFailure, } from "../cli/error.js";
|
|
4
4
|
/** `CM_OPENROUTER_URL` points the provider at a local fake in tests. It is
|
|
@@ -75,25 +75,19 @@ export class OpenRouterProvider {
|
|
|
75
75
|
return openRouterError(this.model, status, body, this.host);
|
|
76
76
|
}
|
|
77
77
|
async complete(request) {
|
|
78
|
-
let
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const detail = await response.text();
|
|
84
|
-
if (/response_format|json_schema|structured/i.test(detail)) {
|
|
85
|
-
const { responseFormat: _dropped, ...withoutSchema } = request;
|
|
86
|
-
response = await this.post(chatBody(this.model, withoutSchema));
|
|
87
|
-
if (response.ok) {
|
|
88
|
-
return parseChatResponse((await response.json()), this.host.provider, this.model);
|
|
89
|
-
}
|
|
90
|
-
throw this.fail(response.status, await response.text());
|
|
78
|
+
let current = request;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const response = await this.post(chatBody(this.model, current));
|
|
81
|
+
if (response.ok) {
|
|
82
|
+
return parseChatResponse((await response.json()), this.host.provider, this.model);
|
|
91
83
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
84
|
+
const detail = await response.text();
|
|
85
|
+
const lighter = response.status === 400
|
|
86
|
+
? withoutRefusedPart(current, detail, /response_format|json_schema|structured/i)
|
|
87
|
+
: undefined;
|
|
88
|
+
if (!lighter)
|
|
89
|
+
throw this.fail(response.status, detail);
|
|
90
|
+
current = lighter;
|
|
96
91
|
}
|
|
97
|
-
return parseChatResponse((await response.json()), this.host.provider, this.model);
|
|
98
92
|
}
|
|
99
93
|
}
|
|
@@ -7,5 +7,14 @@ export { providerKeyEnv } from "../config.ts";
|
|
|
7
7
|
/** Hetzner was dropped in 0.5.1. Naming it beats a generic "must be one of". */
|
|
8
8
|
export declare function rejectRetiredProvider(value: string | undefined): void;
|
|
9
9
|
export declare function createAiProvider(options: Options, model: string): AiProvider | undefined;
|
|
10
|
+
/** A 400 that names one optional part of the request earns one retry without
|
|
11
|
+
* it: the JSON schema, which the prompt asks for anyway, or "thinking off",
|
|
12
|
+
* which a model that always thinks refuses. Undefined when nothing is left to
|
|
13
|
+
* drop, so the caller reports the error. */
|
|
14
|
+
export declare function withoutRefusedPart(request: AiRequest, detail: string, schemaPattern: RegExp): AiRequest | undefined;
|
|
15
|
+
/** The model stopped at its output limit before writing a word. Thinking counts
|
|
16
|
+
* toward that limit, so a thinking model can spend all of it there. Without
|
|
17
|
+
* this the empty answer read as a normal one and failed later as "not JSON". */
|
|
18
|
+
export declare function outputLimitError(provider: string, model: string): Error;
|
|
10
19
|
export declare function parseChatResponse(json: Json, provider: AiResponse["provider"], model: string): AiResponse;
|
|
11
20
|
export declare function chatBody(model: string, request: AiRequest): Json;
|
package/dist/src/ai/provider.js
CHANGED
|
@@ -38,6 +38,28 @@ export function createAiProvider(options, model) {
|
|
|
38
38
|
}
|
|
39
39
|
die(`${options.ai} support is not finished yet. Use openrouter or none.`);
|
|
40
40
|
}
|
|
41
|
+
/** A 400 that names one optional part of the request earns one retry without
|
|
42
|
+
* it: the JSON schema, which the prompt asks for anyway, or "thinking off",
|
|
43
|
+
* which a model that always thinks refuses. Undefined when nothing is left to
|
|
44
|
+
* drop, so the caller reports the error. */
|
|
45
|
+
export function withoutRefusedPart(request, detail, schemaPattern) {
|
|
46
|
+
if (request.responseFormat && schemaPattern.test(detail)) {
|
|
47
|
+
const { responseFormat: _dropped, ...rest } = request;
|
|
48
|
+
return rest;
|
|
49
|
+
}
|
|
50
|
+
if (request.reasoningEffort === "none" &&
|
|
51
|
+
/reasoning|effort|thinking/i.test(detail)) {
|
|
52
|
+
const { reasoningEffort: _dropped, ...rest } = request;
|
|
53
|
+
return rest;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** The model stopped at its output limit before writing a word. Thinking counts
|
|
58
|
+
* toward that limit, so a thinking model can spend all of it there. Without
|
|
59
|
+
* this the empty answer read as a normal one and failed later as "not JSON". */
|
|
60
|
+
export function outputLimitError(provider, model) {
|
|
61
|
+
return new Error(`${provider} model ${model} reached its output limit before writing an answer. Thinking counts toward that limit.`);
|
|
62
|
+
}
|
|
41
63
|
export function parseChatResponse(json, provider, model) {
|
|
42
64
|
const choice = Array.isArray(json.choices)
|
|
43
65
|
? json.choices[0]
|
|
@@ -61,8 +83,14 @@ export function parseChatResponse(json, provider, model) {
|
|
|
61
83
|
})
|
|
62
84
|
.filter((call) => call.id && call.function.name)
|
|
63
85
|
: undefined;
|
|
86
|
+
const text = String(message?.content ?? "");
|
|
87
|
+
if (choice?.finish_reason === "length" &&
|
|
88
|
+
!text.trim() &&
|
|
89
|
+
!toolCalls?.length) {
|
|
90
|
+
throw outputLimitError(provider, model);
|
|
91
|
+
}
|
|
64
92
|
return {
|
|
65
|
-
text
|
|
93
|
+
text,
|
|
66
94
|
...(toolCalls?.length ? { toolCalls } : {}),
|
|
67
95
|
tokensIn: Number(usage?.prompt_tokens ?? 0),
|
|
68
96
|
tokensOut: Number(usage?.completion_tokens ?? 0),
|
package/dist/src/cli/args.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { prepareConfig } from "../config.js";
|
|
1
|
+
import { prepareConfig, savedTokenFor } from "../config.js";
|
|
2
2
|
import { getEnv } from "../util/runtime.js";
|
|
3
3
|
import { askLine } from "./prompt.js";
|
|
4
4
|
import { die } from "./error.js";
|
|
@@ -211,7 +211,7 @@ export async function parseArgs(args) {
|
|
|
211
211
|
let aiToken = text("token") ??
|
|
212
212
|
env("CO_MAINTAINER_TOKEN") ??
|
|
213
213
|
providerKeyEnv(ai, env) ??
|
|
214
|
-
config
|
|
214
|
+
savedTokenFor(config, ai);
|
|
215
215
|
// The OPENROUTER_ names would hand an OpenRouter model id to another
|
|
216
216
|
// provider, so they only count when OpenRouter is the one running.
|
|
217
217
|
const openRouterModelEnv = (name) => ai === "openrouter" ? env(name) : undefined;
|
|
@@ -37,7 +37,7 @@ export declare function resolveField(name: string): string;
|
|
|
37
37
|
* `defaults` are nested maps, not settings, so they are not listed.
|
|
38
38
|
* `satisfies` makes this the type's own vocabulary: a name that is not a
|
|
39
39
|
* `UserConfig` key is a compile error rather than a silently undefined read. */
|
|
40
|
-
export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars", "remoteByok", "remoteByokDefault", "reviewPolicy"];
|
|
40
|
+
export declare const KNOWN_FIELDS: readonly ["auth", "ai", "lowModel", "highModel", "token", "githubPat", "githubAppId", "githubAppPrivateKey", "githubAppPrivateKeyPath", "githubWebhookSecret", "webhookUrl", "githubOAuthClientId", "githubOAuthClientSecret", "githubOAuthAllowedUser", "passwordAuthDisabled", "githubAuthEnabled", "dashboardPasswordHash", "maxConcurrentJobs", "reviewBlocking", "remoteHost", "remoteToken", "remoteNoticeShownFor", "remoteSyncTimeoutSeconds", "maxConcurrentRemoteReviewsPerToken", "remoteToolOutputMaxChars", "remoteByok", "remoteByokDefault", "remoteByokPolicy", "reviewPolicy"];
|
|
41
41
|
/** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
|
|
42
42
|
* command delegates to so there is one implementation of the write rules. */
|
|
43
43
|
export declare function runConfig(args: string[]): Promise<void>;
|
|
@@ -49,6 +49,7 @@ export const ENV_BY_FIELD = {
|
|
|
49
49
|
remoteHost: ["CM_REMOTE_HOST"],
|
|
50
50
|
remoteToken: ["CM_REMOTE_TOKEN"],
|
|
51
51
|
reviewBlocking: ["CO_MAINTAINER_REVIEW_BLOCKING"],
|
|
52
|
+
remoteByokPolicy: ["CM_REMOTE_BYOK_POLICY"],
|
|
52
53
|
};
|
|
53
54
|
/** Renders one field for people. A secret is masked; the password hash, which
|
|
54
55
|
* nobody can use, only reports whether it is set. */
|
|
@@ -156,6 +157,7 @@ export const KNOWN_FIELDS = [
|
|
|
156
157
|
"remoteToolOutputMaxChars",
|
|
157
158
|
"remoteByok",
|
|
158
159
|
"remoteByokDefault",
|
|
160
|
+
"remoteByokPolicy",
|
|
159
161
|
"reviewPolicy",
|
|
160
162
|
];
|
|
161
163
|
/** `co-maintainer config <sub> ...`. `set` is handled by runSet, which this
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readConfig, configPath, writeUserConfig } from "../../config.js";
|
|
1
|
+
import { readConfig, configPath, savedKeyProvider, writeUserConfig, } from "../../config.js";
|
|
2
2
|
import { hashPassword, passwordProblem } from "../../util/password.js";
|
|
3
3
|
import { readTextFile } from "../../util/runtime.js";
|
|
4
4
|
import { verifyAnthropic, verifyOpenAi, verifyOpenRouter, } from "../../ai/verify.js";
|
|
@@ -266,7 +266,16 @@ export async function runSet(args) {
|
|
|
266
266
|
const effective = (field) => field in patch ? patch[field] : before[field];
|
|
267
267
|
const effectiveAi = effective("ai");
|
|
268
268
|
const effectiveToken = effective("token");
|
|
269
|
-
|
|
269
|
+
// The saved key and model belong to the saved provider. A switch without a
|
|
270
|
+
// new key would hand the old provider's key to the new one.
|
|
271
|
+
const previousAi = savedKeyProvider(before);
|
|
272
|
+
const switching = typeof patch.ai === "string" &&
|
|
273
|
+
patch.ai !== "none" &&
|
|
274
|
+
patch.ai !== previousAi;
|
|
275
|
+
if (switching && !("token" in patch) && before.token) {
|
|
276
|
+
die(`The saved key is for ${previousAi}. Pass --token= with the ${String(patch.ai)} key to switch providers.`);
|
|
277
|
+
}
|
|
278
|
+
const effectiveHighModel = switching && !("highModel" in patch) ? undefined : effective("highModel");
|
|
270
279
|
const verifiers = {
|
|
271
280
|
openrouter: verifyOpenRouter,
|
|
272
281
|
openai: verifyOpenAi,
|
|
@@ -121,8 +121,9 @@ export function emitGithubOutput(input) {
|
|
|
121
121
|
* printed on stderr, and the runner reads workflow commands from stderr too.
|
|
122
122
|
* A line that would start one is given a prefix so it stays plain text. */
|
|
123
123
|
export function neutralizeCommands(text) {
|
|
124
|
+
// The runner also ends a line at a bare carriage return.
|
|
124
125
|
return text
|
|
125
|
-
.split(
|
|
126
|
+
.split(/\r\n|\r|\n/)
|
|
126
127
|
.map((line) => (line.trimStart().startsWith("::") ? `[log] ${line}` : line))
|
|
127
128
|
.join("\n");
|
|
128
129
|
}
|
package/dist/src/config.d.ts
CHANGED
|
@@ -93,10 +93,6 @@ export type UserConfig = {
|
|
|
93
93
|
repos?: Record<string, RepoConfig>;
|
|
94
94
|
};
|
|
95
95
|
export type ByokPolicy = "off" | "allow" | "require";
|
|
96
|
-
/** Who pays for a remote review's AI calls. `CM_REMOTE_BYOK_POLICY`
|
|
97
|
-
* overrides the saved config, so a hosted server cannot be talked out of its
|
|
98
|
-
* policy by a config edit. An unset or unreadable value falls back to the
|
|
99
|
-
* config, then to `off`. */
|
|
100
96
|
/** The provider's own key variable. It is read only when that provider runs,
|
|
101
97
|
* so a key meant for one provider is never sent to another. */
|
|
102
98
|
export declare function providerKeyEnv(ai: string, env?: (name: string) => string | undefined): string | undefined;
|
|
@@ -105,8 +101,19 @@ export declare function providerKeyEnv(ai: string, env?: (name: string) => strin
|
|
|
105
101
|
* then the saved key. Without this a server started with the key in its
|
|
106
102
|
* environment reported AI as not configured and failed every job. */
|
|
107
103
|
export declare function resolvedAiToken(config: Pick<UserConfig, "ai" | "token">, ai?: string | undefined): string | undefined;
|
|
104
|
+
/** The provider the saved key belongs to. A config from before providers
|
|
105
|
+
* were saved holds an OpenRouter key, and so does one where AI was switched
|
|
106
|
+
* off, which the dashboard writes for such a config. */
|
|
107
|
+
export declare function savedKeyProvider(config: Pick<UserConfig, "ai">): string;
|
|
108
|
+
/** The saved key belongs to the saved provider, so a run on any other
|
|
109
|
+
* provider never receives it. */
|
|
110
|
+
export declare function savedTokenFor(config: Pick<UserConfig, "ai" | "token">, ai: string): string | undefined;
|
|
108
111
|
/** True when a provider is chosen and a key reaches it from anywhere. */
|
|
109
112
|
export declare function aiConfigured(config: Pick<UserConfig, "ai" | "token">): boolean;
|
|
113
|
+
/** Who pays for a remote review's AI calls. `CM_REMOTE_BYOK_POLICY`
|
|
114
|
+
* overrides the saved config, so a hosted server cannot be talked out of its
|
|
115
|
+
* policy by a config edit. An unset or unreadable value falls back to the
|
|
116
|
+
* config, then to `off`. */
|
|
110
117
|
export declare function remoteByokPolicy(config?: Pick<UserConfig, "remoteByokPolicy">): ByokPolicy;
|
|
111
118
|
/** Platform/env injection keeps the OS branches testable: the
|
|
112
119
|
* migration's `"windows"` vs `"win32"` trap is invisible otherwise. */
|
package/dist/src/config.js
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
1
|
import { chmod, currentPlatform, getEnv, isNotFound, mkdir, readTextFileSync, setEnv, writeTextFile, } from "./util/runtime.js";
|
|
2
|
-
/** Who pays for a remote review's AI calls. `CM_REMOTE_BYOK_POLICY`
|
|
3
|
-
* overrides the saved config, so a hosted server cannot be talked out of its
|
|
4
|
-
* policy by a config edit. An unset or unreadable value falls back to the
|
|
5
|
-
* config, then to `off`. */
|
|
6
2
|
/** The provider's own key variable. It is read only when that provider runs,
|
|
7
3
|
* so a key meant for one provider is never sent to another. */
|
|
8
4
|
export function providerKeyEnv(ai, env = getEnv) {
|
|
@@ -20,12 +16,27 @@ export function providerKeyEnv(ai, env = getEnv) {
|
|
|
20
16
|
export function resolvedAiToken(config, ai = config.ai) {
|
|
21
17
|
return (getEnv("CO_MAINTAINER_TOKEN") ??
|
|
22
18
|
(ai ? providerKeyEnv(ai) : undefined) ??
|
|
23
|
-
config
|
|
19
|
+
savedTokenFor(config, ai ?? "openrouter"));
|
|
20
|
+
}
|
|
21
|
+
/** The provider the saved key belongs to. A config from before providers
|
|
22
|
+
* were saved holds an OpenRouter key, and so does one where AI was switched
|
|
23
|
+
* off, which the dashboard writes for such a config. */
|
|
24
|
+
export function savedKeyProvider(config) {
|
|
25
|
+
return config.ai && config.ai !== "none" ? config.ai : "openrouter";
|
|
26
|
+
}
|
|
27
|
+
/** The saved key belongs to the saved provider, so a run on any other
|
|
28
|
+
* provider never receives it. */
|
|
29
|
+
export function savedTokenFor(config, ai) {
|
|
30
|
+
return savedKeyProvider(config) === ai ? config.token : undefined;
|
|
24
31
|
}
|
|
25
32
|
/** True when a provider is chosen and a key reaches it from anywhere. */
|
|
26
33
|
export function aiConfigured(config) {
|
|
27
34
|
return Boolean(config.ai && config.ai !== "none" && resolvedAiToken(config));
|
|
28
35
|
}
|
|
36
|
+
/** Who pays for a remote review's AI calls. `CM_REMOTE_BYOK_POLICY`
|
|
37
|
+
* overrides the saved config, so a hosted server cannot be talked out of its
|
|
38
|
+
* policy by a config edit. An unset or unreadable value falls back to the
|
|
39
|
+
* config, then to `off`. */
|
|
29
40
|
export function remoteByokPolicy(config = readConfig()) {
|
|
30
41
|
// An env value that is not one of the three known policies is treated as
|
|
31
42
|
// unreadable and falls back to the config, so a typo cannot silently turn a
|
|
@@ -157,6 +157,9 @@ function extractRequest(prompt, maxTokens) {
|
|
|
157
157
|
job: "extract_unit",
|
|
158
158
|
prompt,
|
|
159
159
|
maxTokens,
|
|
160
|
+
// Pulling facts out of a text needs no thinking, and on a thinking model
|
|
161
|
+
// the thinking used the whole limit and left the answer empty.
|
|
162
|
+
reasoningEffort: "none",
|
|
160
163
|
system: "Extract only repository-specific facts. Never invent files, commands, or process.",
|
|
161
164
|
};
|
|
162
165
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { errorResponse } from "../errors.js";
|
|
2
2
|
import { readConfig, resolveAppPrivateKey, writeUserConfig, } from "../../config.js";
|
|
3
|
-
import { aiConfigured } from "../../config.js";
|
|
3
|
+
import { aiConfigured, savedKeyProvider, } from "../../config.js";
|
|
4
4
|
import { testAppAccess, testGithubAccess } from "../../services/credentials.js";
|
|
5
5
|
import { checkPassword, readSessionToken, revokeOtherSessions, } from "../auth.js";
|
|
6
6
|
import { passwordProblem } from "../../util/password.js";
|
|
@@ -99,6 +99,21 @@ export async function handleSettingsRoute(request, url, webhookUrl, passwords, i
|
|
|
99
99
|
text("githubOAuthClientId");
|
|
100
100
|
text("githubOAuthClientSecret");
|
|
101
101
|
text("githubOAuthAllowedUser");
|
|
102
|
+
const previousAi = savedKeyProvider(current);
|
|
103
|
+
if (patch.ai &&
|
|
104
|
+
patch.ai !== "none" &&
|
|
105
|
+
patch.ai !== previousAi &&
|
|
106
|
+
!patch.token &&
|
|
107
|
+
current.token) {
|
|
108
|
+
return errorResponse(422, "key_for_other_provider", `The saved key is for ${previousAi}. Enter the ${patch.ai} key to switch providers.`);
|
|
109
|
+
}
|
|
110
|
+
if ("remoteByokPolicy" in body) {
|
|
111
|
+
const policy = body.remoteByokPolicy;
|
|
112
|
+
if (policy !== "off" && policy !== "allow" && policy !== "require") {
|
|
113
|
+
return errorResponse(422, "invalid_setting", "remoteByokPolicy must be off, allow or require");
|
|
114
|
+
}
|
|
115
|
+
patch.remoteByokPolicy = policy;
|
|
116
|
+
}
|
|
102
117
|
if (typeof body.passwordAuthDisabled === "boolean") {
|
|
103
118
|
patch.passwordAuthDisabled = body.passwordAuthDisabled;
|
|
104
119
|
}
|
|
@@ -21,6 +21,7 @@ import { getReviewByJobId, insertReview, latestPostedReview, setReviewScope, set
|
|
|
21
21
|
import { insertFinding, listCarryableFindingsForReview, listFindingsForPr, listFindingsForReview, setFindingPosted, } from "../store/findings.js";
|
|
22
22
|
import { getOrCreatePrSubject, getSubjectRevision, parseRevisionFiles, parseVisiblePaths, saveSubjectRevision, } from "../store/subjects.js";
|
|
23
23
|
import { enqueue, registerHandler } from "./jobs.js";
|
|
24
|
+
import { requestedReviewBlocked } from "./webhook.js";
|
|
24
25
|
import { getEnv } from "../util/runtime.js";
|
|
25
26
|
const ACCESS_DENIED_BODY = "The App does not have access to review this pull request.";
|
|
26
27
|
export function humanCopy(text) {
|
|
@@ -460,6 +461,14 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
460
461
|
}
|
|
461
462
|
const options = reviewOptions(job.repo, job.pr_number);
|
|
462
463
|
const args = JSON.parse(job.args || "{}");
|
|
464
|
+
const pr = await github.request(`repos/${job.repo}/pulls/${job.pr_number}`);
|
|
465
|
+
if (args.trigger === "request-comment" && !existing) {
|
|
466
|
+
const blocked = requestedReviewBlocked(job.repo, pr);
|
|
467
|
+
if (blocked) {
|
|
468
|
+
log("info", `the review request was not run: ${blocked}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
463
472
|
// A maintainer asked for this review by label or comment: acknowledge it.
|
|
464
473
|
// Best effort, a missing reaction must never fail the review itself.
|
|
465
474
|
if (args.requestReaction && !existing) {
|
|
@@ -470,7 +479,6 @@ async function runReviewJobCore(job, log, client, ai) {
|
|
|
470
479
|
log("info", `could not react to the review request: ${String(error)}`);
|
|
471
480
|
}
|
|
472
481
|
}
|
|
473
|
-
const pr = await github.request(`repos/${job.repo}/pulls/${job.pr_number}`);
|
|
474
482
|
const headSha = String(pr.head?.sha ?? "");
|
|
475
483
|
const mergeBase = String(pr.base?.sha ?? "");
|
|
476
484
|
let scope = args.scope === "incremental" ? "incremental" : "whole-pr";
|
|
@@ -18,6 +18,12 @@ export type WebhookResult = {
|
|
|
18
18
|
prNumber?: number;
|
|
19
19
|
jobId?: string;
|
|
20
20
|
};
|
|
21
|
+
/** A comment request is decided from the issue payload, which says nothing
|
|
22
|
+
* about the fork, the target branch or the size. The review job asks again
|
|
23
|
+
* with the pull request it fetched, so a comment can never get past the size
|
|
24
|
+
* cap or a rule that a label request would hit. Returns why the review must
|
|
25
|
+
* not run, or undefined. */
|
|
26
|
+
export declare function requestedReviewBlocked(repo: string, pr: Record<string, unknown>): string | undefined;
|
|
21
27
|
export declare function maybeEnqueueReview(input: {
|
|
22
28
|
repo: string;
|
|
23
29
|
prNumber: number;
|
|
@@ -99,6 +99,30 @@ function pullRequestFacts(pr, repo) {
|
|
|
99
99
|
targetBranch: asString(asRecord(pr?.base)?.ref),
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
+
/** A comment request is decided from the issue payload, which says nothing
|
|
103
|
+
* about the fork, the target branch or the size. The review job asks again
|
|
104
|
+
* with the pull request it fetched, so a comment can never get past the size
|
|
105
|
+
* cap or a rule that a label request would hit. Returns why the review must
|
|
106
|
+
* not run, or undefined. */
|
|
107
|
+
export function requestedReviewBlocked(repo, pr) {
|
|
108
|
+
const row = getRepo(repo);
|
|
109
|
+
if (!row || row.active !== 1)
|
|
110
|
+
return "repo-not-active";
|
|
111
|
+
const config = readConfig();
|
|
112
|
+
const maxDiffLines = config.repos?.[repo]?.maxPullRequestChangeLines ??
|
|
113
|
+
config.defaults?.maxPullRequestChangeLines;
|
|
114
|
+
const changedLines = (asNumber(pr.additions) ?? 0) + (asNumber(pr.deletions) ?? 0);
|
|
115
|
+
if (maxDiffLines !== undefined && changedLines > maxDiffLines) {
|
|
116
|
+
return "diff-too-large";
|
|
117
|
+
}
|
|
118
|
+
const decision = evaluatePolicy(policyForRepo(row), {
|
|
119
|
+
...pullRequestFacts(pr, repo),
|
|
120
|
+
draft: asBool(pr.draft),
|
|
121
|
+
bot: isBot(asRecord(pr.user)),
|
|
122
|
+
changedLines,
|
|
123
|
+
});
|
|
124
|
+
return decision.action === "skip" ? decision.reason : undefined;
|
|
125
|
+
}
|
|
102
126
|
export function maybeEnqueueReview(input) {
|
|
103
127
|
const row = getRepo(input.repo);
|
|
104
128
|
if (!row || row.active !== 1) {
|
package/dist/src/store/backup.js
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* version only. Data written after the upgrade is lost by design. */
|
|
8
8
|
import { Database } from "./sqlite.js";
|
|
9
9
|
import { CliError, EXIT_USAGE } from "../cli/error.js";
|
|
10
|
-
import { isNotFound, mkdir, readFile, readTextFile, remove, rename, stat, writeFile, writeTextFile, } from "../util/runtime.js";
|
|
10
|
+
import { chmod, isNotFound, mkdir, readFile, readTextFile, remove, rename, stat, writeFile, writeTextFile, } from "../util/runtime.js";
|
|
11
|
+
// config.json holds the AI key, the PAT and the App key, so a copy of it is as
|
|
12
|
+
// private as the original. Windows has no POSIX modes.
|
|
13
|
+
async function writePrivate(path, data) {
|
|
14
|
+
await writeFile(path, data);
|
|
15
|
+
await chmod(path, 0o600).catch(() => { });
|
|
16
|
+
}
|
|
11
17
|
const slot = (paths) => `${paths.root}/previous`;
|
|
12
18
|
export const rolledBackDir = (paths) => `${paths.root}/rolled-back`;
|
|
13
19
|
async function exists(path) {
|
|
@@ -37,6 +43,7 @@ export async function takeBackup(paths, versions) {
|
|
|
37
43
|
const temp = `${slot(paths)}.tmp`;
|
|
38
44
|
await remove(temp, { recursive: true }).catch(() => { });
|
|
39
45
|
await mkdir(temp, { recursive: true });
|
|
46
|
+
await chmod(temp, 0o700).catch(() => { });
|
|
40
47
|
const files = ["app.db"];
|
|
41
48
|
copyDatabase(paths.appDb, `${temp}/app.db`);
|
|
42
49
|
if (await exists(paths.cacheDb)) {
|
|
@@ -44,7 +51,7 @@ export async function takeBackup(paths, versions) {
|
|
|
44
51
|
files.push("cache.db");
|
|
45
52
|
}
|
|
46
53
|
if (await exists(paths.config)) {
|
|
47
|
-
await
|
|
54
|
+
await writePrivate(`${temp}/config.json`, await readFile(paths.config));
|
|
48
55
|
files.push("config.json");
|
|
49
56
|
}
|
|
50
57
|
const manifest = {
|
|
@@ -144,7 +151,7 @@ export async function restoreBackup(paths) {
|
|
|
144
151
|
"config.json": paths.config,
|
|
145
152
|
};
|
|
146
153
|
for (const file of manifest.files) {
|
|
147
|
-
await
|
|
154
|
+
await writePrivate(destinations[file], await readFile(`${slot(paths)}/${file}`));
|
|
148
155
|
}
|
|
149
156
|
// The version marker must match the data again, or the next upgrade would
|
|
150
157
|
// record the wrong version to go back to.
|