co-maintainer 0.4.13 → 0.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +57 -38
  3. package/dist/main.js +2 -3
  4. package/dist/package.json +21 -15
  5. package/dist/src/ai/batch.d.ts +24 -1
  6. package/dist/src/ai/batch.js +75 -23
  7. package/dist/src/ai/estimate.d.ts +40 -0
  8. package/dist/src/ai/estimate.js +113 -0
  9. package/dist/src/ai/fake.d.ts +1 -1
  10. package/dist/src/ai/fake.js +1 -1
  11. package/dist/src/ai/hetzner.js +1 -1
  12. package/dist/src/ai/openrouter.d.ts +13 -0
  13. package/dist/src/ai/openrouter.js +66 -12
  14. package/dist/src/ai/pricing.d.ts +14 -0
  15. package/dist/src/ai/pricing.js +77 -0
  16. package/dist/src/ai/provider.js +8 -0
  17. package/dist/src/ai/verify.d.ts +25 -0
  18. package/dist/src/ai/verify.js +78 -0
  19. package/dist/src/cli/args.js +25 -29
  20. package/dist/src/cli/commands/config.d.ts +44 -0
  21. package/dist/src/cli/commands/config.js +220 -0
  22. package/dist/src/cli/commands/probe.js +70 -103
  23. package/dist/src/cli/commands/registry.d.ts +57 -0
  24. package/dist/src/cli/commands/registry.js +713 -0
  25. package/dist/src/cli/commands/review.js +66 -43
  26. package/dist/src/cli/commands/serve.d.ts +4 -2
  27. package/dist/src/cli/commands/serve.js +14 -10
  28. package/dist/src/cli/commands/set.d.ts +2 -1
  29. package/dist/src/cli/commands/set.js +63 -17
  30. package/dist/src/cli/commands/view.d.ts +1 -0
  31. package/dist/src/cli/commands/view.js +180 -0
  32. package/dist/src/cli/error.d.ts +39 -0
  33. package/dist/src/cli/error.js +64 -0
  34. package/dist/src/cli/main.d.ts +7 -0
  35. package/dist/src/cli/main.js +82 -1
  36. package/dist/src/cli/prompt.js +21 -7
  37. package/dist/src/cli/review_args.d.ts +4 -0
  38. package/dist/src/cli/review_args.js +22 -9
  39. package/dist/src/cli/review_output.d.ts +2 -2
  40. package/dist/src/cli/review_output.js +7 -6
  41. package/dist/src/cli/review_result.d.ts +59 -8
  42. package/dist/src/cli/review_result.js +145 -103
  43. package/dist/src/config.d.ts +15 -0
  44. package/dist/src/config.js +31 -0
  45. package/dist/src/github/app.d.ts +13 -0
  46. package/dist/src/github/app.js +23 -0
  47. package/dist/src/github/app_manifest.d.ts +50 -0
  48. package/dist/src/github/app_manifest.js +138 -0
  49. package/dist/src/github/client.js +7 -1
  50. package/dist/src/github/collect.js +1 -1
  51. package/dist/src/github/gh.js +75 -18
  52. package/dist/src/knowledge/facts.js +8 -3
  53. package/dist/src/knowledge/guide.d.ts +8 -0
  54. package/dist/src/knowledge/guide.js +32 -8
  55. package/dist/src/knowledge/probe.js +13 -3
  56. package/dist/src/knowledge/sections.d.ts +9 -0
  57. package/dist/src/knowledge/sections.js +16 -0
  58. package/dist/src/knowledge/skill.d.ts +5 -0
  59. package/dist/src/knowledge/skill.js +68 -21
  60. package/dist/src/knowledge/synthesis.d.ts +17 -1
  61. package/dist/src/knowledge/synthesis.js +70 -15
  62. package/dist/src/knowledge/types.d.ts +5 -0
  63. package/dist/src/local/codegraph_prepare.d.ts +3 -2
  64. package/dist/src/local/codegraph_prepare.js +4 -2
  65. package/dist/src/local/git_ops.d.ts +5 -4
  66. package/dist/src/local/git_ops.js +8 -9
  67. package/dist/src/local/review_local.js +36 -14
  68. package/dist/src/pr/checkout.js +14 -3
  69. package/dist/src/pr/codegraph_tools.js +3 -3
  70. package/dist/src/pr/diff_summary.js +3 -3
  71. package/dist/src/pr/findings.js +16 -5
  72. package/dist/src/pr/findings_json.d.ts +26 -0
  73. package/dist/src/pr/findings_json.js +187 -0
  74. package/dist/src/pr/review_copy.d.ts +20 -0
  75. package/dist/src/pr/review_copy.js +40 -0
  76. package/dist/src/pr/reviewer.d.ts +2 -1
  77. package/dist/src/pr/reviewer.js +84 -91
  78. package/dist/src/remote/client.js +42 -42
  79. package/dist/src/remote/http.d.ts +5 -0
  80. package/dist/src/remote/http.js +49 -0
  81. package/dist/src/remote/server/guides.d.ts +22 -0
  82. package/dist/src/remote/server/guides.js +81 -0
  83. package/dist/src/remote/server/routes.js +24 -0
  84. package/dist/src/review/blocking.d.ts +35 -0
  85. package/dist/src/review/blocking.js +45 -0
  86. package/dist/src/review/carry_over.d.ts +12 -1
  87. package/dist/src/review/carry_over.js +35 -9
  88. package/dist/src/review/engine.d.ts +1 -0
  89. package/dist/src/review/guides.js +28 -1
  90. package/dist/src/server/api/installations.js +1 -1
  91. package/dist/src/server/api/repos.js +86 -3
  92. package/dist/src/server/api/settings.js +3 -3
  93. package/dist/src/server/app.d.ts +3 -0
  94. package/dist/src/server/app.js +12 -5
  95. package/dist/src/server/pages/activity.d.ts +1 -0
  96. package/dist/src/server/pages/activity.js +2 -2
  97. package/dist/src/server/pages/add_repo.js +73 -5
  98. package/dist/src/server/pages/client.d.ts +1 -1
  99. package/dist/src/server/pages/client.js +1 -1
  100. package/dist/src/server/pages/home.d.ts +6 -1
  101. package/dist/src/server/pages/home.js +25 -2
  102. package/dist/src/server/pages/knowledge.js +1 -1
  103. package/dist/src/server/pages/layout.js +2 -2
  104. package/dist/src/server/pages/repo.d.ts +1 -1
  105. package/dist/src/server/pages/repo.js +21 -2
  106. package/dist/src/server/pages/repo_prs.d.ts +5 -1
  107. package/dist/src/server/pages/repo_prs.js +36 -1
  108. package/dist/src/server/pages/repo_remote.js +2 -2
  109. package/dist/src/server/pages/repo_settings.js +2 -2
  110. package/dist/src/server/pages/router.d.ts +6 -0
  111. package/dist/src/server/pages/router.js +129 -9
  112. package/dist/src/server/pages/settings.d.ts +1 -1
  113. package/dist/src/server/pages/settings.js +87 -9
  114. package/dist/src/server/pages/styles.d.ts +1 -1
  115. package/dist/src/server/pages/styles.js +1 -1
  116. package/dist/src/services/probe.d.ts +28 -0
  117. package/dist/src/services/probe.js +133 -0
  118. package/dist/src/services/remake_cron.d.ts +1 -1
  119. package/dist/src/services/remake_cron.js +3 -3
  120. package/dist/src/services/remote_review.js +14 -4
  121. package/dist/src/services/review.d.ts +11 -0
  122. package/dist/src/services/review.js +39 -11
  123. package/dist/src/services/setup.js +37 -19
  124. package/dist/src/services/setup_checklist.d.ts +10 -0
  125. package/dist/src/services/setup_checklist.js +87 -0
  126. package/dist/src/store/app_db.js +5 -2
  127. package/dist/src/store/cache_db.d.ts +4 -0
  128. package/dist/src/store/cache_db.js +27 -4
  129. package/dist/src/store/deliveries.d.ts +7 -0
  130. package/dist/src/store/deliveries.js +15 -0
  131. package/dist/src/tools/codegraph.d.ts +30 -0
  132. package/dist/src/tools/codegraph.js +49 -12
  133. package/dist/src/types.d.ts +10 -0
  134. package/dist/src/util/log.d.ts +3 -0
  135. package/dist/src/util/log.js +12 -0
  136. package/dist/src/util/run_summary.d.ts +33 -0
  137. package/dist/src/util/run_summary.js +47 -0
  138. package/dist/src/util/webhook_reachability.d.ts +11 -0
  139. package/dist/src/util/webhook_reachability.js +64 -0
  140. package/package.json +21 -15
@@ -1,6 +1,7 @@
1
1
  import { parseFindings } from "../pr/findings.js";
2
2
  import { readSuggestion, stripSuggestion } from "../pr/suggestion.js";
3
3
  import { anchorTextFromPatch, } from "../review/carry_over.js";
4
+ import { DEFAULT_REVIEW_BLOCKING, isBlocking, } from "../review/blocking.js";
4
5
  import { humanCopy } from "../services/review.js";
5
6
  const SEVERITY_RANK = {
6
7
  P0: 0,
@@ -8,12 +9,8 @@ const SEVERITY_RANK = {
8
9
  P2: 2,
9
10
  P3: 3,
10
11
  };
11
- export function isBlockingFinding(title, severity) {
12
- if (/\[P0\b/i.test(title) || /^P0$/i.test(severity))
13
- return true;
14
- if (/\[P\d\s*·\s*blocking\]/i.test(title))
15
- return true;
16
- return false;
12
+ export function isBlockingFinding(title, severity, mode = DEFAULT_REVIEW_BLOCKING) {
13
+ return isBlocking(mode, { severity, text: title });
17
14
  }
18
15
  export function resolvedFromFirstReview(parsed, filesByPath) {
19
16
  return parsed.map((finding) => {
@@ -54,14 +51,14 @@ function compareFindings(a, b) {
54
51
  export function sortResolvedFindings(findings) {
55
52
  return [...findings].sort(compareFindings);
56
53
  }
57
- export function toJsonFinding(row) {
54
+ export function toJsonFinding(row, mode = DEFAULT_REVIEW_BLOCKING) {
58
55
  const suggestion = readSuggestion(row.bodyMd);
59
56
  return {
60
57
  id: row.id,
61
58
  state: row.state,
62
59
  closeReason: row.closeReason ?? null,
63
60
  severity: row.severity,
64
- blocking: isBlockingFinding(row.title, row.severity),
61
+ blocking: isBlockingFinding(row.title, row.severity, mode),
65
62
  path: row.path,
66
63
  lineFrom: row.lineFrom,
67
64
  lineTo: row.lineTo,
@@ -90,11 +87,11 @@ export function revisionStats(revision) {
90
87
  truncated: false,
91
88
  };
92
89
  }
93
- export function summaryCounts(findings) {
90
+ export function summaryCounts(findings, mode = DEFAULT_REVIEW_BLOCKING) {
94
91
  let blocking = 0;
95
92
  for (const row of findings) {
96
93
  if ((row.state === "new" || row.state === "open") &&
97
- isBlockingFinding(row.title, row.severity)) {
94
+ isBlockingFinding(row.title, row.severity, mode)) {
98
95
  blocking++;
99
96
  }
100
97
  }
@@ -105,10 +102,50 @@ export function summaryCounts(findings) {
105
102
  blocking,
106
103
  };
107
104
  }
108
- export function reviewExitCodeFromResolved(findings) {
109
- return summaryCounts(findings).blocking > 0 ? 1 : 0;
105
+ export function reviewExitCodeFromResolved(findings, mode = DEFAULT_REVIEW_BLOCKING) {
106
+ return summaryCounts(findings, mode).blocking > 0 ? 1 : 0;
110
107
  }
111
- function locationLabel(row) {
108
+ /** Local and PR findings: blocking is decided here from the configured rule,
109
+ * exactly as the exit code decides it (CORE-41). */
110
+ export function humanFindingsFromResolved(findings, mode = DEFAULT_REVIEW_BLOCKING) {
111
+ return findings.map((row) => {
112
+ const suggestion = readSuggestion(row.bodyMd);
113
+ return {
114
+ state: row.state,
115
+ severity: row.severity,
116
+ blocking: isBlockingFinding(row.title, row.severity, mode),
117
+ path: row.path,
118
+ lineFrom: row.lineFrom,
119
+ lineTo: row.lineTo,
120
+ title: humanCopy(row.title),
121
+ body: humanCopy(stripSuggestion(row.bodyMd)).trim(),
122
+ suggestion: suggestion
123
+ ? {
124
+ lineFrom: suggestion.from,
125
+ lineTo: suggestion.to,
126
+ text: suggestion.code,
127
+ }
128
+ : null,
129
+ };
130
+ });
131
+ }
132
+ /** Remote findings already carry the server's blocking decision, so the label
133
+ * uses that field rather than re-deriving the rule on a client that may not
134
+ * share the server's config (CORE-41). */
135
+ export function humanFindingsFromJson(findings) {
136
+ return findings.map((row) => ({
137
+ state: row.state,
138
+ severity: row.severity,
139
+ blocking: row.blocking,
140
+ path: row.path,
141
+ lineFrom: row.lineFrom,
142
+ lineTo: row.lineTo,
143
+ title: row.title,
144
+ body: row.body.trim(),
145
+ suggestion: row.suggestion,
146
+ }));
147
+ }
148
+ function humanLocation(row) {
112
149
  if (!row.path)
113
150
  return row.title;
114
151
  const from = row.lineFrom ?? 0;
@@ -116,37 +153,59 @@ function locationLabel(row) {
116
153
  const span = from === to ? `${from}` : `${from}-${to}`;
117
154
  return `${row.path}:${span}`;
118
155
  }
119
- function shortTitle(row) {
120
- const title = humanCopy(row.title);
121
- const dash = title.indexOf(" — ");
122
- return dash === -1
123
- ? title
124
- : title
125
- .slice(dash + 3)
126
- .replace(/^`|`$/g, "")
127
- .trim();
156
+ /** The finding heading, as the shared header prints it. A parsed heading is
157
+ * `[P2 · non-blocking] \`path\`: \`symbol\``. The label and the location are
158
+ * already printed beside it, so only the symbol survives. An older heading
159
+ * that used an em dash is still accepted, so comments posted before 0.5.0
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) {
163
+ const withoutLabel = row.title.replace(/^\[P\d\s*·\s*[^\]]*\]\s*/, "");
164
+ const dash = withoutLabel.indexOf(" — ");
165
+ const tail = dash === -1 ? withoutLabel : withoutLabel.slice(dash + 3);
166
+ const colon = tail.indexOf(": ");
167
+ const symbol = colon === -1 ? tail : tail.slice(colon + 2);
168
+ const clean = symbol.replace(/^`|`$/g, "").trim();
169
+ return clean === "" || clean === row.path ? "" : clean;
128
170
  }
129
- function impactLabel(row) {
130
- const blocking = isBlockingFinding(row.title, row.severity);
131
- return `[${row.severity} · ${blocking ? "blocking" : "non-blocking"}]`;
171
+ function sortHumanFindings(rows) {
172
+ const stateOrder = { new: 0, open: 1, closed: 2 };
173
+ return [...rows].sort((a, b) => {
174
+ const ds = stateOrder[a.state] - stateOrder[b.state];
175
+ if (ds !== 0)
176
+ return ds;
177
+ const sa = SEVERITY_RANK[a.severity] ?? 9;
178
+ const sb = SEVERITY_RANK[b.severity] ?? 9;
179
+ if (sa !== sb)
180
+ return sa - sb;
181
+ const pa = a.path ?? "";
182
+ const pb = b.path ?? "";
183
+ if (pa !== pb)
184
+ return pa.localeCompare(pb);
185
+ return (a.lineFrom ?? 0) - (b.lineFrom ?? 0);
186
+ });
132
187
  }
133
- /** Plan §9.1 human-readable local review output. */
134
- export function formatHumanLocalReview(header, revision, guideBuiltAt, codegraphState, findings, warnings) {
135
- const stats = revisionStats(revision);
136
- const guideBit = guideBuiltAt
137
- ? `guide ${guideBuiltAt.slice(0, 10)}`
188
+ /** The single human-readable review format (CORE-43 / F21, F22, F23). Local,
189
+ * PR and remote reviews print this: a header naming the guide build date and
190
+ * the codegraph state, the findings grouped by state, then one summary line.
191
+ *
192
+ * The severity legend and the "ask for more detail" sentence are deliberately
193
+ * absent: a terminal has nobody to ask, and the legend is fixed noise. Both
194
+ * stay in the GitHub comment, which is a different reader. */
195
+ export function formatHumanReview(input) {
196
+ const guideBit = input.guideBuiltAt
197
+ ? `guide built ${input.guideBuiltAt.slice(0, 10)}`
138
198
  : "guide unknown";
139
- const cgBit = codegraphState === "used"
199
+ const cgBit = input.codegraphState === "used"
140
200
  ? "codegraph used"
141
- : codegraphState === "disabled"
201
+ : input.codegraphState === "disabled"
142
202
  ? "codegraph disabled"
143
- : "codegraph unavailable";
144
- const lines = [
145
- header,
146
- `${stats.files} files · +${stats.additions} −${stats.deletions} · ${guideBit} · ${cgBit}`,
147
- "",
148
- ];
149
- const sorted = sortResolvedFindings(findings);
203
+ : input.codegraphState === "unavailable"
204
+ ? "codegraph unavailable"
205
+ : "codegraph unknown";
206
+ const meta = [input.stats, guideBit, cgBit].filter(Boolean).join(" · ");
207
+ const lines = [input.title, meta, ""];
208
+ const sorted = sortHumanFindings(input.findings);
150
209
  const groups = [
151
210
  { label: "Closed", state: "closed", bullet: "✓" },
152
211
  { label: "Still open", state: "open", bullet: "•" },
@@ -160,25 +219,24 @@ export function formatHumanLocalReview(header, revision, guideBuiltAt, codegraph
160
219
  any = true;
161
220
  lines.push(`${group.label} (${rows.length})`);
162
221
  for (const row of rows) {
163
- const loc = locationLabel(row);
164
- const head = `${group.bullet} ${loc} ${impactLabel(row)} ${shortTitle(row)}`;
222
+ const loc = humanLocation(row);
223
+ const label = `[${row.severity} · ${row.blocking ? "blocking" : "non-blocking"}]`;
224
+ const short = humanShortTitle(row);
225
+ const head = `${group.bullet} ${loc} ${label}${short ? ` \`${short}\`` : ""}`;
165
226
  lines.push(` ${head}`);
166
- if (row.state !== "closed") {
167
- const body = humanCopy(stripSuggestion(row.bodyMd)).trim();
168
- if (body) {
169
- for (const part of body.split("\n")) {
170
- lines.push(` ${part}`);
171
- }
172
- const suggestion = readSuggestion(row.bodyMd);
173
- if (suggestion && row.path) {
174
- lines.push(` Suggested replacement for ${row.path}:${suggestion.from}${suggestion.to !== suggestion.from ? `-${suggestion.to}` : ""}`);
175
- lines.push(" ```");
176
- for (const part of suggestion.code.split("\n")) {
177
- lines.push(` ${part}`);
178
- }
179
- lines.push(" ```");
180
- }
181
- }
227
+ if (row.state === "closed")
228
+ continue;
229
+ if (row.body) {
230
+ for (const part of row.body.split("\n"))
231
+ lines.push(` ${part}`);
232
+ }
233
+ if (row.suggestion && row.path) {
234
+ const { lineFrom, lineTo, text } = row.suggestion;
235
+ lines.push(` Suggested replacement for ${row.path}:${lineFrom}${lineTo !== lineFrom ? `-${lineTo}` : ""}`);
236
+ lines.push(" ```");
237
+ for (const part of text.split("\n"))
238
+ lines.push(` ${part}`);
239
+ lines.push(" ```");
182
240
  }
183
241
  }
184
242
  lines.push("");
@@ -187,58 +245,41 @@ export function formatHumanLocalReview(header, revision, guideBuiltAt, codegraph
187
245
  lines.push("No actionable findings.");
188
246
  lines.push("");
189
247
  }
190
- const counts = summaryCounts(findings);
248
+ const counts = {
249
+ new: sorted.filter((f) => f.state === "new").length,
250
+ open: sorted.filter((f) => f.state === "open").length,
251
+ closed: sorted.filter((f) => f.state === "closed").length,
252
+ blocking: sorted.filter((f) => (f.state === "new" || f.state === "open") && f.blocking).length,
253
+ };
191
254
  lines.push(`Summary: ${counts.new} new · ${counts.open} open · ${counts.closed} closed · ${counts.blocking} blocking`);
192
- if (warnings.length) {
255
+ if (input.warnings?.length) {
193
256
  lines.push("Warnings:");
194
- for (const w of warnings)
257
+ for (const w of input.warnings)
195
258
  lines.push(` - ${w.message}`);
196
259
  }
197
260
  return lines.join("\n");
198
261
  }
199
- /** Human-readable findings block for remote sync JSON (same shape as `toJsonFinding`). */
200
- export function formatHumanJsonFindings(findings) {
201
- if (findings.length === 0) {
202
- return "## Findings\n\nNo actionable findings.";
203
- }
204
- const lines = ["## Findings", ""];
205
- const sorted = [...findings].sort((a, b) => (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9));
206
- for (const row of sorted) {
207
- const blocking = row.blocking || isBlockingFinding(row.title, row.severity);
208
- const impact = `[${row.severity} · ${blocking ? "blocking" : "non-blocking"}]`;
209
- let prefix = "";
210
- if (row.path) {
211
- const from = row.lineFrom ?? 0;
212
- const to = row.lineTo ?? from;
213
- const span = from === to ? `${from}` : `${from}-${to}`;
214
- prefix = `${row.path}:${span} `;
215
- }
216
- lines.push(`- ${prefix}${impact} ${row.title}`);
217
- const body = row.body.trim();
218
- if (body) {
219
- for (const part of body.split("\n")) {
220
- lines.push(` ${part}`);
221
- }
222
- }
223
- if (row.suggestion && row.path) {
224
- const { lineFrom, lineTo, text } = row.suggestion;
225
- lines.push(` Suggested replacement for ${row.path}:${lineFrom}${lineTo !== lineFrom ? `-${lineTo}` : ""}`);
226
- lines.push(" ```");
227
- for (const part of text.split("\n")) {
228
- lines.push(` ${part}`);
229
- }
230
- lines.push(" ```");
231
- }
232
- lines.push("");
233
- }
234
- return lines.join("\n").trimEnd();
262
+ /** Local review output: the shared format plus the working-tree file stats. */
263
+ export function formatHumanLocalReview(header, revision, guideBuiltAt, codegraphState, findings, warnings, mode = DEFAULT_REVIEW_BLOCKING) {
264
+ const stats = revisionStats(revision);
265
+ return formatHumanReview({
266
+ title: header,
267
+ stats: `${stats.files} files · +${stats.additions} −${stats.deletions}`,
268
+ guideBuiltAt,
269
+ codegraphState,
270
+ findings: humanFindingsFromResolved(findings, mode),
271
+ warnings,
272
+ });
235
273
  }
274
+ /** The exit code for a remote review (CORE-41). The server already decided
275
+ * each finding's `blocking` under its own configured rule, so the client
276
+ * trusts that field instead of re-deriving the rule from a title: the client
277
+ * may not even have the same config as the server. */
236
278
  export function reviewExitCodeFromJsonFindings(findings) {
237
- return findings.some((f) => f.blocking || isBlockingFinding(f.title, f.severity))
238
- ? 1
239
- : 0;
279
+ return findings.some((f) => f.blocking) ? 1 : 0;
240
280
  }
241
281
  export function buildPrReviewJson(input) {
282
+ const mode = input.reviewBlocking ?? DEFAULT_REVIEW_BLOCKING;
242
283
  const findings = resolvedFromFirstReview(parseFindings(input.markdown), new Map());
243
284
  const sorted = sortResolvedFindings(findings);
244
285
  return JSON.stringify({
@@ -251,14 +292,15 @@ export function buildPrReviewJson(input) {
251
292
  state: input.codegraphState,
252
293
  reason: input.codegraphReason,
253
294
  },
254
- summary: summaryCounts(findings),
255
- findings: sorted.map(toJsonFinding),
295
+ summary: summaryCounts(findings, mode),
296
+ findings: sorted.map((row) => toJsonFinding(row, mode)),
256
297
  warnings: [],
257
298
  usage: input.usage,
258
299
  durationMs: input.durationMs,
259
300
  });
260
301
  }
261
302
  export function buildLocalReviewJson(input) {
303
+ const mode = input.reviewBlocking ?? DEFAULT_REVIEW_BLOCKING;
262
304
  const sorted = sortResolvedFindings(input.findings);
263
305
  return JSON.stringify({
264
306
  schemaVersion: 1,
@@ -272,8 +314,8 @@ export function buildLocalReviewJson(input) {
272
314
  state: input.codegraphState,
273
315
  reason: input.codegraphReason,
274
316
  },
275
- summary: summaryCounts(input.findings),
276
- findings: sorted.map(toJsonFinding),
317
+ summary: summaryCounts(input.findings, mode),
318
+ findings: sorted.map((row) => toJsonFinding(row, mode)),
277
319
  warnings: input.warnings,
278
320
  usage: input.usage,
279
321
  durationMs: input.durationMs,
@@ -37,6 +37,9 @@ export type UserConfig = {
37
37
  * written by `set`. `githubWebhookSecret` is optional. */
38
38
  githubAppId?: string;
39
39
  githubAppPrivateKey?: string;
40
+ /** Path to the App private key file, kept as a path so the key is never
41
+ * copied into config.json. An inline `githubAppPrivateKey` wins over it. */
42
+ githubAppPrivateKeyPath?: string;
40
43
  githubWebhookSecret?: string;
41
44
  webhookUrl?: string;
42
45
  /** "Sign in with GitHub" for the dashboard, reusing the App's own OAuth
@@ -58,6 +61,9 @@ export type UserConfig = {
58
61
  };
59
62
  /** Cap on jobs running at once across all types. Unset means no limit. */
60
63
  maxConcurrentJobs?: number;
64
+ /** What a review uses to decide a blocking finding (CORE-41). `model` lets
65
+ * the model's own severity decide, `severity` uses a fixed threshold. */
66
+ reviewBlocking?: "model" | "severity";
61
67
  /** Remote review CLI target (`co-maintainer set`). */
62
68
  remoteHost?: string;
63
69
  /** Bearer token for `/api/remote/*`; only written by `set`. */
@@ -101,6 +107,15 @@ export declare function worktreeDir(repo: string, pr: number): string;
101
107
  export declare function cacheDbPath(): string;
102
108
  export declare function loadEnvFile(path: string): void;
103
109
  export declare function readConfig(): UserConfig;
110
+ /** The App private key to sign with. An inline `githubAppPrivateKey` wins,
111
+ * then the file named by `githubAppPrivateKeyPath`. Reading the file here
112
+ * instead of copying it into config.json keeps a key on disk in one place.
113
+ * `undefined` when neither is set or the named file cannot be read, which is
114
+ * the same as "App not configured" to every caller. */
115
+ export declare function resolveAppPrivateKey(config: Pick<UserConfig, "githubAppPrivateKey" | "githubAppPrivateKeyPath">): string | undefined;
116
+ /** `true` when a path is configured but the file is missing or unreadable,
117
+ * so `serve` can warn at startup instead of looking silently unconfigured. */
118
+ export declare function appPrivateKeyFileMissing(config: Pick<UserConfig, "githubAppPrivateKey" | "githubAppPrivateKeyPath">): boolean;
104
119
  /** Merges `patch` into `repos[repo]` in config.json, dropping undefined
105
120
  * fields. Never call this with a token or other secret. */
106
121
  export declare function writeRepoConfig(repo: string, patch: RepoConfig): Promise<void>;
@@ -100,6 +100,37 @@ export function readConfig() {
100
100
  throw new Error(`Could not read config ${configPath()}: ${String(error)}`);
101
101
  }
102
102
  }
103
+ /** The App private key to sign with. An inline `githubAppPrivateKey` wins,
104
+ * then the file named by `githubAppPrivateKeyPath`. Reading the file here
105
+ * instead of copying it into config.json keeps a key on disk in one place.
106
+ * `undefined` when neither is set or the named file cannot be read, which is
107
+ * the same as "App not configured" to every caller. */
108
+ export function resolveAppPrivateKey(config) {
109
+ if (config.githubAppPrivateKey)
110
+ return config.githubAppPrivateKey;
111
+ if (!config.githubAppPrivateKeyPath)
112
+ return undefined;
113
+ try {
114
+ return readTextFileSync(config.githubAppPrivateKeyPath);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ /** `true` when a path is configured but the file is missing or unreadable,
121
+ * so `serve` can warn at startup instead of looking silently unconfigured. */
122
+ export function appPrivateKeyFileMissing(config) {
123
+ if (config.githubAppPrivateKey || !config.githubAppPrivateKeyPath) {
124
+ return false;
125
+ }
126
+ try {
127
+ readTextFileSync(config.githubAppPrivateKeyPath);
128
+ return false;
129
+ }
130
+ catch {
131
+ return true;
132
+ }
133
+ }
103
134
  async function writeConfig(config) {
104
135
  await mkdir(`${getConfigDir()}/co-maintainer`, { recursive: true });
105
136
  const path = configPath();
@@ -40,4 +40,17 @@ export type InstallationRepos = {
40
40
  /** For `GET /api/installations`: every installation the App has, each with
41
41
  * the repos it can see. */
42
42
  export declare function listInstallationsWithRepos(appId: string, privateKeyPem: string): Promise<InstallationRepos[]>;
43
+ export type OpenPull = {
44
+ number: number;
45
+ title: string;
46
+ draft: boolean;
47
+ author: string;
48
+ headRef: string;
49
+ updatedAt: string;
50
+ };
51
+ /** Open, non-draft pull requests for one repository through the installation
52
+ * that can see it, for the Pull requests tab. `undefined` when no installation
53
+ * covers the repo, so the caller can tell "not installed" from "none open"
54
+ * instead of showing an empty list for a setup mistake. */
55
+ export declare function listOpenPulls(appId: string, privateKeyPem: string, fullName: string): Promise<OpenPull[] | undefined>;
43
56
  export declare function findInstallationForRepo(appId: string, privateKeyPem: string, fullName: string): Promise<number | undefined>;
@@ -112,6 +112,29 @@ export async function listInstallationsWithRepos(appId, privateKeyPem) {
112
112
  }
113
113
  return result;
114
114
  }
115
+ /** Open, non-draft pull requests for one repository through the installation
116
+ * that can see it, for the Pull requests tab. `undefined` when no installation
117
+ * covers the repo, so the caller can tell "not installed" from "none open"
118
+ * instead of showing an empty list for a setup mistake. */
119
+ export async function listOpenPulls(appId, privateKeyPem, fullName) {
120
+ const installationId = await findInstallationForRepo(appId, privateKeyPem, fullName);
121
+ if (installationId === undefined)
122
+ return undefined;
123
+ const client = new AppClient(appId, privateKeyPem, installationId);
124
+ const [owner, repo] = fullName.split("/");
125
+ const raw = await client.pages(`repos/${owner}/${repo}/pulls?state=open&sort=updated&direction=desc`, 100);
126
+ return raw
127
+ .filter((pull) => pull.draft !== true)
128
+ .map((pull) => ({
129
+ number: Number(pull.number ?? 0),
130
+ title: String(pull.title ?? ""),
131
+ draft: false,
132
+ author: String(pull.user?.login ?? ""),
133
+ headRef: String(pull.head?.ref ?? ""),
134
+ updatedAt: String(pull.updated_at ?? ""),
135
+ }))
136
+ .filter((pull) => Number.isSafeInteger(pull.number) && pull.number > 0);
137
+ }
115
138
  export async function findInstallationForRepo(appId, privateKeyPem, fullName) {
116
139
  const normalized = fullName.toLowerCase();
117
140
  const installations = await listInstallationsWithRepos(appId, privateKeyPem);
@@ -0,0 +1,50 @@
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
+ };
8
+ export declare const APP_MANIFEST_EVENTS: readonly ["pull_request", "pull_request_review", "pull_request_review_comment", "issue_comment"];
9
+ /** GitHub App names are globally unique and capped at 34 characters. */
10
+ export declare const APP_NAME_MAX = 34;
11
+ export type AppManifest = {
12
+ name: string;
13
+ url: string;
14
+ hook_attributes: {
15
+ url: string;
16
+ active: boolean;
17
+ };
18
+ redirect_url: string;
19
+ callback_urls: string[];
20
+ public: boolean;
21
+ default_permissions: Record<string, string>;
22
+ default_events: string[];
23
+ };
24
+ export declare function buildAppManifest(input: {
25
+ name: string;
26
+ baseUrl: string;
27
+ webhookUrl: string;
28
+ }): AppManifest;
29
+ /** `co-maintainer-<host>`, shortened to fit the 34 character cap. The name
30
+ * is only a suggestion: GitHub rejects a duplicate and the field stays
31
+ * editable. */
32
+ export declare function defaultAppName(host: string): string;
33
+ export declare function appNameProblem(name: string): string | undefined;
34
+ /** Why the App cannot be created yet, or `undefined` when its webhook
35
+ * address looks reachable from GitHub. */
36
+ export declare function manifestBlockedReason(webhookUrl: string): string | undefined;
37
+ export type ManifestConversion = {
38
+ appId: string;
39
+ slug: string;
40
+ pem: string;
41
+ webhookSecret: string;
42
+ clientId: string;
43
+ clientSecret: string;
44
+ };
45
+ /** `POST /app-manifests/{code}/conversions` — the `code` is single use and
46
+ * expires roughly an hour after GitHub redirects the browser back. */
47
+ export declare function convertManifest(code: string): Promise<ManifestConversion>;
48
+ export declare function beginManifestState(now?: number): string;
49
+ /** `false` for an unknown state, a replayed one, or an expired one. */
50
+ export declare function consumeManifestState(state: string, now?: number): boolean;
@@ -0,0 +1,138 @@
1
+ /** The GitHub App manifest flow: create the App from the dashboard so its
2
+ * webhook secret, private key, and OAuth credentials are written straight
3
+ * into config.json instead of being copied by hand between two screens.
4
+ *
5
+ * Permissions and events are derived from the calls this codebase actually
6
+ * makes, not guessed:
7
+ * - `contents: read` read PR_REVIEW_GUIDE.md, file contents, git trees
8
+ * - `pull_requests: write` post reviews and review comments, read a PR
9
+ * - `issues: write` post issue comments, read them
10
+ * - `checks: write` create and update the review check run
11
+ * - `metadata: read` required by every App
12
+ * - events handled by `dispatchGithubEvent` in services/webhook.ts
13
+ *
14
+ * `installation` and `installation_repositories` are deliberately absent:
15
+ * GitHub delivers both to every App without a subscription, which is why
16
+ * `webhook.ts` handles them at all. */
17
+ import { githubFetch } from "./client.js";
18
+ 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
+ };
26
+ export const APP_MANIFEST_EVENTS = [
27
+ "pull_request",
28
+ "pull_request_review",
29
+ "pull_request_review_comment",
30
+ "issue_comment",
31
+ ];
32
+ /** GitHub App names are globally unique and capped at 34 characters. */
33
+ export const APP_NAME_MAX = 34;
34
+ export function buildAppManifest(input) {
35
+ const base = input.baseUrl.replace(/\/+$/, "");
36
+ return {
37
+ name: input.name.trim(),
38
+ url: base,
39
+ hook_attributes: { url: input.webhookUrl, active: true },
40
+ redirect_url: `${base}/github/app-manifest/callback`,
41
+ callback_urls: [`${base}/auth/github/callback`],
42
+ public: false,
43
+ default_permissions: { ...APP_MANIFEST_PERMISSIONS },
44
+ default_events: [...APP_MANIFEST_EVENTS],
45
+ };
46
+ }
47
+ /** `co-maintainer-<host>`, shortened to fit the 34 character cap. The name
48
+ * is only a suggestion: GitHub rejects a duplicate and the field stays
49
+ * editable. */
50
+ export function defaultAppName(host) {
51
+ const clean = host
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9-]+/g, "-")
54
+ .replace(/^-+|-+$/g, "");
55
+ const name = `co-maintainer-${clean}`;
56
+ return name.length <= APP_NAME_MAX
57
+ ? name
58
+ : name.slice(0, APP_NAME_MAX).replace(/-+$/, "");
59
+ }
60
+ export function appNameProblem(name) {
61
+ const trimmed = name.trim();
62
+ if (!trimmed)
63
+ return "Enter a name for the GitHub App.";
64
+ if (trimmed.length > APP_NAME_MAX) {
65
+ return `The name must be ${APP_NAME_MAX} characters or fewer.`;
66
+ }
67
+ if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(trimmed)) {
68
+ return "Use letters, digits, and hyphens only, starting with a letter or digit.";
69
+ }
70
+ return undefined;
71
+ }
72
+ /** Why the App cannot be created yet, or `undefined` when its webhook
73
+ * address looks reachable from GitHub. */
74
+ export function manifestBlockedReason(webhookUrl) {
75
+ if (!webhookUrl.trim()) {
76
+ return "Set a webhook address before creating the App.";
77
+ }
78
+ const reach = webhookReachabilityProblem(webhookUrl);
79
+ if (reach)
80
+ return `${reach} Set a public webhook address first.`;
81
+ return undefined;
82
+ }
83
+ /** `POST /app-manifests/{code}/conversions` — the `code` is single use and
84
+ * expires roughly an hour after GitHub redirects the browser back. */
85
+ export async function convertManifest(code) {
86
+ const response = await githubFetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
87
+ method: "POST",
88
+ headers: {
89
+ accept: "application/vnd.github+json",
90
+ "content-type": "application/json",
91
+ },
92
+ });
93
+ if (!response.ok) {
94
+ throw new Error(`GitHub App conversion failed (${response.status}).`);
95
+ }
96
+ const body = (await response.json());
97
+ const text = (value) => (typeof value === "string" ? value : "");
98
+ const id = body.id;
99
+ const appId = typeof id === "number" ? String(id) : typeof id === "string" ? id : "";
100
+ if (!appId || !text(body.pem)) {
101
+ throw new Error("GitHub returned an incomplete App conversion.");
102
+ }
103
+ return {
104
+ appId,
105
+ slug: text(body.slug),
106
+ pem: text(body.pem),
107
+ webhookSecret: text(body.webhook_secret),
108
+ clientId: text(body.client_id),
109
+ clientSecret: text(body.client_secret),
110
+ };
111
+ }
112
+ /** One dashboard admin, one in-flight App creation at a time, so a
113
+ * server-side map is enough. A cookie would work too, but keeping the token
114
+ * out of the browser is one less thing to forge. Expired entries are swept
115
+ * on the next start, and a state is single use. */
116
+ const MANIFEST_STATE_TTL_MS = 10 * 60 * 1000;
117
+ const pendingStates = new Map();
118
+ function randomState() {
119
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
120
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
121
+ }
122
+ export function beginManifestState(now = Date.now()) {
123
+ for (const [state, expires] of pendingStates) {
124
+ if (expires <= now)
125
+ pendingStates.delete(state);
126
+ }
127
+ const state = randomState();
128
+ pendingStates.set(state, now + MANIFEST_STATE_TTL_MS);
129
+ return state;
130
+ }
131
+ /** `false` for an unknown state, a replayed one, or an expired one. */
132
+ export function consumeManifestState(state, now = Date.now()) {
133
+ const expires = pendingStates.get(state);
134
+ if (expires === undefined)
135
+ return false;
136
+ pendingStates.delete(state);
137
+ return expires > now;
138
+ }