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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1-beta.4",
3
+ "version": "0.5.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",
@@ -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 and the
23
- * `cache_read_input_tokens` count shows up in the usage log.
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
@@ -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: texts.join(""),
206
+ text,
203
207
  ...(toolCalls.length ? { toolCalls } : {}),
204
- tokensIn: Number(usage?.input_tokens ?? 0),
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").
@@ -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
- if (status === 400 && (code === "model_not_found" || /model/i.test(detail))) {
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: texts.join(""),
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 response = await this.post(responsesBody(this.model, request));
186
- // A model may reject `text.format` next to tools. The schema is only a
187
- // hint (the prompt also asks for a fenced JSON block), so one retry
188
- // without it beats failing the review outright.
189
- if (response.status === 400 && request.responseFormat) {
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
- throw openAiError(this.model, response.status, detail);
200
- }
201
- if (!response.ok) {
202
- throw openAiError(this.model, response.status, await response.text());
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 response = await this.post(chatBody(this.model, request));
79
- // A provider may refuse `response_format` next to `tools`. The schema is
80
- // only a hint — the prompt also asks for a fenced JSON block — so one
81
- // retry without it is better than failing the review.
82
- if (response.status === 400 && request.responseFormat) {
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
- throw this.fail(response.status, detail);
93
- }
94
- if (!response.ok) {
95
- throw this.fail(response.status, await response.text());
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;
@@ -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: String(message?.content ?? ""),
93
+ text,
66
94
  ...(toolCalls?.length ? { toolCalls } : {}),
67
95
  tokensIn: Number(usage?.prompt_tokens ?? 0),
68
96
  tokensOut: Number(usage?.completion_tokens ?? 0),
@@ -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.token;
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
- const effectiveHighModel = effective("highModel");
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("\n")
126
+ .split(/\r\n|\r|\n/)
126
127
  .map((line) => (line.trimStart().startsWith("::") ? `[log] ${line}` : line))
127
128
  .join("\n");
128
129
  }
@@ -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. */
@@ -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.token);
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) {
@@ -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 writeFile(`${temp}/config.json`, await readFile(paths.config));
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 writeFile(destinations[file], await readFile(`${slot(paths)}/${file}`));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.5.1-beta.4",
3
+ "version": "0.5.2",
4
4
  "description": "An imitator of yours, A PR review tool like co-maintainer.",
5
5
  "keywords": [
6
6
  "ai",