co-maintainer 0.5.1-beta.3 → 0.5.1
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/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +2 -2
- package/dist/src/ai/anthropic.js +6 -3
- package/dist/src/ai/openai.js +3 -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/server/api/settings.js +16 -1
- package/dist/src/services/remote_review.js +15 -8
- 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/src/ai/hetzner.d.ts +0 -16
- package/dist/src/ai/hetzner.js +0 -69
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Murat Kirazkaya
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
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
|
@@ -26,8 +26,7 @@ export function anthropicError(model, status, body) {
|
|
|
26
26
|
if (status === 401 || status === 403) {
|
|
27
27
|
return new CliError("anthropic_unauthorized", "Anthropic rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
|
|
28
28
|
}
|
|
29
|
-
if ((status === 400 || status === 404) &&
|
|
30
|
-
(type === "not_found_error" || /model/i.test(detail))) {
|
|
29
|
+
if ((status === 400 || status === 404) && type === "not_found_error") {
|
|
31
30
|
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
31
|
}
|
|
33
32
|
return new CliError("anthropic_failed", `Anthropic failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
|
|
@@ -201,7 +200,11 @@ export function parseMessagesBody(json, model) {
|
|
|
201
200
|
return {
|
|
202
201
|
text: texts.join(""),
|
|
203
202
|
...(toolCalls.length ? { toolCalls } : {}),
|
|
204
|
-
|
|
203
|
+
// Tokens read from or written to the prompt cache are input too, and with
|
|
204
|
+
// the guides cached they are most of it.
|
|
205
|
+
tokensIn: Number(usage?.input_tokens ?? 0) +
|
|
206
|
+
Number(usage?.cache_read_input_tokens ?? 0) +
|
|
207
|
+
Number(usage?.cache_creation_input_tokens ?? 0),
|
|
205
208
|
tokensOut: Number(usage?.output_tokens ?? 0),
|
|
206
209
|
// Anthropic reports only token counts, never a dollar cost, so this is
|
|
207
210
|
// always unknown (`costReasonText`'s "provider_did_not_report").
|
package/dist/src/ai/openai.js
CHANGED
|
@@ -22,7 +22,9 @@ export function openAiError(model, status, body) {
|
|
|
22
22
|
if (status === 401) {
|
|
23
23
|
return new CliError("openai_unauthorized", "OpenAI rejected the API key.", "Set a new one with co-maintainer set --token=...", EXIT_USAGE);
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// Only the code says the model is unknown. Other 400s mention "model" too,
|
|
26
|
+
// a context that is too long or a setting this model does not take.
|
|
27
|
+
if ((status === 400 || status === 404) && code === "model_not_found") {
|
|
26
28
|
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
29
|
}
|
|
28
30
|
return new CliError("openai_failed", `OpenAI failed with ${status}${detail ? `: ${detail}` : "."}`, undefined, EXIT_RUNTIME);
|
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
|
|
@@ -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
|
}
|
|
@@ -250,7 +250,12 @@ export function registerRemoteReviewHandler() {
|
|
|
250
250
|
.filter((name) => typeof name === "string" && name));
|
|
251
251
|
const useCodegraph = allowed.size > 0;
|
|
252
252
|
const options = reviewOptionsForRepo(job.repo, useCodegraph, byokKey);
|
|
253
|
-
|
|
253
|
+
// A BYOK client brings a key but not models, so a server with no
|
|
254
|
+
// models cannot run its review either.
|
|
255
|
+
if (!options.aiToken ||
|
|
256
|
+
options.ai === "none" ||
|
|
257
|
+
!options.lowModel ||
|
|
258
|
+
!options.highModel) {
|
|
254
259
|
throw new Error("server AI is not configured for remote review");
|
|
255
260
|
}
|
|
256
261
|
const billed = byokKey !== undefined ? "byok" : "server";
|
|
@@ -319,7 +324,12 @@ export function registerRemoteReviewHandler() {
|
|
|
319
324
|
codegraph: { state: result.codegraphState },
|
|
320
325
|
summary: summaryCounts(findings, blockingMode),
|
|
321
326
|
findings: findings.map((row) => toJsonFinding(row, blockingMode)),
|
|
322
|
-
usage: {
|
|
327
|
+
usage: {
|
|
328
|
+
tokensIn,
|
|
329
|
+
tokensOut,
|
|
330
|
+
...costUsage(costTally),
|
|
331
|
+
billedTo: billed,
|
|
332
|
+
},
|
|
323
333
|
remote: { jobId: job.id, reviewId, clientVersion: VERSION },
|
|
324
334
|
});
|
|
325
335
|
setReviewStatus(reviewId, "done", {
|
|
@@ -377,12 +387,9 @@ export function registerRemoteReviewHandler() {
|
|
|
377
387
|
dropByokKey(job.id);
|
|
378
388
|
if (!completed) {
|
|
379
389
|
// Reached on failure and on the aborted early return above.
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
}
|
|
383
|
-
else {
|
|
384
|
-
setReviewStatus(reviewId, "failed");
|
|
385
|
-
}
|
|
390
|
+
setReviewStatus(reviewId, signal.aborted ? "aborted" : "failed", {
|
|
391
|
+
billed_to: billedTo,
|
|
392
|
+
});
|
|
386
393
|
deleteRemoteReviewInput(job.id);
|
|
387
394
|
closeRemoteSession(job.id);
|
|
388
395
|
}
|
|
@@ -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.
|
package/package.json
CHANGED
package/dist/src/ai/hetzner.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { AiProvider, AiRequest, AiResponse } from "../types.ts";
|
|
2
|
-
declare const sleep: (milliseconds: number) => Promise<void>;
|
|
3
|
-
export declare class HetznerProvider implements AiProvider {
|
|
4
|
-
readonly supportsTools = false;
|
|
5
|
-
private readonly endpoint;
|
|
6
|
-
private readonly apiKey;
|
|
7
|
-
private readonly model;
|
|
8
|
-
private readonly sleeper;
|
|
9
|
-
private nextRequest;
|
|
10
|
-
private spacing;
|
|
11
|
-
constructor(apiKey: string, model: string, endpoint?: string, sleeper?: typeof sleep);
|
|
12
|
-
complete(request: AiRequest): Promise<AiResponse>;
|
|
13
|
-
private waitForQuota;
|
|
14
|
-
private jitter;
|
|
15
|
-
}
|
|
16
|
-
export {};
|
package/dist/src/ai/hetzner.js
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { chatBody, parseChatResponse } from "./provider.js";
|
|
2
|
-
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
3
|
-
export class HetznerProvider {
|
|
4
|
-
supportsTools = false;
|
|
5
|
-
endpoint;
|
|
6
|
-
apiKey;
|
|
7
|
-
model;
|
|
8
|
-
sleeper;
|
|
9
|
-
nextRequest = 0;
|
|
10
|
-
spacing = 8_000;
|
|
11
|
-
constructor(apiKey, model, endpoint = "https://inference.hetzner.com/api/v1/chat/completions", sleeper = sleep) {
|
|
12
|
-
if (!apiKey)
|
|
13
|
-
throw new Error("Hetzner requires HETZNER_API_KEY");
|
|
14
|
-
if (!model)
|
|
15
|
-
throw new Error("Hetzner requires HETZNER_MODEL");
|
|
16
|
-
this.apiKey = apiKey;
|
|
17
|
-
this.model = model;
|
|
18
|
-
this.sleeper = sleeper;
|
|
19
|
-
this.endpoint = endpoint;
|
|
20
|
-
}
|
|
21
|
-
async complete(request) {
|
|
22
|
-
let attempt = 0;
|
|
23
|
-
for (;;) {
|
|
24
|
-
attempt++;
|
|
25
|
-
await this.waitForQuota();
|
|
26
|
-
const response = await fetch(this.endpoint, {
|
|
27
|
-
method: "POST",
|
|
28
|
-
headers: {
|
|
29
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
30
|
-
"Content-Type": "application/json",
|
|
31
|
-
},
|
|
32
|
-
body: JSON.stringify(chatBody(this.model, request)),
|
|
33
|
-
});
|
|
34
|
-
this.nextRequest = Date.now() + this.spacing;
|
|
35
|
-
if (response.ok) {
|
|
36
|
-
this.spacing = Math.max(8_000, this.spacing * 0.9);
|
|
37
|
-
return parseChatResponse((await response.json()), "hetzner", this.model);
|
|
38
|
-
}
|
|
39
|
-
if ([401, 403, 404].includes(response.status)) {
|
|
40
|
-
throw new Error(`Hetzner fatal ${response.status}: ${await response.text()}`);
|
|
41
|
-
}
|
|
42
|
-
if (response.status === 429) {
|
|
43
|
-
this.spacing = Math.min(180_000, this.spacing * 1.5);
|
|
44
|
-
const wait = this.jitter(this.spacing);
|
|
45
|
-
console.log(`[ai] hetzner 429 on attempt ${attempt}; retrying in ${Math.round(wait / 1000)}s`);
|
|
46
|
-
await this.sleeper(wait);
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (response.status >= 500 || response.status === 408) {
|
|
50
|
-
const wait = this.jitter(Math.min(180_000, this.spacing * 1.5));
|
|
51
|
-
console.log(`[ai] hetzner ${response.status} on attempt ${attempt}; retrying in ${Math.round(wait / 1000)}s`);
|
|
52
|
-
await this.sleeper(wait);
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
throw new Error(`Hetzner ${response.status}: ${await response.text()}`);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async waitForQuota() {
|
|
59
|
-
const wait = this.nextRequest - Date.now();
|
|
60
|
-
if (wait > 0) {
|
|
61
|
-
const jittered = this.jitter(wait);
|
|
62
|
-
console.log(`[ai] hetzner quota spacing, waiting ${Math.round(jittered / 1000)}s`);
|
|
63
|
-
await this.sleeper(jittered);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
jitter(milliseconds) {
|
|
67
|
-
return Math.max(250, Math.round(milliseconds * (0.8 + Math.random() * 0.4)));
|
|
68
|
-
}
|
|
69
|
-
}
|