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
@@ -0,0 +1,128 @@
1
+ /** `review --output=github`.
2
+ *
3
+ * Findings become GitHub workflow commands (`::error` and `::warning`) so a
4
+ * pull request shows them as annotations, and a markdown table goes to the job
5
+ * summary. This file is pure apart from `appendStepSummary`.
6
+ *
7
+ * A finding's text comes from a model reading an untrusted pull request, so it
8
+ * is never printed raw: the runner reads a workflow command from any line that
9
+ * starts with `::`, and a body that carried such a line could mask a secret or
10
+ * end the step's commands. Every finding is one escaped line, and nothing else
11
+ * from the review is printed to stdout in this mode. */
12
+ import { appendFileSync } from "node:fs";
13
+ import { formatRunSummary } from "../util/run_summary.js";
14
+ import { humanShortTitle, sortHumanFindings, } from "./review_result.js";
15
+ /** GitHub shows at most this many error and this many warning annotations per
16
+ * step and drops the rest without a word. */
17
+ export const MAX_ANNOTATIONS_PER_LEVEL = 10;
18
+ /** The message part of a workflow command. GitHub's own toolkit escapes exactly
19
+ * these three, and a `:` or `,` here would show as `%3A` and `%2C` in the text. */
20
+ export function escapeData(value) {
21
+ return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
22
+ }
23
+ /** A property value (`file`, `title`) also cannot hold `:` or `,`, which end it. */
24
+ export function escapeProperty(value) {
25
+ return escapeData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
26
+ }
27
+ function annotation(row) {
28
+ const properties = [];
29
+ if (row.path) {
30
+ properties.push(`file=${escapeProperty(row.path)}`);
31
+ const from = row.lineFrom ?? 0;
32
+ if (from > 0) {
33
+ properties.push(`line=${from}`);
34
+ const to = row.lineTo ?? from;
35
+ if (to > from)
36
+ properties.push(`endLine=${to}`);
37
+ }
38
+ }
39
+ const short = humanShortTitle(row);
40
+ properties.push(`title=${escapeProperty(short ? `${row.severity}: ${short}` : `${row.severity} finding`)}`);
41
+ const level = row.blocking ? "error" : "warning";
42
+ return `::${level} ${properties.join(",")}::${escapeData(row.body || row.title)}`;
43
+ }
44
+ function active(findings) {
45
+ return sortHumanFindings(findings).filter((row) => row.state !== "closed");
46
+ }
47
+ /** One workflow command per finding still standing, errors first. A finding
48
+ * that was closed since the last review needs no annotation. */
49
+ export function githubAnnotations(findings) {
50
+ const rows = active(findings);
51
+ const errors = rows.filter((row) => row.blocking);
52
+ const warnings = rows.filter((row) => !row.blocking);
53
+ const shown = [
54
+ ...errors.slice(0, MAX_ANNOTATIONS_PER_LEVEL),
55
+ ...warnings.slice(0, MAX_ANNOTATIONS_PER_LEVEL),
56
+ ];
57
+ const lines = shown.map(annotation);
58
+ const left = rows.length - shown.length;
59
+ if (left > 0) {
60
+ lines.push(`::notice title=co-maintainer::${escapeData(`${left} more ${left === 1 ? "finding is" : "findings are"} not annotated, GitHub shows ${MAX_ANNOTATIONS_PER_LEVEL} errors and ${MAX_ANNOTATIONS_PER_LEVEL} warnings per step. The job summary lists all of them.`)}`);
61
+ }
62
+ return lines;
63
+ }
64
+ /** Markdown for a table cell: nothing the model wrote can become a link, an
65
+ * image, a tag or an extra column. */
66
+ function cell(value, limit = 300) {
67
+ const flat = value.replace(/\s+/g, " ").trim();
68
+ const cut = flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
69
+ return cut.replace(/[\\`*_{}[\]<>|]/g, "\\$&");
70
+ }
71
+ function where(row) {
72
+ if (!row.path)
73
+ return "";
74
+ const from = row.lineFrom ?? 0;
75
+ const to = row.lineTo ?? from;
76
+ const span = from <= 0 ? "" : from === to ? `:${from}` : `:${from}-${to}`;
77
+ return cell(`${row.path}${span}`, 120);
78
+ }
79
+ export function githubSummary(input) {
80
+ const rows = active(input.findings);
81
+ const closed = input.findings.filter((row) => row.state === "closed").length;
82
+ const blocking = rows.filter((row) => row.blocking).length;
83
+ const lines = ["## co-maintainer review", "", cell(input.title, 200), ""];
84
+ if (rows.length === 0) {
85
+ lines.push("No actionable findings.");
86
+ }
87
+ else {
88
+ lines.push("| Severity | State | Where | Finding |", "|---|---|---|---|");
89
+ for (const row of rows) {
90
+ const short = humanShortTitle(row);
91
+ const text = [short, row.body.split("\n")[0] ?? ""]
92
+ .filter(Boolean)
93
+ .join(": ");
94
+ lines.push(`| ${cell(row.severity, 10)}${row.blocking ? " (blocking)" : ""} | ${row.state} | ${where(row)} | ${cell(text || row.title)} |`);
95
+ }
96
+ }
97
+ lines.push("", `**Summary:** ${rows.filter((r) => r.state === "new").length} new · ${rows.filter((r) => r.state === "open").length} open · ${closed} closed · ${blocking} blocking`);
98
+ if (input.run)
99
+ lines.push("", formatRunSummary(input.run));
100
+ return lines.join("\n");
101
+ }
102
+ /** Appends to `$GITHUB_STEP_SUMMARY` when it is set. A summary that cannot be
103
+ * written is reported on stderr and never fails the review. */
104
+ export function appendStepSummary(markdown, path = process.env.GITHUB_STEP_SUMMARY) {
105
+ if (!path)
106
+ return;
107
+ try {
108
+ appendFileSync(path, `${markdown}\n`);
109
+ }
110
+ catch (error) {
111
+ console.error(`Could not write the job summary: ${String(error)}`);
112
+ }
113
+ }
114
+ /** The whole `--output=github` output of one finished review. */
115
+ export function emitGithubOutput(input) {
116
+ for (const line of githubAnnotations(input.findings))
117
+ console.log(line);
118
+ appendStepSummary(githubSummary(input));
119
+ }
120
+ /** Progress lines that come from somewhere else (the server's job log) are
121
+ * printed on stderr, and the runner reads workflow commands from stderr too.
122
+ * A line that would start one is given a prefix so it stays plain text. */
123
+ export function neutralizeCommands(text) {
124
+ return text
125
+ .split("\n")
126
+ .map((line) => (line.trimStart().startsWith("::") ? `[log] ${line}` : line))
127
+ .join("\n");
128
+ }
@@ -2,6 +2,7 @@ import { type ParsedFinding } from "../pr/findings.ts";
2
2
  import { type ResolvedFinding } from "../review/carry_over.ts";
3
3
  import { type ReviewBlocking } from "../review/blocking.ts";
4
4
  import type { Revision } from "../review/revision.ts";
5
+ import type { BilledTo, CostReason, CostStatus } from "../util/cost.ts";
5
6
  export type ReviewWarning = {
6
7
  code: string;
7
8
  message: string;
@@ -44,7 +45,7 @@ export declare function summaryCounts(findings: ResolvedFinding[], mode?: Review
44
45
  export declare function reviewExitCodeFromResolved(findings: ResolvedFinding[], mode?: ReviewBlocking): number;
45
46
  /** One finding as the human renderer prints it. Local, PR and remote reviews
46
47
  * all normalize into this shape before printing, so the three modes cannot
47
- * drift into different products (CORE-43 / F21, F22). */
48
+ * drift into different products. */
48
49
  export type HumanFinding = {
49
50
  state: "new" | "open" | "closed";
50
51
  severity: string;
@@ -61,12 +62,20 @@ export type HumanFinding = {
61
62
  } | null;
62
63
  };
63
64
  /** Local and PR findings: blocking is decided here from the configured rule,
64
- * exactly as the exit code decides it (CORE-41). */
65
+ * exactly as the exit code decides it. */
65
66
  export declare function humanFindingsFromResolved(findings: ResolvedFinding[], mode?: ReviewBlocking): HumanFinding[];
66
67
  /** Remote findings already carry the server's blocking decision, so the label
67
68
  * uses that field rather than re-deriving the rule on a client that may not
68
- * share the server's config (CORE-41). */
69
+ * share the server's config. */
69
70
  export declare function humanFindingsFromJson(findings: JsonReviewFinding[]): HumanFinding[];
71
+ /** The finding heading, as the shared header prints it. A parsed heading is
72
+ * `[P2 · non-blocking] \`path\`: \`symbol\``. The label and the location are
73
+ * already printed beside it, so only the symbol survives. An older heading
74
+ * that used an em dash is still accepted, so comments posted before 0.5.0
75
+ * stay readable. A heading with no symbol leaves nothing: printing the label
76
+ * or the path again would repeat what the line already says. */
77
+ export declare function humanShortTitle(row: HumanFinding): string;
78
+ export declare function sortHumanFindings(rows: HumanFinding[]): HumanFinding[];
70
79
  export type HumanReviewInput = {
71
80
  /** Header line: repo, subject and mode. */
72
81
  title: string;
@@ -74,13 +83,13 @@ export type HumanReviewInput = {
74
83
  stats?: string;
75
84
  guideBuiltAt: string | null;
76
85
  /** `null` when the run cannot say (a server older than 0.5.0 sends no
77
- * codegraph block). A wrong "used" is what F23 was about, so unknown is
86
+ * codegraph block). A wrong "used" is misleading, so unknown is
78
87
  * reported as unknown rather than guessed. */
79
88
  codegraphState: "used" | "disabled" | "unavailable" | null;
80
89
  findings: HumanFinding[];
81
90
  warnings?: ReviewWarning[];
82
91
  };
83
- /** The single human-readable review format (CORE-43 / F21, F22, F23). Local,
92
+ /** The single human-readable review format. Local,
84
93
  * PR and remote reviews print this: a header naming the guide build date and
85
94
  * the codegraph state, the findings grouped by state, then one summary line.
86
95
  *
@@ -90,11 +99,21 @@ export type HumanReviewInput = {
90
99
  export declare function formatHumanReview(input: HumanReviewInput): string;
91
100
  /** Local review output: the shared format plus the working-tree file stats. */
92
101
  export declare function formatHumanLocalReview(header: string, revision: Revision, guideBuiltAt: string | null, codegraphState: "used" | "disabled" | "unavailable", findings: ResolvedFinding[], warnings: ReviewWarning[], mode?: ReviewBlocking): string;
93
- /** The exit code for a remote review (CORE-41). The server already decided
102
+ /** The exit code for a remote review. The server already decided
94
103
  * each finding's `blocking` under its own configured rule, so the client
95
104
  * trusts that field instead of re-deriving the rule from a title: the client
96
105
  * may not even have the same config as the server. */
97
106
  export declare function reviewExitCodeFromJsonFindings(findings: JsonReviewFinding[]): number;
107
+ /** `costUsd` is `null` when the cost is unknown, and `costStatus` and
108
+ * `costNote` say why. */
109
+ export type ReviewUsage = {
110
+ tokensIn: number;
111
+ tokensOut: number;
112
+ costUsd: number | null;
113
+ costStatus?: CostStatus;
114
+ costNote?: CostReason | null;
115
+ billedTo?: BilledTo;
116
+ };
98
117
  export type LocalReviewJsonInput = {
99
118
  repo: string;
100
119
  branch: string;
@@ -105,11 +124,7 @@ export type LocalReviewJsonInput = {
105
124
  codegraphReason: string | null;
106
125
  findings: ResolvedFinding[];
107
126
  warnings: ReviewWarning[];
108
- usage: {
109
- tokensIn: number;
110
- tokensOut: number;
111
- costUsd: number | null;
112
- };
127
+ usage: ReviewUsage;
113
128
  durationMs: number;
114
129
  reviewBlocking?: ReviewBlocking;
115
130
  };
@@ -120,11 +135,7 @@ export type PrReviewJsonInput = {
120
135
  guideBuiltAt: string | null;
121
136
  codegraphState: "used" | "disabled" | "unavailable";
122
137
  codegraphReason: string | null;
123
- usage: {
124
- tokensIn: number;
125
- tokensOut: number;
126
- costUsd: number | null;
127
- };
138
+ usage: ReviewUsage;
128
139
  durationMs: number;
129
140
  reviewBlocking?: ReviewBlocking;
130
141
  };
@@ -106,7 +106,7 @@ export function reviewExitCodeFromResolved(findings, mode = DEFAULT_REVIEW_BLOCK
106
106
  return summaryCounts(findings, mode).blocking > 0 ? 1 : 0;
107
107
  }
108
108
  /** Local and PR findings: blocking is decided here from the configured rule,
109
- * exactly as the exit code decides it (CORE-41). */
109
+ * exactly as the exit code decides it. */
110
110
  export function humanFindingsFromResolved(findings, mode = DEFAULT_REVIEW_BLOCKING) {
111
111
  return findings.map((row) => {
112
112
  const suggestion = readSuggestion(row.bodyMd);
@@ -131,7 +131,7 @@ export function humanFindingsFromResolved(findings, mode = DEFAULT_REVIEW_BLOCKI
131
131
  }
132
132
  /** Remote findings already carry the server's blocking decision, so the label
133
133
  * uses that field rather than re-deriving the rule on a client that may not
134
- * share the server's config (CORE-41). */
134
+ * share the server's config. */
135
135
  export function humanFindingsFromJson(findings) {
136
136
  return findings.map((row) => ({
137
137
  state: row.state,
@@ -158,8 +158,8 @@ function humanLocation(row) {
158
158
  * already printed beside it, so only the symbol survives. An older heading
159
159
  * that used an em dash is still accepted, so comments posted before 0.5.0
160
160
  * stay readable. A heading with no symbol leaves nothing: printing the label
161
- * or the path again would repeat what the line already says (CORE-43). */
162
- function humanShortTitle(row) {
161
+ * or the path again would repeat what the line already says. */
162
+ export function humanShortTitle(row) {
163
163
  const withoutLabel = row.title.replace(/^\[P\d\s*·\s*[^\]]*\]\s*/, "");
164
164
  const dash = withoutLabel.indexOf(" — ");
165
165
  const tail = dash === -1 ? withoutLabel : withoutLabel.slice(dash + 3);
@@ -168,7 +168,7 @@ function humanShortTitle(row) {
168
168
  const clean = symbol.replace(/^`|`$/g, "").trim();
169
169
  return clean === "" || clean === row.path ? "" : clean;
170
170
  }
171
- function sortHumanFindings(rows) {
171
+ export function sortHumanFindings(rows) {
172
172
  const stateOrder = { new: 0, open: 1, closed: 2 };
173
173
  return [...rows].sort((a, b) => {
174
174
  const ds = stateOrder[a.state] - stateOrder[b.state];
@@ -185,7 +185,7 @@ function sortHumanFindings(rows) {
185
185
  return (a.lineFrom ?? 0) - (b.lineFrom ?? 0);
186
186
  });
187
187
  }
188
- /** The single human-readable review format (CORE-43 / F21, F22, F23). Local,
188
+ /** The single human-readable review format. Local,
189
189
  * PR and remote reviews print this: a header naming the guide build date and
190
190
  * the codegraph state, the findings grouped by state, then one summary line.
191
191
  *
@@ -271,7 +271,7 @@ export function formatHumanLocalReview(header, revision, guideBuiltAt, codegraph
271
271
  warnings,
272
272
  });
273
273
  }
274
- /** The exit code for a remote review (CORE-41). The server already decided
274
+ /** The exit code for a remote review. The server already decided
275
275
  * each finding's `blocking` under its own configured rule, so the client
276
276
  * trusts that field instead of re-deriving the rule from a title: the client
277
277
  * may not even have the same config as the server. */
@@ -5,7 +5,7 @@ import { type Platform } from "./util/runtime.ts";
5
5
  * flags and no interactive prompts, reusing what `init` was told. */
6
6
  export type RepoConfig = {
7
7
  auth?: "gh" | "pat";
8
- ai?: "none" | "openrouter" | "hetzner";
8
+ ai?: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
9
9
  lowModel?: string;
10
10
  highModel?: string;
11
11
  maxCommits?: number;
@@ -24,7 +24,7 @@ export type RepoConfig = {
24
24
  };
25
25
  export type UserConfig = {
26
26
  auth?: "gh" | "pat";
27
- ai?: "none" | "openrouter" | "hetzner";
27
+ ai?: "none" | "openrouter" | "openai" | "anthropic" | "opencode-zen" | "opencode-go";
28
28
  lowModel?: string;
29
29
  highModel?: string;
30
30
  /** Written only by `co-maintainer set --token=...`. Every other write
@@ -61,22 +61,44 @@ export type UserConfig = {
61
61
  };
62
62
  /** Cap on jobs running at once across all types. Unset means no limit. */
63
63
  maxConcurrentJobs?: number;
64
- /** What a review uses to decide a blocking finding (CORE-41). `model` lets
64
+ /** What a review uses to decide a blocking finding. `model` lets
65
65
  * the model's own severity decide, `severity` uses a fixed threshold. */
66
66
  reviewBlocking?: "model" | "severity";
67
67
  /** Remote review CLI target (`co-maintainer set`). */
68
68
  remoteHost?: string;
69
69
  /** Bearer token for `/api/remote/*`; only written by `set`. */
70
70
  remoteToken?: string;
71
- /** Hosts for which the unpublished-code notice was shown (plan §14.1 G13). */
71
+ /** Hosts for which the unpublished-code notice was shown. */
72
72
  remoteNoticeShownFor?: string[];
73
- /** Remote client watchdog timeout; dashboard may override (plan §19.6). */
73
+ /** Remote client watchdog timeout; dashboard may override. */
74
74
  remoteSyncTimeoutSeconds?: number;
75
75
  maxConcurrentRemoteReviewsPerToken?: number;
76
76
  remoteToolOutputMaxChars?: number;
77
+ /** Whether remote clients may bring their own AI key. `off`
78
+ * rejects one, `allow` accepts one and falls back to the server key when
79
+ * absent, `require` refuses a review that does not carry one. The
80
+ * `CM_REMOTE_BYOK_POLICY` env var overrides this, which is how a hosted
81
+ * deployment forces the setting. */
82
+ remoteByokPolicy?: ByokPolicy;
83
+ /** The review policy new repositories start with: a template
84
+ * name or a policy object. Unset means the old per repository switches. */
85
+ reviewPolicy?: string | Record<string, unknown>;
86
+ /** The client's own AI key for remote review, sent only when
87
+ * `--remote-byok` (or `remoteByokDefault`) turns it on for a run. Only
88
+ * ever written by `set`. */
89
+ remoteByok?: string;
90
+ /** Whether `co-maintainer review --remote` sends `remoteByok` by default.
91
+ * `--no-remote-byok` turns it off for one run without unsetting this. */
92
+ remoteByokDefault?: boolean;
77
93
  repos?: Record<string, RepoConfig>;
78
94
  };
79
- /** Platform/env injection keeps the OS branches testable (plan §0b): the
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
+ export declare function remoteByokPolicy(config?: Pick<UserConfig, "remoteByokPolicy">): ByokPolicy;
101
+ /** Platform/env injection keeps the OS branches testable: the
80
102
  * migration's `"windows"` vs `"win32"` trap is invisible otherwise. */
81
103
  export declare function getConfigDir(os?: Platform, env?: (name: string) => string | undefined): string;
82
104
  export declare function getCacheDir(os?: Platform, env?: (name: string) => string | undefined): string;
@@ -1,11 +1,25 @@
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
+ export function remoteByokPolicy(config = readConfig()) {
7
+ // An env value that is not one of the three known policies is treated as
8
+ // unreadable and falls back to the config, so a typo cannot silently turn a
9
+ // configured `require` into `off`. An explicit `off` still overrides.
10
+ const env = getEnv("CM_REMOTE_BYOK_POLICY");
11
+ const raw = env === "off" || env === "allow" || env === "require"
12
+ ? env
13
+ : config.remoteByokPolicy;
14
+ return raw === "allow" || raw === "require" ? raw : "off";
15
+ }
2
16
  function homeDir(env) {
3
17
  const home = env("HOME") ?? env("USERPROFILE");
4
18
  if (!home)
5
19
  throw new Error("Could not determine the home directory");
6
20
  return home;
7
21
  }
8
- /** Platform/env injection keeps the OS branches testable (plan §0b): the
22
+ /** Platform/env injection keeps the OS branches testable: the
9
23
  * migration's `"windows"` vs `"win32"` trap is invisible otherwise. */
10
24
  export function getConfigDir(os = currentPlatform(), env = getEnv) {
11
25
  const home = homeDir(env);
@@ -1,5 +1,5 @@
1
1
  import type { Run } from "../pr/checkout.ts";
2
- /** `refs/remotes/<remote>/…` for scope and PR algorithms (plan §10.5). */
2
+ /** `refs/remotes/<remote>/…` for scope and PR algorithms. */
3
3
  export declare function detectDefaultBranchRef(cwd: string, remote: string, run: Run): Promise<string | undefined>;
4
4
  /** Short branch name (`main`) for `refs/remotes/<remote>/<branch>`. */
5
5
  export declare function resolveDefaultBranchName(cwd: string, remote: string, run: Run): Promise<string | undefined>;
@@ -1,4 +1,4 @@
1
- /** `refs/remotes/<remote>/…` for scope and PR algorithms (plan §10.5). */
1
+ /** `refs/remotes/<remote>/…` for scope and PR algorithms. */
2
2
  export async function detectDefaultBranchRef(cwd, remote, run) {
3
3
  const symref = await run("git", ["symbolic-ref", "-q", `refs/remotes/${remote}/HEAD`], cwd);
4
4
  if (symref.code === 0 && symref.stdout.trim()) {
@@ -1,4 +1,4 @@
1
- import { githubFetch, GitHubHttpError, paginate } from "./client.js";
1
+ import { githubFetch, httpError, paginate } from "./client.js";
2
2
  import { importAppPrivateKey, signAppJwt } from "./jwt.js";
3
3
  const API = "https://api.github.com";
4
4
  function headers(token) {
@@ -14,7 +14,7 @@ async function call(endpoint, token, init = {}) {
14
14
  headers: { ...headers(token), ...(init.headers ?? {}) },
15
15
  });
16
16
  if (!response.ok) {
17
- throw new GitHubHttpError(response.status, await response.text());
17
+ throw await httpError(response, init.method ?? "GET", endpoint, "App");
18
18
  }
19
19
  if (response.status === 204)
20
20
  return undefined;
@@ -1,10 +1,4 @@
1
- export declare const APP_MANIFEST_PERMISSIONS: {
2
- readonly contents: "read";
3
- readonly issues: "write";
4
- readonly pull_requests: "write";
5
- readonly checks: "write";
6
- readonly metadata: "read";
7
- };
1
+ export declare const APP_MANIFEST_PERMISSIONS: Record<string, import("./permissions.ts").Level>;
8
2
  export declare const APP_MANIFEST_EVENTS: readonly ["pull_request", "pull_request_review", "pull_request_review_comment", "issue_comment"];
9
3
  /** GitHub App names are globally unique and capped at 34 characters. */
10
4
  export declare const APP_NAME_MAX = 34;
@@ -15,14 +15,9 @@
15
15
  * GitHub delivers both to every App without a subscription, which is why
16
16
  * `webhook.ts` handles them at all. */
17
17
  import { githubFetch } from "./client.js";
18
+ import { APP_PERMISSIONS } from "./permissions.js";
18
19
  import { webhookReachabilityProblem } from "../util/webhook_reachability.js";
19
- export const APP_MANIFEST_PERMISSIONS = {
20
- contents: "read",
21
- issues: "write",
22
- pull_requests: "write",
23
- checks: "write",
24
- metadata: "read",
25
- };
20
+ export const APP_MANIFEST_PERMISSIONS = APP_PERMISSIONS;
26
21
  export const APP_MANIFEST_EVENTS = [
27
22
  "pull_request",
28
23
  "pull_request_review",
@@ -3,8 +3,11 @@
3
3
  * not use `githubFetch`; both paths share `paginate` and `waitForRateLimit`. */
4
4
  export declare class GitHubHttpError extends Error {
5
5
  readonly status: number;
6
- constructor(status: number, body: string);
6
+ constructor(status: number, body: string, hint?: string);
7
7
  }
8
+ /** A failed response as an error. A 403 or 404 names the endpoint and the
9
+ * permission it needs, because GitHub's own body rarely says which one. */
10
+ export declare function httpError(response: Response, method: string, endpoint: string, who: "token" | "App"): Promise<GitHubHttpError>;
8
11
  export declare function isAccessDenied(error: unknown): boolean;
9
12
  /** Primary probes wake at the reset, but at least every half hour. */
10
13
  export declare const RATE_LIMIT_PROBE_MS: number;
@@ -3,14 +3,26 @@
3
3
  * not use `githubFetch`; both paths share `paginate` and `waitForRateLimit`. */
4
4
  import { log } from "../util/log.js";
5
5
  import { networkFailure } from "../cli/error.js";
6
+ import { missingHint, refusalHint } from "./permissions.js";
6
7
  export class GitHubHttpError extends Error {
7
8
  status;
8
- constructor(status, body) {
9
- super(`GitHub API ${status}: ${body}`);
9
+ constructor(status, body, hint) {
10
+ super(`GitHub API ${status}: ${body}${hint ? ` ${hint}` : ""}`);
10
11
  this.status = status;
11
12
  this.name = "GitHubHttpError";
12
13
  }
13
14
  }
15
+ /** A failed response as an error. A 403 or 404 names the endpoint and the
16
+ * permission it needs, because GitHub's own body rarely says which one. */
17
+ export async function httpError(response, method, endpoint, who) {
18
+ const body = await response.text();
19
+ const hint = response.status === 403
20
+ ? refusalHint(method, endpoint, who)
21
+ : response.status === 404
22
+ ? missingHint(method, endpoint, who)
23
+ : undefined;
24
+ return new GitHubHttpError(response.status, body, hint);
25
+ }
14
26
  export function isAccessDenied(error) {
15
27
  return (error instanceof GitHubHttpError &&
16
28
  (error.status === 403 || error.status === 404));
@@ -17,12 +17,12 @@ function ghSpawn() {
17
17
  return { command: bin, prefix: script ? [script] : [] };
18
18
  }
19
19
  /** Turns a failed `gh api` call into a message that says what broke, why, and
20
- * what to do next (CORE-12, F09/F24/F28). The four shapes the DX research hit:
20
+ * what to do next. The four shapes that came up in practice:
21
21
  * `gh` missing from PATH, the repo missing or unreadable, an empty stderr, and
22
22
  * everything else. The first line is the only part a human reads, so it always
23
23
  * names the situation; the raw gh text moves to the hint so it stays available
24
24
  * without drowning the message. */
25
- function ghFailure(endpoint, stderr) {
25
+ function ghFailure(method, endpoint, stderr) {
26
26
  if (/ENOENT|command not found|not recognized/i.test(stderr)) {
27
27
  return new CliError("gh_not_installed", "GitHub CLI (gh) was not found.", "Install it from https://cli.github.com or use --auth=pat.", EXIT_USAGE);
28
28
  }
@@ -32,6 +32,9 @@ function ghFailure(endpoint, stderr) {
32
32
  const subject = match ? `${match[1]}/${match[2]}` : endpoint;
33
33
  return new CliError("repo_not_found", `${subject} was not found, or your GitHub account cannot read it.`, "Check the name and run gh auth status.", EXIT_USAGE);
34
34
  }
35
+ if (/HTTP 403/i.test(stderr)) {
36
+ return new CliError("github_forbidden", `GitHub refused ${method} ${endpoint}.`, `Your gh login needs the repo scope. Run gh auth refresh -s repo. gh said: ${stderr}`, EXIT_USAGE);
37
+ }
35
38
  // An empty stderr used to collapse to just the endpoint, which told the user
36
39
  // nothing about what happened.
37
40
  if (stderr.length === 0) {
@@ -129,7 +132,7 @@ export class GhClient {
129
132
  this.debug = debug;
130
133
  }
131
134
  request(endpoint) {
132
- return this.call(endpoint, () => {
135
+ return this.call("GET", endpoint, () => {
133
136
  const { command, prefix } = ghSpawn();
134
137
  return commandOutput(command, {
135
138
  args: [...prefix, "api", endpoint],
@@ -151,7 +154,7 @@ export class GhClient {
151
154
  return paginate((e) => this.request(e), endpoint, limit, progress);
152
155
  }
153
156
  send(endpoint, method, body) {
154
- return this.call(endpoint, () => {
157
+ return this.call(method, endpoint, () => {
155
158
  const { command, prefix } = ghSpawn();
156
159
  return commandWithInput(command, {
157
160
  args: [...prefix, "api", "-X", method, endpoint, "--input", "-"],
@@ -161,13 +164,13 @@ export class GhClient {
161
164
  }, JSON.stringify(body));
162
165
  });
163
166
  }
164
- async call(endpoint, run) {
167
+ async call(method, endpoint, run) {
165
168
  noteGhCall(this.debug);
166
169
  const started = Date.now();
167
170
  let spun = false;
168
171
  for (;;) {
169
172
  // A missing `gh` binary rejects the spawn instead of returning a failed
170
- // result, so the ENOENT case has to be caught here too (CORE-12).
173
+ // result, so the ENOENT case has to be caught here too.
171
174
  let result;
172
175
  try {
173
176
  result = await run();
@@ -193,7 +196,7 @@ export class GhClient {
193
196
  ? bucket.remaining === 0
194
197
  : /rate limit/i.test(error);
195
198
  if (!limited)
196
- throw ghFailure(endpoint, error);
199
+ throw ghFailure(method, endpoint, error);
197
200
  const resetAt = bucket?.resetAt ?? new Date(Date.now() + RATE_LIMIT_PROBE_MS);
198
201
  const delay = probeDelay(resetAt, Date.now());
199
202
  if (delay === 0) {
@@ -1,4 +1,4 @@
1
- import { githubFetch, GitHubHttpError, paginate } from "./client.js";
1
+ import { githubFetch, httpError, paginate } from "./client.js";
2
2
  export class PatClient {
3
3
  token;
4
4
  constructor(token) {
@@ -15,7 +15,7 @@ export class PatClient {
15
15
  },
16
16
  });
17
17
  if (!response.ok) {
18
- throw new GitHubHttpError(response.status, await response.text());
18
+ throw await httpError(response, "GET", endpoint, "token");
19
19
  }
20
20
  return (await response.json());
21
21
  }
@@ -31,7 +31,7 @@ export class PatClient {
31
31
  body: JSON.stringify(body),
32
32
  });
33
33
  if (!response.ok) {
34
- throw new GitHubHttpError(response.status, await response.text());
34
+ throw await httpError(response, "POST", endpoint, "token");
35
35
  }
36
36
  return (await response.json());
37
37
  }
@@ -50,7 +50,7 @@ export class PatClient {
50
50
  body: JSON.stringify(body),
51
51
  });
52
52
  if (!response.ok) {
53
- throw new GitHubHttpError(response.status, await response.text());
53
+ throw await httpError(response, "PATCH", endpoint, "token");
54
54
  }
55
55
  return (await response.json());
56
56
  }
@@ -0,0 +1,36 @@
1
+ /** The one list of GitHub calls this codebase makes and what each needs.
2
+ *
3
+ * The App manifest, the App permission check on save, the 403 and 404 hints
4
+ * and the permission tables in the docs are all read from `ENDPOINTS`.
5
+ * `permissions.test.ts` scans the source for every endpoint it calls and fails
6
+ * when one is missing here, so a new call cannot ship without its permission. */
7
+ export type Level = "read" | "write";
8
+ export type Permission = "metadata" | "contents" | "pull_requests" | "issues" | "checks";
9
+ /** `gh` is a signed-in GitHub CLI, `pat` a personal access token, `app` a
10
+ * GitHub App installation token. */
11
+ export type Identity = "gh" | "pat" | "app";
12
+ export type Endpoint = {
13
+ method: "GET" | "POST" | "PATCH";
14
+ /** `{repo}` is `owner/name`, `{path}` may hold slashes, any other
15
+ * placeholder is one path segment. */
16
+ pattern: string;
17
+ permission?: Permission;
18
+ level?: Level;
19
+ identities: readonly Identity[];
20
+ feature: string;
21
+ };
22
+ /** Key order is the order the manifest and the doc tables list them in. */
23
+ export declare const PERMISSION_LABEL: Record<Permission, string>;
24
+ export declare const ENDPOINTS: readonly Endpoint[];
25
+ /** What the App must be granted, in the shape the manifest and the save-time
26
+ * check want. */
27
+ export declare const APP_PERMISSIONS: Record<string, Level>;
28
+ /** The entry for a real request such as `repos/o/r/pulls/4/files?page=2`. */
29
+ export declare function endpointFor(method: string, endpoint: string): Endpoint | undefined;
30
+ /** `who` is what the failing credential is called in the sentence. */
31
+ export declare function refusalHint(method: string, endpoint: string, who: "token" | "App"): string;
32
+ export declare function missingHint(method: string, endpoint: string, who: "token" | "App"): string;
33
+ export type TableKind = "app" | "pat-fine" | "pat-classic" | "gh" | "oauth";
34
+ /** A markdown table for the docs. `build_docs.ts` writes it between the
35
+ * `permissions:<kind>` markers and a test fails when the page is stale. */
36
+ export declare function permissionTable(kind: TableKind): string;