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.
- package/LICENSE +21 -21
- package/README.md +57 -38
- package/dist/main.js +2 -3
- package/dist/package.json +21 -15
- package/dist/src/ai/batch.d.ts +24 -1
- package/dist/src/ai/batch.js +75 -23
- package/dist/src/ai/estimate.d.ts +40 -0
- package/dist/src/ai/estimate.js +113 -0
- package/dist/src/ai/fake.d.ts +1 -1
- package/dist/src/ai/fake.js +1 -1
- package/dist/src/ai/hetzner.js +1 -1
- package/dist/src/ai/openrouter.d.ts +13 -0
- package/dist/src/ai/openrouter.js +66 -12
- package/dist/src/ai/pricing.d.ts +14 -0
- package/dist/src/ai/pricing.js +77 -0
- package/dist/src/ai/provider.js +8 -0
- package/dist/src/ai/verify.d.ts +25 -0
- package/dist/src/ai/verify.js +78 -0
- package/dist/src/cli/args.js +25 -29
- package/dist/src/cli/commands/config.d.ts +44 -0
- package/dist/src/cli/commands/config.js +220 -0
- package/dist/src/cli/commands/probe.js +70 -103
- package/dist/src/cli/commands/registry.d.ts +57 -0
- package/dist/src/cli/commands/registry.js +713 -0
- package/dist/src/cli/commands/review.js +66 -43
- package/dist/src/cli/commands/serve.d.ts +4 -2
- package/dist/src/cli/commands/serve.js +14 -10
- package/dist/src/cli/commands/set.d.ts +2 -1
- package/dist/src/cli/commands/set.js +63 -17
- package/dist/src/cli/commands/view.d.ts +1 -0
- package/dist/src/cli/commands/view.js +180 -0
- package/dist/src/cli/error.d.ts +39 -0
- package/dist/src/cli/error.js +64 -0
- package/dist/src/cli/main.d.ts +7 -0
- package/dist/src/cli/main.js +82 -1
- package/dist/src/cli/prompt.js +21 -7
- package/dist/src/cli/review_args.d.ts +4 -0
- package/dist/src/cli/review_args.js +22 -9
- package/dist/src/cli/review_output.d.ts +2 -2
- package/dist/src/cli/review_output.js +7 -6
- package/dist/src/cli/review_result.d.ts +59 -8
- package/dist/src/cli/review_result.js +145 -103
- package/dist/src/config.d.ts +15 -0
- package/dist/src/config.js +31 -0
- package/dist/src/github/app.d.ts +13 -0
- package/dist/src/github/app.js +23 -0
- package/dist/src/github/app_manifest.d.ts +50 -0
- package/dist/src/github/app_manifest.js +138 -0
- package/dist/src/github/client.js +7 -1
- package/dist/src/github/collect.js +1 -1
- package/dist/src/github/gh.js +75 -18
- package/dist/src/knowledge/facts.js +8 -3
- package/dist/src/knowledge/guide.d.ts +8 -0
- package/dist/src/knowledge/guide.js +32 -8
- package/dist/src/knowledge/probe.js +13 -3
- package/dist/src/knowledge/sections.d.ts +9 -0
- package/dist/src/knowledge/sections.js +16 -0
- package/dist/src/knowledge/skill.d.ts +5 -0
- package/dist/src/knowledge/skill.js +68 -21
- package/dist/src/knowledge/synthesis.d.ts +17 -1
- package/dist/src/knowledge/synthesis.js +70 -15
- package/dist/src/knowledge/types.d.ts +5 -0
- package/dist/src/local/codegraph_prepare.d.ts +3 -2
- package/dist/src/local/codegraph_prepare.js +4 -2
- package/dist/src/local/git_ops.d.ts +5 -4
- package/dist/src/local/git_ops.js +8 -9
- package/dist/src/local/review_local.js +36 -14
- package/dist/src/pr/checkout.js +14 -3
- package/dist/src/pr/codegraph_tools.js +3 -3
- package/dist/src/pr/diff_summary.js +3 -3
- package/dist/src/pr/findings.js +16 -5
- package/dist/src/pr/findings_json.d.ts +26 -0
- package/dist/src/pr/findings_json.js +187 -0
- package/dist/src/pr/review_copy.d.ts +20 -0
- package/dist/src/pr/review_copy.js +40 -0
- package/dist/src/pr/reviewer.d.ts +2 -1
- package/dist/src/pr/reviewer.js +84 -91
- package/dist/src/remote/client.js +42 -42
- package/dist/src/remote/http.d.ts +5 -0
- package/dist/src/remote/http.js +49 -0
- package/dist/src/remote/server/guides.d.ts +22 -0
- package/dist/src/remote/server/guides.js +81 -0
- package/dist/src/remote/server/routes.js +24 -0
- package/dist/src/review/blocking.d.ts +35 -0
- package/dist/src/review/blocking.js +45 -0
- package/dist/src/review/carry_over.d.ts +12 -1
- package/dist/src/review/carry_over.js +35 -9
- package/dist/src/review/engine.d.ts +1 -0
- package/dist/src/review/guides.js +28 -1
- package/dist/src/server/api/installations.js +1 -1
- package/dist/src/server/api/repos.js +86 -3
- package/dist/src/server/api/settings.js +3 -3
- package/dist/src/server/app.d.ts +3 -0
- package/dist/src/server/app.js +12 -5
- package/dist/src/server/pages/activity.d.ts +1 -0
- package/dist/src/server/pages/activity.js +2 -2
- package/dist/src/server/pages/add_repo.js +73 -5
- package/dist/src/server/pages/client.d.ts +1 -1
- package/dist/src/server/pages/client.js +1 -1
- package/dist/src/server/pages/home.d.ts +6 -1
- package/dist/src/server/pages/home.js +25 -2
- package/dist/src/server/pages/knowledge.js +1 -1
- package/dist/src/server/pages/layout.js +2 -2
- package/dist/src/server/pages/repo.d.ts +1 -1
- package/dist/src/server/pages/repo.js +21 -2
- package/dist/src/server/pages/repo_prs.d.ts +5 -1
- package/dist/src/server/pages/repo_prs.js +36 -1
- package/dist/src/server/pages/repo_remote.js +2 -2
- package/dist/src/server/pages/repo_settings.js +2 -2
- package/dist/src/server/pages/router.d.ts +6 -0
- package/dist/src/server/pages/router.js +129 -9
- package/dist/src/server/pages/settings.d.ts +1 -1
- package/dist/src/server/pages/settings.js +87 -9
- package/dist/src/server/pages/styles.d.ts +1 -1
- package/dist/src/server/pages/styles.js +1 -1
- package/dist/src/services/probe.d.ts +28 -0
- package/dist/src/services/probe.js +133 -0
- package/dist/src/services/remake_cron.d.ts +1 -1
- package/dist/src/services/remake_cron.js +3 -3
- package/dist/src/services/remote_review.js +14 -4
- package/dist/src/services/review.d.ts +11 -0
- package/dist/src/services/review.js +39 -11
- package/dist/src/services/setup.js +37 -19
- package/dist/src/services/setup_checklist.d.ts +10 -0
- package/dist/src/services/setup_checklist.js +87 -0
- package/dist/src/store/app_db.js +5 -2
- package/dist/src/store/cache_db.d.ts +4 -0
- package/dist/src/store/cache_db.js +27 -4
- package/dist/src/store/deliveries.d.ts +7 -0
- package/dist/src/store/deliveries.js +15 -0
- package/dist/src/tools/codegraph.d.ts +30 -0
- package/dist/src/tools/codegraph.js +49 -12
- package/dist/src/types.d.ts +10 -0
- package/dist/src/util/log.d.ts +3 -0
- package/dist/src/util/log.js +12 -0
- package/dist/src/util/run_summary.d.ts +33 -0
- package/dist/src/util/run_summary.js +47 -0
- package/dist/src/util/webhook_reachability.d.ts +11 -0
- package/dist/src/util/webhook_reachability.js +64 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
130
|
-
const
|
|
131
|
-
return
|
|
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
|
-
/**
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
: "
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 =
|
|
164
|
-
const
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 =
|
|
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
|
|
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
|
-
/**
|
|
200
|
-
export function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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,
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/config.js
CHANGED
|
@@ -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();
|
package/dist/src/github/app.d.ts
CHANGED
|
@@ -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>;
|
package/dist/src/github/app.js
CHANGED
|
@@ -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
|
+
}
|