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.
Files changed (177) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +5 -3
  3. package/dist/package.json +1 -1
  4. package/dist/src/ai/anthropic.d.ts +55 -0
  5. package/dist/src/ai/anthropic.js +262 -0
  6. package/dist/src/ai/batch.d.ts +2 -2
  7. package/dist/src/ai/batch.js +1 -1
  8. package/dist/src/ai/estimate.d.ts +4 -0
  9. package/dist/src/ai/estimate.js +17 -2
  10. package/dist/src/ai/mermaid_loop.js +7 -4
  11. package/dist/src/ai/openai.d.ts +45 -0
  12. package/dist/src/ai/openai.js +206 -0
  13. package/dist/src/ai/opencode.d.ts +10 -0
  14. package/dist/src/ai/opencode.js +76 -0
  15. package/dist/src/ai/openrouter.d.ts +23 -7
  16. package/dist/src/ai/openrouter.js +39 -23
  17. package/dist/src/ai/pricing.js +1 -1
  18. package/dist/src/ai/provider.d.ts +9 -0
  19. package/dist/src/ai/provider.js +42 -3
  20. package/dist/src/ai/verify.d.ts +9 -1
  21. package/dist/src/ai/verify.js +81 -3
  22. package/dist/src/cli/args.d.ts +1 -2
  23. package/dist/src/cli/args.js +38 -26
  24. package/dist/src/cli/commands/config.d.ts +1 -1
  25. package/dist/src/cli/commands/config.js +10 -4
  26. package/dist/src/cli/commands/probe.js +3 -11
  27. package/dist/src/cli/commands/registry.d.ts +1 -9
  28. package/dist/src/cli/commands/registry.js +75 -10
  29. package/dist/src/cli/commands/review.js +18 -13
  30. package/dist/src/cli/commands/rollback.d.ts +1 -0
  31. package/dist/src/cli/commands/rollback.js +42 -0
  32. package/dist/src/cli/commands/serve.js +2 -3
  33. package/dist/src/cli/commands/set.js +71 -7
  34. package/dist/src/cli/commands/view.js +10 -9
  35. package/dist/src/cli/error.d.ts +4 -4
  36. package/dist/src/cli/error.js +4 -4
  37. package/dist/src/cli/main.d.ts +4 -4
  38. package/dist/src/cli/main.js +13 -8
  39. package/dist/src/cli/prompt.js +1 -1
  40. package/dist/src/cli/review_args.d.ts +9 -3
  41. package/dist/src/cli/review_args.js +28 -5
  42. package/dist/src/cli/review_github.d.ts +31 -0
  43. package/dist/src/cli/review_github.js +128 -0
  44. package/dist/src/cli/review_result.d.ts +27 -16
  45. package/dist/src/cli/review_result.js +7 -7
  46. package/dist/src/config.d.ts +28 -6
  47. package/dist/src/config.js +15 -1
  48. package/dist/src/git/default_branch.d.ts +1 -1
  49. package/dist/src/git/default_branch.js +1 -1
  50. package/dist/src/github/app.js +2 -2
  51. package/dist/src/github/app_manifest.d.ts +1 -7
  52. package/dist/src/github/app_manifest.js +2 -7
  53. package/dist/src/github/client.d.ts +4 -1
  54. package/dist/src/github/client.js +14 -2
  55. package/dist/src/github/gh.js +10 -7
  56. package/dist/src/github/pat.js +4 -4
  57. package/dist/src/github/permissions.d.ts +36 -0
  58. package/dist/src/github/permissions.js +334 -0
  59. package/dist/src/knowledge/facts.js +1 -1
  60. package/dist/src/knowledge/guide.d.ts +3 -3
  61. package/dist/src/knowledge/guide.js +5 -5
  62. package/dist/src/knowledge/probe.js +1 -2
  63. package/dist/src/knowledge/sections.d.ts +3 -3
  64. package/dist/src/knowledge/sections.js +3 -3
  65. package/dist/src/knowledge/skill.d.ts +1 -1
  66. package/dist/src/knowledge/skill.js +4 -4
  67. package/dist/src/knowledge/synthesis.d.ts +1 -1
  68. package/dist/src/knowledge/synthesis.js +5 -5
  69. package/dist/src/knowledge/types.d.ts +1 -1
  70. package/dist/src/local/codegraph_exclude.d.ts +1 -1
  71. package/dist/src/local/codegraph_exclude.js +1 -1
  72. package/dist/src/local/codegraph_prepare.js +1 -1
  73. package/dist/src/local/git_ops.d.ts +1 -1
  74. package/dist/src/local/git_ops.js +1 -1
  75. package/dist/src/local/git_parse.d.ts +2 -2
  76. package/dist/src/local/git_parse.js +2 -2
  77. package/dist/src/local/git_revision.d.ts +1 -1
  78. package/dist/src/local/git_revision.js +1 -1
  79. package/dist/src/local/git_untracked.d.ts +1 -1
  80. package/dist/src/local/git_untracked.js +1 -1
  81. package/dist/src/local/review_local.js +17 -14
  82. package/dist/src/local/review_lock.d.ts +1 -1
  83. package/dist/src/local/review_lock.js +1 -1
  84. package/dist/src/pr/checkout.js +2 -2
  85. package/dist/src/pr/findings.js +1 -1
  86. package/dist/src/pr/findings_json.d.ts +2 -2
  87. package/dist/src/pr/findings_json.js +1 -1
  88. package/dist/src/pr/github_revision.js +1 -1
  89. package/dist/src/pr/review_copy.d.ts +2 -2
  90. package/dist/src/pr/review_copy.js +2 -2
  91. package/dist/src/pr/reviewer.d.ts +6 -0
  92. package/dist/src/pr/reviewer.js +49 -39
  93. package/dist/src/remote/client.d.ts +4 -0
  94. package/dist/src/remote/client.js +84 -23
  95. package/dist/src/remote/fixtures.js +1 -1
  96. package/dist/src/remote/http.d.ts +10 -1
  97. package/dist/src/remote/http.js +15 -3
  98. package/dist/src/remote/schema.d.ts +3 -3
  99. package/dist/src/remote/schema.js +3 -3
  100. package/dist/src/remote/server/body.d.ts +1 -1
  101. package/dist/src/remote/server/body.js +1 -1
  102. package/dist/src/remote/server/byok_keys.d.ts +15 -0
  103. package/dist/src/remote/server/byok_keys.js +40 -0
  104. package/dist/src/remote/server/guides.js +2 -2
  105. package/dist/src/remote/server/routes.js +8 -1
  106. package/dist/src/remote/validate.js +15 -1
  107. package/dist/src/review/blocking.d.ts +2 -2
  108. package/dist/src/review/blocking.js +2 -2
  109. package/dist/src/review/carry_over.d.ts +2 -2
  110. package/dist/src/review/carry_over.js +2 -2
  111. package/dist/src/review/guides.js +1 -1
  112. package/dist/src/server/api/remote_tokens.js +2 -0
  113. package/dist/src/server/api/repos.js +51 -0
  114. package/dist/src/server/api/settings.js +10 -0
  115. package/dist/src/server/errors.d.ts +1 -1
  116. package/dist/src/server/http.js +1 -1
  117. package/dist/src/server/pages/activity.d.ts +2 -0
  118. package/dist/src/server/pages/activity.js +18 -4
  119. package/dist/src/server/pages/add_repo.js +61 -1
  120. package/dist/src/server/pages/analytics.js +18 -8
  121. package/dist/src/server/pages/cost.d.ts +21 -0
  122. package/dist/src/server/pages/cost.js +72 -0
  123. package/dist/src/server/pages/pr.js +5 -4
  124. package/dist/src/server/pages/repo.js +3 -2
  125. package/dist/src/server/pages/repo_prs.js +3 -2
  126. package/dist/src/server/pages/repo_remote.js +3 -2
  127. package/dist/src/server/pages/repo_settings.js +326 -12
  128. package/dist/src/server/pages/router.js +3 -2
  129. package/dist/src/server/pages/settings.js +50 -6
  130. package/dist/src/server/pages/styles.d.ts +1 -1
  131. package/dist/src/server/pages/styles.js +1 -1
  132. package/dist/src/services/credentials.js +8 -11
  133. package/dist/src/services/dashboard.d.ts +22 -9
  134. package/dist/src/services/dashboard.js +16 -2
  135. package/dist/src/services/probe.js +1 -1
  136. package/dist/src/services/remote_review.js +142 -35
  137. package/dist/src/services/remote_review_abort.d.ts +5 -1
  138. package/dist/src/services/remote_review_abort.js +9 -1
  139. package/dist/src/services/review.d.ts +1 -1
  140. package/dist/src/services/review.js +19 -5
  141. package/dist/src/services/review_policy.d.ts +116 -0
  142. package/dist/src/services/review_policy.js +489 -0
  143. package/dist/src/services/setup.d.ts +4 -3
  144. package/dist/src/services/setup.js +18 -16
  145. package/dist/src/services/setup_checklist.js +2 -2
  146. package/dist/src/services/webhook.d.ts +23 -0
  147. package/dist/src/services/webhook.js +141 -34
  148. package/dist/src/store/app_db.d.ts +5 -0
  149. package/dist/src/store/app_db.js +69 -13
  150. package/dist/src/store/backup.d.ts +31 -0
  151. package/dist/src/store/backup.js +158 -0
  152. package/dist/src/store/migrations.d.ts +1 -1
  153. package/dist/src/store/migrations.js +21 -1
  154. package/dist/src/store/remote_review_inputs.d.ts +3 -0
  155. package/dist/src/store/remote_review_inputs.js +3 -3
  156. package/dist/src/store/remote_tokens.d.ts +1 -1
  157. package/dist/src/store/remote_tokens.js +1 -1
  158. package/dist/src/store/repos.d.ts +2 -2
  159. package/dist/src/store/repos.js +1 -1
  160. package/dist/src/store/reviews.d.ts +21 -13
  161. package/dist/src/store/reviews.js +60 -15
  162. package/dist/src/store/rows.d.ts +10 -0
  163. package/dist/src/tools/codegraph.d.ts +4 -4
  164. package/dist/src/tools/codegraph.js +5 -5
  165. package/dist/src/tools/codegraph_exec.d.ts +1 -1
  166. package/dist/src/tools/codegraph_exec.js +1 -1
  167. package/dist/src/types.d.ts +12 -6
  168. package/dist/src/util/cost.d.ts +52 -0
  169. package/dist/src/util/cost.js +70 -0
  170. package/dist/src/util/log.d.ts +1 -1
  171. package/dist/src/util/log.js +2 -2
  172. package/dist/src/util/redact.js +2 -0
  173. package/dist/src/util/run_summary.d.ts +7 -2
  174. package/dist/src/util/run_summary.js +28 -2
  175. package/dist/src/util/time.d.ts +1 -1
  176. package/dist/src/util/time.js +1 -1
  177. package/package.json +1 -1
@@ -1,11 +1,23 @@
1
- /** Shared HTTPS plumbing for the remote endpoints (CORE-25, CORE-44).
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
- /** A usage error the CLI prints with its hint and exit code (CORE-12). */
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 (CORE-12).
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 — plan §15.1–15.2. */
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 (plan §14.4). */
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 (plan §13.6, §14.5). */
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 — plan §15.1–15.2. */
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 (plan §14.4). */
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 (plan §13.6, §14.5). */
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 — plan §14.5 / G6 (bytes, streaming). */
1
+ /** Bounded request body read (bytes, streaming). */
2
2
  export type BoundedBodyResult = {
3
3
  ok: true;
4
4
  text: string;
@@ -1,4 +1,4 @@
1
- /** Bounded request body read — plan §14.5 / G6 (bytes, streaming). */
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 (CORE-44).
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 (CORE-23). */
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 (plan §14.5).
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 (CORE-41, plan decision 2 option C).
1
+ /** How a review decides a blocking finding.
2
2
  *
3
- * F19: the same pull request was reviewed twice, the same P2 finding came back
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 (CORE-41, plan decision 2 option C).
1
+ /** How a review decides a blocking finding.
2
2
  *
3
- * F19: the same pull request was reviewed twice, the same P2 finding came back
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
- /** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
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 before CORE-42)
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
- /** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
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 before CORE-42)
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 (CORE-42).
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 };
@@ -1,4 +1,4 @@
1
- /** The one error shape every `/api/*` response uses — see PLAN.md Section 7. */
1
+ /** The one error shape every `/api/*` response uses. */
2
2
  export type ApiError = {
3
3
  code: string;
4
4
  message: string;
@@ -1,4 +1,4 @@
1
- /** A manual `node:http` ↔ `fetch` bridge (plan §2 HTTP).
1
+ /** A manual `node:http` ↔ `fetch` bridge.
2
2
  *
3
3
  * The app is written against the Web `Request`/`Response` types, which Deno
4
4
  * served natively. Node has both types but no server that speaks them
@@ -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, money, skSlot, text, when } from "./layout.js";
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">${money(Number(review.cost ?? 0))}</td></tr>`;
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) => `<tr><td>${text(row.repo ?? "")}${row.pr_number ? ` #${row.pr_number}` : ""}</td>
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></tr>`)
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
- postAndGo("/api/repos", { repo: repo, patch: plan.patch }, "/activity", confirm);
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() {