co-maintainer 0.5.0 → 0.5.1-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +5 -3
- package/dist/package.json +1 -1
- package/dist/src/ai/anthropic.d.ts +55 -0
- package/dist/src/ai/anthropic.js +262 -0
- package/dist/src/ai/batch.d.ts +2 -2
- package/dist/src/ai/batch.js +1 -1
- package/dist/src/ai/estimate.d.ts +4 -0
- package/dist/src/ai/estimate.js +17 -2
- package/dist/src/ai/mermaid_loop.js +7 -4
- package/dist/src/ai/openai.d.ts +45 -0
- package/dist/src/ai/openai.js +206 -0
- package/dist/src/ai/opencode.d.ts +10 -0
- package/dist/src/ai/opencode.js +76 -0
- package/dist/src/ai/openrouter.d.ts +23 -7
- package/dist/src/ai/openrouter.js +39 -23
- package/dist/src/ai/pricing.js +1 -1
- package/dist/src/ai/provider.d.ts +9 -0
- package/dist/src/ai/provider.js +42 -3
- package/dist/src/ai/verify.d.ts +9 -1
- package/dist/src/ai/verify.js +81 -3
- package/dist/src/cli/args.d.ts +1 -2
- package/dist/src/cli/args.js +38 -26
- package/dist/src/cli/commands/config.d.ts +1 -1
- package/dist/src/cli/commands/config.js +10 -4
- package/dist/src/cli/commands/probe.js +3 -11
- package/dist/src/cli/commands/registry.d.ts +1 -9
- package/dist/src/cli/commands/registry.js +75 -10
- package/dist/src/cli/commands/review.js +18 -13
- package/dist/src/cli/commands/rollback.d.ts +1 -0
- package/dist/src/cli/commands/rollback.js +42 -0
- package/dist/src/cli/commands/serve.js +2 -3
- package/dist/src/cli/commands/set.js +71 -7
- package/dist/src/cli/commands/view.js +10 -9
- package/dist/src/cli/error.d.ts +4 -4
- package/dist/src/cli/error.js +4 -4
- package/dist/src/cli/main.d.ts +4 -4
- package/dist/src/cli/main.js +13 -8
- package/dist/src/cli/prompt.js +1 -1
- package/dist/src/cli/review_args.d.ts +9 -3
- package/dist/src/cli/review_args.js +28 -5
- package/dist/src/cli/review_github.d.ts +31 -0
- package/dist/src/cli/review_github.js +128 -0
- package/dist/src/cli/review_result.d.ts +27 -16
- package/dist/src/cli/review_result.js +7 -7
- package/dist/src/config.d.ts +28 -6
- package/dist/src/config.js +15 -1
- package/dist/src/git/default_branch.d.ts +1 -1
- package/dist/src/git/default_branch.js +1 -1
- package/dist/src/github/app.js +2 -2
- package/dist/src/github/app_manifest.d.ts +1 -7
- package/dist/src/github/app_manifest.js +2 -7
- package/dist/src/github/client.d.ts +4 -1
- package/dist/src/github/client.js +14 -2
- package/dist/src/github/gh.js +10 -7
- package/dist/src/github/pat.js +4 -4
- package/dist/src/github/permissions.d.ts +36 -0
- package/dist/src/github/permissions.js +334 -0
- package/dist/src/knowledge/facts.js +1 -1
- package/dist/src/knowledge/guide.d.ts +3 -3
- package/dist/src/knowledge/guide.js +5 -5
- package/dist/src/knowledge/probe.js +1 -2
- package/dist/src/knowledge/sections.d.ts +3 -3
- package/dist/src/knowledge/sections.js +3 -3
- package/dist/src/knowledge/skill.d.ts +1 -1
- package/dist/src/knowledge/skill.js +4 -4
- package/dist/src/knowledge/synthesis.d.ts +1 -1
- package/dist/src/knowledge/synthesis.js +5 -5
- package/dist/src/knowledge/types.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.d.ts +1 -1
- package/dist/src/local/codegraph_exclude.js +1 -1
- package/dist/src/local/codegraph_prepare.js +1 -1
- package/dist/src/local/git_ops.d.ts +1 -1
- package/dist/src/local/git_ops.js +1 -1
- package/dist/src/local/git_parse.d.ts +2 -2
- package/dist/src/local/git_parse.js +2 -2
- package/dist/src/local/git_revision.d.ts +1 -1
- package/dist/src/local/git_revision.js +1 -1
- package/dist/src/local/git_untracked.d.ts +1 -1
- package/dist/src/local/git_untracked.js +1 -1
- package/dist/src/local/review_local.js +17 -14
- package/dist/src/local/review_lock.d.ts +1 -1
- package/dist/src/local/review_lock.js +1 -1
- package/dist/src/pr/checkout.js +2 -2
- package/dist/src/pr/findings.js +1 -1
- package/dist/src/pr/findings_json.d.ts +2 -2
- package/dist/src/pr/findings_json.js +1 -1
- package/dist/src/pr/github_revision.js +1 -1
- package/dist/src/pr/review_copy.d.ts +2 -2
- package/dist/src/pr/review_copy.js +2 -2
- package/dist/src/pr/reviewer.d.ts +6 -0
- package/dist/src/pr/reviewer.js +49 -39
- package/dist/src/remote/client.d.ts +4 -0
- package/dist/src/remote/client.js +84 -23
- package/dist/src/remote/fixtures.js +1 -1
- package/dist/src/remote/http.d.ts +10 -1
- package/dist/src/remote/http.js +15 -3
- package/dist/src/remote/schema.d.ts +3 -3
- package/dist/src/remote/schema.js +3 -3
- package/dist/src/remote/server/body.d.ts +1 -1
- package/dist/src/remote/server/body.js +1 -1
- package/dist/src/remote/server/byok_keys.d.ts +15 -0
- package/dist/src/remote/server/byok_keys.js +40 -0
- package/dist/src/remote/server/guides.js +2 -2
- package/dist/src/remote/server/routes.js +8 -1
- package/dist/src/remote/validate.js +15 -1
- package/dist/src/review/blocking.d.ts +2 -2
- package/dist/src/review/blocking.js +2 -2
- package/dist/src/review/carry_over.d.ts +2 -2
- package/dist/src/review/carry_over.js +2 -2
- package/dist/src/review/guides.js +1 -1
- package/dist/src/server/api/remote_tokens.js +2 -0
- package/dist/src/server/api/repos.js +51 -0
- package/dist/src/server/api/settings.js +10 -0
- package/dist/src/server/errors.d.ts +1 -1
- package/dist/src/server/http.js +1 -1
- package/dist/src/server/pages/activity.d.ts +2 -0
- package/dist/src/server/pages/activity.js +18 -4
- package/dist/src/server/pages/add_repo.js +61 -1
- package/dist/src/server/pages/analytics.js +18 -8
- package/dist/src/server/pages/cost.d.ts +21 -0
- package/dist/src/server/pages/cost.js +72 -0
- package/dist/src/server/pages/pr.js +5 -4
- package/dist/src/server/pages/repo.js +3 -2
- package/dist/src/server/pages/repo_prs.js +3 -2
- package/dist/src/server/pages/repo_remote.js +3 -2
- package/dist/src/server/pages/repo_settings.js +326 -12
- package/dist/src/server/pages/router.js +3 -2
- package/dist/src/server/pages/settings.js +50 -6
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/credentials.js +8 -11
- package/dist/src/services/dashboard.d.ts +22 -9
- package/dist/src/services/dashboard.js +16 -2
- package/dist/src/services/probe.js +1 -1
- package/dist/src/services/remote_review.js +142 -35
- package/dist/src/services/remote_review_abort.d.ts +5 -1
- package/dist/src/services/remote_review_abort.js +9 -1
- package/dist/src/services/review.d.ts +1 -1
- package/dist/src/services/review.js +19 -5
- package/dist/src/services/review_policy.d.ts +116 -0
- package/dist/src/services/review_policy.js +489 -0
- package/dist/src/services/setup.d.ts +4 -3
- package/dist/src/services/setup.js +18 -16
- package/dist/src/services/setup_checklist.js +2 -2
- package/dist/src/services/webhook.d.ts +23 -0
- package/dist/src/services/webhook.js +141 -34
- package/dist/src/store/app_db.d.ts +5 -0
- package/dist/src/store/app_db.js +69 -13
- package/dist/src/store/backup.d.ts +31 -0
- package/dist/src/store/backup.js +158 -0
- package/dist/src/store/migrations.d.ts +1 -1
- package/dist/src/store/migrations.js +21 -1
- package/dist/src/store/remote_review_inputs.d.ts +3 -0
- package/dist/src/store/remote_review_inputs.js +3 -3
- package/dist/src/store/remote_tokens.d.ts +1 -1
- package/dist/src/store/remote_tokens.js +1 -1
- package/dist/src/store/repos.d.ts +2 -2
- package/dist/src/store/repos.js +1 -1
- package/dist/src/store/reviews.d.ts +21 -13
- package/dist/src/store/reviews.js +60 -15
- package/dist/src/store/rows.d.ts +10 -0
- package/dist/src/tools/codegraph.d.ts +4 -4
- package/dist/src/tools/codegraph.js +5 -5
- package/dist/src/tools/codegraph_exec.d.ts +1 -1
- package/dist/src/tools/codegraph_exec.js +1 -1
- package/dist/src/types.d.ts +12 -6
- package/dist/src/util/cost.d.ts +52 -0
- package/dist/src/util/cost.js +70 -0
- package/dist/src/util/log.d.ts +1 -1
- package/dist/src/util/log.js +2 -2
- package/dist/src/util/redact.js +2 -0
- package/dist/src/util/run_summary.d.ts +7 -2
- package/dist/src/util/run_summary.js +28 -2
- package/dist/src/util/time.d.ts +1 -1
- package/dist/src/util/time.js +1 -1
- package/package.json +1 -1
package/dist/src/remote/http.js
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
/** Shared HTTPS plumbing for the remote endpoints
|
|
1
|
+
/** Shared HTTPS plumbing for the remote endpoints.
|
|
2
2
|
*
|
|
3
3
|
* `review --remote` and `view --remote` talk to the same server with the same
|
|
4
4
|
* bearer token, so the base URL, the token header and the explanations for a
|
|
5
5
|
* rejected token or an unreachable host live here rather than in the review
|
|
6
6
|
* client alone. */
|
|
7
7
|
import { CliError, EXIT_USAGE, networkFailure } from "../cli/error.js";
|
|
8
|
-
|
|
8
|
+
import { getEnv } from "../util/runtime.js";
|
|
9
|
+
/** Where the remote server is, as an environment variable can say it.
|
|
10
|
+
* A CI job has no config file, and a token on the command line
|
|
11
|
+
* ends up in the process list, so `CM_REMOTE_HOST` and `CM_REMOTE_TOKEN` are
|
|
12
|
+
* read here. An empty value counts as unset, so a workflow secret that did not
|
|
13
|
+
* resolve falls through to the "not configured" error instead of a blank host. */
|
|
14
|
+
export function remoteFromEnv() {
|
|
15
|
+
return {
|
|
16
|
+
host: getEnv("CM_REMOTE_HOST") || undefined,
|
|
17
|
+
token: getEnv("CM_REMOTE_TOKEN") || undefined,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** A usage error the CLI prints with its hint and exit code. */
|
|
9
21
|
export function die(code, message, hint, exitCode = EXIT_USAGE) {
|
|
10
22
|
throw new CliError(code, message, hint, exitCode);
|
|
11
23
|
}
|
|
@@ -26,7 +38,7 @@ export async function remoteFetch(host, token, path, init) {
|
|
|
26
38
|
try {
|
|
27
39
|
const response = await fetch(url, { ...init, headers });
|
|
28
40
|
// The server's 401 text is `invalid or missing token`, which does not say
|
|
29
|
-
// where the token comes from or how to get another one
|
|
41
|
+
// where the token comes from or how to get another one.
|
|
30
42
|
if (response.status === 401 || response.status === 403) {
|
|
31
43
|
die("remote_token_rejected", `The server at ${baseUrl(host)} rejected the remote review token.`, "Create one in its dashboard under Settings, Remote review tokens.", EXIT_USAGE);
|
|
32
44
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** Remote review wire protocol
|
|
1
|
+
/** Remote review wire protocol. */
|
|
2
2
|
export declare const REMOTE_SCHEMA_VERSION = 1;
|
|
3
3
|
export declare const MIN_CLIENT_SCHEMA = 1;
|
|
4
4
|
export declare const MIN_SERVER_SCHEMA = 1;
|
|
5
|
-
/** README install line for 426 responses
|
|
5
|
+
/** README install line for 426 responses. */
|
|
6
6
|
export declare const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
|
|
7
7
|
export declare const REMOTE_MAX_BODY_BYTES = 52428800;
|
|
8
8
|
export declare const REMOTE_SYNC_INTERVAL_SECONDS = 3;
|
|
@@ -11,5 +11,5 @@ export declare const REMOTE_SYNC_INTERVAL_SECONDS = 3;
|
|
|
11
11
|
export declare const REMOTE_FIXTURE_HASH: Record<number, string>;
|
|
12
12
|
export declare const REMOTE_SYNC_STATUSES: readonly ["queued", "running", "done", "failed", "canceled"];
|
|
13
13
|
export type RemoteSyncStatus = (typeof REMOTE_SYNC_STATUSES)[number];
|
|
14
|
-
/** Tool names the server may invoke via the bridge
|
|
14
|
+
/** Tool names the server may invoke via the bridge. */
|
|
15
15
|
export declare const REMOTE_KNOWN_TOOL_NAMES: Set<string>;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
/** Remote review wire protocol
|
|
1
|
+
/** Remote review wire protocol. */
|
|
2
2
|
export const REMOTE_SCHEMA_VERSION = 1;
|
|
3
3
|
export const MIN_CLIENT_SCHEMA = 1;
|
|
4
4
|
export const MIN_SERVER_SCHEMA = 1;
|
|
5
|
-
/** README install line for 426 responses
|
|
5
|
+
/** README install line for 426 responses. */
|
|
6
6
|
export const REMOTE_CLI_UPGRADE_COMMAND = "npm install -g co-maintainer@latest";
|
|
7
7
|
export const REMOTE_MAX_BODY_BYTES = 52_428_800;
|
|
8
8
|
export const REMOTE_SYNC_INTERVAL_SECONDS = 3;
|
|
@@ -18,7 +18,7 @@ export const REMOTE_SYNC_STATUSES = [
|
|
|
18
18
|
"failed",
|
|
19
19
|
"canceled",
|
|
20
20
|
];
|
|
21
|
-
/** Tool names the server may invoke via the bridge
|
|
21
|
+
/** Tool names the server may invoke via the bridge. */
|
|
22
22
|
export const REMOTE_KNOWN_TOOL_NAMES = new Set([
|
|
23
23
|
"codegraph-query",
|
|
24
24
|
"codegraph-node",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Bounded request body read
|
|
1
|
+
/** Bounded request body read (bytes, streaming). */
|
|
2
2
|
export async function readBoundedUtf8(request, maxBytes) {
|
|
3
3
|
const lengthHeader = request.headers.get("content-length");
|
|
4
4
|
if (lengthHeader !== null) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Remembers a key for one job. An empty key is not stored. */
|
|
2
|
+
export declare function registerByokKey(jobId: string, key: string): void;
|
|
3
|
+
/** The key for a job, or undefined when none was submitted or it was already
|
|
4
|
+
* dropped. Callers must treat undefined as "no key", never as "use the
|
|
5
|
+
* server key" without checking the policy first. */
|
|
6
|
+
export declare function getByokKey(jobId: string): string | undefined;
|
|
7
|
+
export declare function hasByokKey(jobId: string): boolean;
|
|
8
|
+
/** Called on every terminal path: done, failed, canceled, superseded. */
|
|
9
|
+
export declare function dropByokKey(jobId: string): void;
|
|
10
|
+
/** Test-only hook so a scenario can simulate a server restart without a new
|
|
11
|
+
* process. Not used in production code. */
|
|
12
|
+
export declare function clearByokKeysForTest(): void;
|
|
13
|
+
/** Test-only count of held keys, so a test can prove a failed path left none
|
|
14
|
+
* behind without reading any key value. */
|
|
15
|
+
export declare function byokKeyCountForTest(): number;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** In-memory BYOK key store for remote reviews.
|
|
2
|
+
*
|
|
3
|
+
* A client's own AI key lives only here, keyed by job id, for the life of the
|
|
4
|
+
* job. It is never written to a table, a log line, or an error message. The
|
|
5
|
+
* key is gone the moment the job finishes, fails, is canceled, or is
|
|
6
|
+
* superseded, and a server restart empties this map, which is exactly the
|
|
7
|
+
* documented "your key was not kept across a restart" behavior.
|
|
8
|
+
*
|
|
9
|
+
* Why a module-level Map rather than the DB: a persisted key would survive a
|
|
10
|
+
* restart and be readable by anything that reads the database file, which is
|
|
11
|
+
* the leak the byte-scan test exists to prevent. */
|
|
12
|
+
const keys = new Map();
|
|
13
|
+
/** Remembers a key for one job. An empty key is not stored. */
|
|
14
|
+
export function registerByokKey(jobId, key) {
|
|
15
|
+
if (key)
|
|
16
|
+
keys.set(jobId, key);
|
|
17
|
+
}
|
|
18
|
+
/** The key for a job, or undefined when none was submitted or it was already
|
|
19
|
+
* dropped. Callers must treat undefined as "no key", never as "use the
|
|
20
|
+
* server key" without checking the policy first. */
|
|
21
|
+
export function getByokKey(jobId) {
|
|
22
|
+
return keys.get(jobId);
|
|
23
|
+
}
|
|
24
|
+
export function hasByokKey(jobId) {
|
|
25
|
+
return keys.has(jobId);
|
|
26
|
+
}
|
|
27
|
+
/** Called on every terminal path: done, failed, canceled, superseded. */
|
|
28
|
+
export function dropByokKey(jobId) {
|
|
29
|
+
keys.delete(jobId);
|
|
30
|
+
}
|
|
31
|
+
/** Test-only hook so a scenario can simulate a server restart without a new
|
|
32
|
+
* process. Not used in production code. */
|
|
33
|
+
export function clearByokKeysForTest() {
|
|
34
|
+
keys.clear();
|
|
35
|
+
}
|
|
36
|
+
/** Test-only count of held keys, so a test can prove a failed path left none
|
|
37
|
+
* behind without reading any key value. */
|
|
38
|
+
export function byokKeyCountForTest() {
|
|
39
|
+
return keys.size;
|
|
40
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** `GET /api/remote/guides?repo=owner/repo` — read a repository's guides with a
|
|
2
|
-
* remote token
|
|
2
|
+
* remote token.
|
|
3
3
|
*
|
|
4
4
|
* This is what `view --remote` calls. It is a read-only addition: no existing
|
|
5
5
|
* route changes, and it uses the same bearer model the review endpoints use
|
|
@@ -11,7 +11,7 @@ import { reposDir } from "../../config.js";
|
|
|
11
11
|
import { loadGuides } from "../../review/guides.js";
|
|
12
12
|
import { readDir, readTextFile, stat } from "../../util/runtime.js";
|
|
13
13
|
import { validateRemotePath } from "../validate.js";
|
|
14
|
-
/** The four generated guides, in the order a reader wants them
|
|
14
|
+
/** The four generated guides, in the order a reader wants them. */
|
|
15
15
|
const GUIDES = [
|
|
16
16
|
{ kind: "skill", file: "SKILL.md" },
|
|
17
17
|
{ kind: "codebase", file: "CODEBASE.md" },
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { VERSION } from "../../version.js";
|
|
2
|
-
import { cloneDir, readConfig } from "../../config.js";
|
|
2
|
+
import { cloneDir, readConfig, remoteByokPolicy } from "../../config.js";
|
|
3
3
|
import { resolveDefaultBranchName } from "../../git/default_branch.js";
|
|
4
4
|
import { runCommand } from "../../pr/checkout.js";
|
|
5
5
|
import { loadGuides } from "../../review/guides.js";
|
|
@@ -77,6 +77,7 @@ async function handleHandshake(request, ip) {
|
|
|
77
77
|
const config = readConfig();
|
|
78
78
|
const timeoutSeconds = config.remoteSyncTimeoutSeconds ?? 10;
|
|
79
79
|
const toolLimit = config.remoteToolOutputMaxChars ?? null;
|
|
80
|
+
const policy = remoteByokPolicy(config);
|
|
80
81
|
return Response.json({
|
|
81
82
|
schemaVersion: REMOTE_SCHEMA_VERSION,
|
|
82
83
|
minClientSchema: MIN_CLIENT_SCHEMA,
|
|
@@ -95,6 +96,12 @@ async function handleHandshake(request, ip) {
|
|
|
95
96
|
maxBodyBytes: REMOTE_MAX_BODY_BYTES,
|
|
96
97
|
toolOutputMaxChars: toolLimit,
|
|
97
98
|
},
|
|
99
|
+
// Additive advertisement: a 0.5.0 client ignores these, and an
|
|
100
|
+
// older server never sends them, so the client gates on `features`. No key,
|
|
101
|
+
// model, or other secret is ever echoed here.
|
|
102
|
+
features: ["byok"],
|
|
103
|
+
byok: { policy },
|
|
104
|
+
ai: { provider: config.ai ?? "openrouter" },
|
|
98
105
|
});
|
|
99
106
|
}
|
|
100
107
|
async function handleSync(request, ip, jobId) {
|
|
@@ -171,6 +171,20 @@ export function validateSubmitRequest(body) {
|
|
|
171
171
|
if (body.fresh !== undefined && typeof body.fresh !== "boolean") {
|
|
172
172
|
return bad("fresh", "must be a boolean");
|
|
173
173
|
}
|
|
174
|
+
if (body.byok !== undefined) {
|
|
175
|
+
if (!isRecord(body.byok)) {
|
|
176
|
+
return bad("byok", "must be an object");
|
|
177
|
+
}
|
|
178
|
+
if (typeof body.byok.key !== "string" || !body.byok.key) {
|
|
179
|
+
return bad("byok.key", "must be a non-empty string");
|
|
180
|
+
}
|
|
181
|
+
if (body.byok.key.length > 500) {
|
|
182
|
+
return bad("byok.key", "too long");
|
|
183
|
+
}
|
|
184
|
+
if (/[\0\r\n]/.test(body.byok.key)) {
|
|
185
|
+
return bad("byok.key", "invalid characters");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
174
188
|
const revisionErr = validateRevisionPayload(body.revision);
|
|
175
189
|
if (revisionErr)
|
|
176
190
|
return revisionErr;
|
|
@@ -190,7 +204,7 @@ export function validateSubmitRequest(body) {
|
|
|
190
204
|
if (typeof tool.name !== "string" || !tool.name) {
|
|
191
205
|
return bad(`capabilities.tools[${i}].name`, "required");
|
|
192
206
|
}
|
|
193
|
-
// Unknown tools are ignored at runtime; no error here
|
|
207
|
+
// Unknown tools are ignored at runtime; no error here.
|
|
194
208
|
if (REMOTE_KNOWN_TOOL_NAMES.has(tool.name) &&
|
|
195
209
|
tool.version !== undefined &&
|
|
196
210
|
typeof tool.version !== "string") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** How a review decides a blocking finding
|
|
1
|
+
/** How a review decides a blocking finding.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
3
|
+
* The same pull request was reviewed twice, the same P2 finding came back
|
|
4
4
|
* `non-blocking` the first time and `blocking` the second, and the exit code
|
|
5
5
|
* changed because the model's own label moved. `model` keeps that behavior
|
|
6
6
|
* (the model decides) because it is what 0.4.13 shipped; `severity` ignores
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/** How a review decides a blocking finding
|
|
1
|
+
/** How a review decides a blocking finding.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
3
|
+
* The same pull request was reviewed twice, the same P2 finding came back
|
|
4
4
|
* `non-blocking` the first time and `blocking` the second, and the exit code
|
|
5
5
|
* changed because the model's own label moved. `model` keeps that behavior
|
|
6
6
|
* (the model decides) because it is what 0.4.13 shipped; `severity` ignores
|
|
@@ -45,13 +45,13 @@ export declare function relocate(anchorText: string | null, oldFrom: number, pat
|
|
|
45
45
|
from: number;
|
|
46
46
|
to: number;
|
|
47
47
|
} | null;
|
|
48
|
-
/**
|
|
48
|
+
/** A guide rebuilt after the previous review invalidates that
|
|
49
49
|
* review's verdicts, its "unchanged" suppression and its open findings — they
|
|
50
50
|
* were all judged against the old rules. Such a run starts fresh instead:
|
|
51
51
|
* every file is scanned again and old findings are no longer carried over.
|
|
52
52
|
*
|
|
53
53
|
* `previousBuiltAt` is the guide's own build time as of that review. A
|
|
54
|
-
* previous review that never recorded one (a snapshot written
|
|
54
|
+
* previous review that never recorded one (a snapshot written by an older version)
|
|
55
55
|
* counts as rebuilt too: re-scanning once is cheaper than letting a stale
|
|
56
56
|
* verdict mask a new violation. With no current time there is nothing to
|
|
57
57
|
* compare, so nothing is treated as rebuilt. */
|
|
@@ -70,13 +70,13 @@ export function relocate(anchorText, oldFrom, patch) {
|
|
|
70
70
|
function guideChanged(previous, currentGuideBuiltAt) {
|
|
71
71
|
return (previous.guideBuiltAt ?? "") !== (currentGuideBuiltAt ?? "");
|
|
72
72
|
}
|
|
73
|
-
/**
|
|
73
|
+
/** A guide rebuilt after the previous review invalidates that
|
|
74
74
|
* review's verdicts, its "unchanged" suppression and its open findings — they
|
|
75
75
|
* were all judged against the old rules. Such a run starts fresh instead:
|
|
76
76
|
* every file is scanned again and old findings are no longer carried over.
|
|
77
77
|
*
|
|
78
78
|
* `previousBuiltAt` is the guide's own build time as of that review. A
|
|
79
|
-
* previous review that never recorded one (a snapshot written
|
|
79
|
+
* previous review that never recorded one (a snapshot written by an older version)
|
|
80
80
|
* counts as rebuilt too: re-scanning once is cheaper than letting a stale
|
|
81
81
|
* verdict mask a new violation. With no current time there is nothing to
|
|
82
82
|
* compare, so nothing is treated as rebuilt. */
|
|
@@ -55,7 +55,7 @@ export async function loadGuides(repo) {
|
|
|
55
55
|
}
|
|
56
56
|
// With no `repos` row, the files are the only evidence of when the guide
|
|
57
57
|
// was built. Without this a local review always says `guide unknown` and
|
|
58
|
-
// can never notice that a rebuild made its carry-over stale
|
|
58
|
+
// can never notice that a rebuild made its carry-over stale.
|
|
59
59
|
guideBuiltAt ??= await guideFilesBuiltAt(repo);
|
|
60
60
|
return {
|
|
61
61
|
shortGuide,
|
|
@@ -3,6 +3,7 @@ import { errorResponse } from "../errors.js";
|
|
|
3
3
|
import { createRemoteToken, deleteRemoteToken, listRemoteTokens, setRemoteTokenActive, } from "../../services/remote_tokens.js";
|
|
4
4
|
import { cancelRemoteJobsForToken } from "../../services/remote_token_jobs.js";
|
|
5
5
|
import { remoteTokenUsageSince } from "../../store/reviews.js";
|
|
6
|
+
import { formatCostTotal } from "../pages/cost.js";
|
|
6
7
|
import { getRemoteToken } from "../../store/remote_tokens.js";
|
|
7
8
|
import { daysAgoIso } from "../../util/time.js";
|
|
8
9
|
export async function handleRemoteTokensRoute(request, url) {
|
|
@@ -18,6 +19,7 @@ export async function handleRemoteTokensRoute(request, url) {
|
|
|
18
19
|
lastUsedAt: row.last_used_at,
|
|
19
20
|
reviews30d: usage.reviews,
|
|
20
21
|
cost30d: usage.cost,
|
|
22
|
+
cost30dLabel: formatCostTotal(usage),
|
|
21
23
|
};
|
|
22
24
|
});
|
|
23
25
|
return Response.json(items);
|
|
@@ -3,6 +3,7 @@ import { AppClient, findInstallationForRepo } from "../../github/app.js";
|
|
|
3
3
|
import { activateRepo, deactivateRepo, getRepo, listActiveRepos, updateRepoSettings, } from "../../store/repos.js";
|
|
4
4
|
import { readConfig, writeRepoConfig } from "../../config.js";
|
|
5
5
|
import { parseRemakeCron } from "../../services/remake_cron.js";
|
|
6
|
+
import { parsePolicy, policyToStore, describePolicy, } from "../../services/review_policy.js";
|
|
6
7
|
import { enqueueSetup } from "../../services/setup.js";
|
|
7
8
|
import { enqueueManualReview } from "../../services/review.js";
|
|
8
9
|
import { probePlan, recommendationPatch } from "../../services/probe.js";
|
|
@@ -14,6 +15,22 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
14
15
|
if (url.pathname === "/api/repos" && request.method === "GET") {
|
|
15
16
|
return Response.json({ items: listActiveRepos() });
|
|
16
17
|
}
|
|
18
|
+
// The plain sentences shown under the policy editor before it is saved, so
|
|
19
|
+
// the wording lives in one place and the page never rebuilds it.
|
|
20
|
+
if (url.pathname === "/api/repos/policy-preview" &&
|
|
21
|
+
request.method === "POST") {
|
|
22
|
+
let body;
|
|
23
|
+
try {
|
|
24
|
+
body = await request.json();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return errorResponse(400, "bad_request", "expected a JSON body");
|
|
28
|
+
}
|
|
29
|
+
const parsed = parsePolicy(body.policy);
|
|
30
|
+
if (!parsed.ok)
|
|
31
|
+
return errorResponse(422, "invalid_policy", parsed.problem);
|
|
32
|
+
return Response.json({ summary: describePolicy(parsed.value) });
|
|
33
|
+
}
|
|
17
34
|
if (url.pathname === "/api/repos/preview" && request.method === "POST") {
|
|
18
35
|
if (!githubApp) {
|
|
19
36
|
return errorResponse(422, "no_github_app", "Configure the GitHub App in Settings to preview a repository.");
|
|
@@ -109,6 +126,15 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
109
126
|
return errorResponse(422, "app_access_denied", `The GitHub App cannot access ${repo}.`);
|
|
110
127
|
}
|
|
111
128
|
}
|
|
129
|
+
// Checked before anything is written, so a bad policy leaves no half
|
|
130
|
+
// added repository behind.
|
|
131
|
+
let policyToKeep;
|
|
132
|
+
if ("reviewPolicy" in body) {
|
|
133
|
+
const stored = policyToStore(body.reviewPolicy, { allowLegacy: false });
|
|
134
|
+
if (!stored.ok)
|
|
135
|
+
return errorResponse(422, "invalid_policy", stored.problem);
|
|
136
|
+
policyToKeep = stored.value;
|
|
137
|
+
}
|
|
112
138
|
// `patch` is the confirmed preview. It is optional so a caller that
|
|
113
139
|
// never opened the preview keeps the old behavior; when present it is
|
|
114
140
|
// written before `activateRepo` so the enqueued init reads it.
|
|
@@ -119,6 +145,9 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
119
145
|
await writeRepoConfig(repo, patch);
|
|
120
146
|
}
|
|
121
147
|
activateRepo(repo, installationId);
|
|
148
|
+
if (policyToKeep !== undefined) {
|
|
149
|
+
updateRepoSettings(repo, { review_policy_json: policyToKeep });
|
|
150
|
+
}
|
|
122
151
|
try {
|
|
123
152
|
const { id } = enqueueSetup(repo, "init");
|
|
124
153
|
return Response.json({ jobId: id });
|
|
@@ -168,6 +197,13 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
168
197
|
}
|
|
169
198
|
}
|
|
170
199
|
const patch = {};
|
|
200
|
+
if ("reviewPolicy" in body) {
|
|
201
|
+
const stored = policyToStore(body.reviewPolicy, { allowLegacy: true });
|
|
202
|
+
if (!stored.ok) {
|
|
203
|
+
return errorResponse(422, "invalid_policy", stored.problem);
|
|
204
|
+
}
|
|
205
|
+
patch.review_policy_json = stored.value;
|
|
206
|
+
}
|
|
171
207
|
if (typeof body.autoReview === "boolean") {
|
|
172
208
|
patch.auto_review = body.autoReview ? 1 : 0;
|
|
173
209
|
}
|
|
@@ -177,6 +213,21 @@ export async function handleReposRoute(request, url, githubApp) {
|
|
|
177
213
|
if (typeof body.skipBots === "boolean") {
|
|
178
214
|
patch.skip_bots = body.skipBots ? 1 : 0;
|
|
179
215
|
}
|
|
216
|
+
// The three switches only mean something under the legacy policy. A
|
|
217
|
+
// repository on a real policy refuses them instead of silently ignoring
|
|
218
|
+
// the change, and one on the server default is pinned to the switches
|
|
219
|
+
// the moment someone uses them.
|
|
220
|
+
if (patch.auto_review !== undefined ||
|
|
221
|
+
patch.skip_drafts !== undefined ||
|
|
222
|
+
patch.skip_bots !== undefined) {
|
|
223
|
+
const stored = patch.review_policy_json !== undefined
|
|
224
|
+
? patch.review_policy_json
|
|
225
|
+
: (getRepo(fullName)?.review_policy_json ?? null);
|
|
226
|
+
if (stored !== null && stored !== '"legacy"') {
|
|
227
|
+
return errorResponse(409, "policy_in_use", "This repository uses a review policy, so the auto review, drafts and bots switches do not apply. Change the policy instead.");
|
|
228
|
+
}
|
|
229
|
+
patch.review_policy_json = '"legacy"';
|
|
230
|
+
}
|
|
180
231
|
if (typeof body.useCodegraph === "boolean") {
|
|
181
232
|
patch.use_codegraph = body.useCodegraph ? 1 : 0;
|
|
182
233
|
}
|
|
@@ -4,6 +4,7 @@ import { testAppAccess, testGithubAccess } from "../../services/credentials.js";
|
|
|
4
4
|
import { checkPassword, readSessionToken, revokeOtherSessions, } from "../auth.js";
|
|
5
5
|
import { passwordProblem } from "../../util/password.js";
|
|
6
6
|
import { readJsonObject } from "./json_body.js";
|
|
7
|
+
import { policyToStore } from "../../services/review_policy.js";
|
|
7
8
|
function validateWebhookUrl(value) {
|
|
8
9
|
if (typeof value !== "string" || !value.trim()) {
|
|
9
10
|
return "webhook URL is required";
|
|
@@ -64,6 +65,7 @@ export async function handleSettingsRoute(request, url, webhookUrl, passwords, i
|
|
|
64
65
|
remoteSyncTimeoutSeconds: config.remoteSyncTimeoutSeconds ?? null,
|
|
65
66
|
maxConcurrentRemoteReviewsPerToken: config.maxConcurrentRemoteReviewsPerToken ?? null,
|
|
66
67
|
remoteToolOutputMaxChars: config.remoteToolOutputMaxChars ?? null,
|
|
68
|
+
reviewPolicy: config.reviewPolicy ?? null,
|
|
67
69
|
});
|
|
68
70
|
}
|
|
69
71
|
if (url.pathname === "/api/settings" && request.method === "PUT") {
|
|
@@ -144,6 +146,14 @@ export async function handleSettingsRoute(request, url, webhookUrl, passwords, i
|
|
|
144
146
|
const remoteToolOut = positiveIntOrNull("remoteToolOutputMaxChars", "remoteToolOutputMaxChars");
|
|
145
147
|
if (remoteToolOut)
|
|
146
148
|
return remoteToolOut;
|
|
149
|
+
if ("reviewPolicy" in body) {
|
|
150
|
+
const stored = policyToStore(body.reviewPolicy, { allowLegacy: false });
|
|
151
|
+
if (!stored.ok) {
|
|
152
|
+
return errorResponse(422, "invalid_policy", stored.problem);
|
|
153
|
+
}
|
|
154
|
+
patch.reviewPolicy =
|
|
155
|
+
stored.value === null ? undefined : JSON.parse(stored.value);
|
|
156
|
+
}
|
|
147
157
|
if (body.defaults && typeof body.defaults === "object") {
|
|
148
158
|
const defaults = body.defaults;
|
|
149
159
|
const next = { ...current.defaults };
|
package/dist/src/server/http.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { activityFeed, runningJobs, skippedDeliveries } from "../../services/dashboard.ts";
|
|
2
2
|
import type { JobLogRow, JobRow } from "../../store/rows.ts";
|
|
3
|
+
/** True for the skips a policy left waiting on a maintainer's request. */
|
|
4
|
+
export declare function isWaitingForRequest(reason: string | null): boolean;
|
|
3
5
|
export declare function jobLabel(job: JobRow): string;
|
|
4
6
|
export declare function renderActivity(username: string, running: ReturnType<typeof runningJobs>, feed: ReturnType<typeof activityFeed>, skipped: ReturnType<typeof skippedDeliveries>): Response;
|
|
5
7
|
export declare function renderJob(username: string, job: JobRow, logs: JobLogRow[]): Response;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { empty, html, layout,
|
|
1
|
+
import { empty, html, layout, skSlot, text, when } from "./layout.js";
|
|
2
|
+
import { formatCost } from "./cost.js";
|
|
2
3
|
import { getLogsSince } from "../../services/jobs.js";
|
|
4
|
+
/** True for the skips a policy left waiting on a maintainer's request. */
|
|
5
|
+
export function isWaitingForRequest(reason) {
|
|
6
|
+
return Boolean(reason?.endsWith("waiting for a maintainer request."));
|
|
7
|
+
}
|
|
3
8
|
function skipReason(reason) {
|
|
4
9
|
switch (reason) {
|
|
5
10
|
case "draft":
|
|
@@ -103,7 +108,7 @@ export function renderActivity(username, running, feed, skipped) {
|
|
|
103
108
|
<td><a href="/repos/${text(review.repo)}/pulls/${review.pr_number}">${text(review.repo)} #${review.pr_number}</a></td>
|
|
104
109
|
<td class="muted">${when(review.created_at)}</td>
|
|
105
110
|
<td>${result}</td>
|
|
106
|
-
<td class="num">${
|
|
111
|
+
<td class="num">${formatCost(review)}</td></tr>`;
|
|
107
112
|
}
|
|
108
113
|
const job = item.job;
|
|
109
114
|
return `<tr><td><a href="/activity/${text(job.id)}">${text(jobLabel(job))}</a></td>
|
|
@@ -128,9 +133,18 @@ export function renderActivity(username, running, feed, skipped) {
|
|
|
128
133
|
: `<button class="btn sm" disabled>Next</button>`}</div>`;
|
|
129
134
|
const skippedRows = skipped
|
|
130
135
|
.slice(0, 20)
|
|
131
|
-
.map((row) =>
|
|
136
|
+
.map((row) => {
|
|
137
|
+
const label = `${row.repo ?? ""}${row.pr_number ? ` #${row.pr_number}` : ""}`;
|
|
138
|
+
// A pull request waiting for a request is one click from a review, and
|
|
139
|
+
// the person on the dashboard is already allowed to ask.
|
|
140
|
+
const action = row.repo && row.pr_number && isWaitingForRequest(row.reason)
|
|
141
|
+
? `<button class="btn sm" type="button" data-post="/api/repos/${text(row.repo)}/pulls/${row.pr_number}/review" aria-label="Review ${text(label)} now">Review now</button>`
|
|
142
|
+
: "";
|
|
143
|
+
return `<tr><td>${text(label)}</td>
|
|
132
144
|
<td class="muted">${text(skipReason(row.reason))}</td>
|
|
133
|
-
<td class="muted" style="width:120px">${when(row.received_at)}</td
|
|
145
|
+
<td class="muted" style="width:120px">${when(row.received_at)}</td>
|
|
146
|
+
<td style="width:110px;text-align:right">${action}</td></tr>`;
|
|
147
|
+
})
|
|
134
148
|
.join("");
|
|
135
149
|
return html(layout({
|
|
136
150
|
title: "Activity · co-maintainer",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { html, layout, skSlot, text } from "./layout.js";
|
|
2
|
+
import { readConfig } from "../../config.js";
|
|
3
|
+
import { TEMPLATE_INFO } from "../../services/review_policy.js";
|
|
2
4
|
export function renderAddRepo(username, picker) {
|
|
3
5
|
const notice = picker.error
|
|
4
6
|
? `<div class="notice bad"><div class="txt">${text(picker.error)}</div></div>`
|
|
@@ -15,6 +17,27 @@ export function renderAddRepo(username, picker) {
|
|
|
15
17
|
.join("")}</optgroup>`)
|
|
16
18
|
.join("");
|
|
17
19
|
const canAdd = picker.repos.some((repo) => !repo.alreadyActive);
|
|
20
|
+
// What a repository added now would follow if nobody chooses: the server
|
|
21
|
+
// default when there is one, otherwise the simple switches, which behave
|
|
22
|
+
// exactly like "everyone".
|
|
23
|
+
const serverDefault = readConfig().reviewPolicy;
|
|
24
|
+
const choices = [
|
|
25
|
+
...TEMPLATE_INFO,
|
|
26
|
+
...(typeof serverDefault === "object"
|
|
27
|
+
? [
|
|
28
|
+
{
|
|
29
|
+
name: "__server",
|
|
30
|
+
label: "Server default (custom rules)",
|
|
31
|
+
description: "The custom rules set for the whole server.",
|
|
32
|
+
},
|
|
33
|
+
]
|
|
34
|
+
: []),
|
|
35
|
+
];
|
|
36
|
+
const initial = typeof serverDefault === "string"
|
|
37
|
+
? serverDefault
|
|
38
|
+
: typeof serverDefault === "object"
|
|
39
|
+
? "__server"
|
|
40
|
+
: "everyone";
|
|
18
41
|
const select = `<select id="repo"${canAdd ? "" : " disabled"}>
|
|
19
42
|
<option value="">Choose a repository</option>
|
|
20
43
|
${options}
|
|
@@ -44,6 +67,7 @@ export function renderAddRepo(username, picker) {
|
|
|
44
67
|
</div>
|
|
45
68
|
<script>
|
|
46
69
|
var plan = null;
|
|
70
|
+
var POLICY = ${JSON.stringify({ choices, initial }).replace(/</g, "\\u003c")};
|
|
47
71
|
function showPlan(data) {
|
|
48
72
|
plan = data;
|
|
49
73
|
var box = document.getElementById("plan");
|
|
@@ -84,6 +108,37 @@ function showPlan(data) {
|
|
|
84
108
|
});
|
|
85
109
|
box.appendChild(ul);
|
|
86
110
|
}
|
|
111
|
+
var choose = document.createElement("fieldset");
|
|
112
|
+
choose.style.cssText = "border:0;padding:0;margin:14px 0 0";
|
|
113
|
+
var legend = document.createElement("legend");
|
|
114
|
+
legend.className = "muted";
|
|
115
|
+
legend.style.padding = "0";
|
|
116
|
+
legend.textContent = "Who gets a review";
|
|
117
|
+
choose.appendChild(legend);
|
|
118
|
+
POLICY.choices.forEach(function(item) {
|
|
119
|
+
var id = "policy-" + item.name;
|
|
120
|
+
var radio = document.createElement("input");
|
|
121
|
+
radio.type = "radio";
|
|
122
|
+
radio.name = "policy";
|
|
123
|
+
radio.id = id;
|
|
124
|
+
radio.value = item.name;
|
|
125
|
+
radio.checked = item.name === POLICY.initial;
|
|
126
|
+
var label = document.createElement("label");
|
|
127
|
+
label.setAttribute("for", id);
|
|
128
|
+
label.style.cssText = "display:flex;align-items:flex-start;gap:8px;margin:6px 0";
|
|
129
|
+
var body = document.createElement("span");
|
|
130
|
+
var title = document.createElement("b");
|
|
131
|
+
title.textContent = item.label;
|
|
132
|
+
var desc = document.createElement("span");
|
|
133
|
+
desc.className = "muted";
|
|
134
|
+
desc.textContent = " " + item.description;
|
|
135
|
+
body.appendChild(title);
|
|
136
|
+
body.appendChild(desc);
|
|
137
|
+
label.appendChild(radio);
|
|
138
|
+
label.appendChild(body);
|
|
139
|
+
choose.appendChild(label);
|
|
140
|
+
});
|
|
141
|
+
box.appendChild(choose);
|
|
87
142
|
var row = document.createElement("div");
|
|
88
143
|
row.style.display = "flex";
|
|
89
144
|
row.style.gap = "8px";
|
|
@@ -107,7 +162,12 @@ function showPlan(data) {
|
|
|
107
162
|
confirm.addEventListener("click", function() {
|
|
108
163
|
var repo = document.getElementById("repo").value.trim();
|
|
109
164
|
if (!repo || !plan) return;
|
|
110
|
-
|
|
165
|
+
var picked = document.querySelector('input[name="policy"]:checked');
|
|
166
|
+
var body = { repo: repo, patch: plan.patch };
|
|
167
|
+
// The server default is followed, not copied, so it is only sent when
|
|
168
|
+
// this repository is meant to differ from it.
|
|
169
|
+
if (picked && picked.value !== POLICY.initial) body.reviewPolicy = picked.value;
|
|
170
|
+
postAndGo("/api/repos", body, "/activity", confirm);
|
|
111
171
|
});
|
|
112
172
|
}
|
|
113
173
|
document.getElementById("preview").addEventListener("click", function() {
|