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
|
@@ -6,9 +6,12 @@ import { GhClient } from "../../github/gh.js";
|
|
|
6
6
|
import { log, startHeartbeat, timed, withCliLogsToStderr, } from "../../util/log.js";
|
|
7
7
|
import { runLocalReview } from "../../local/review_local.js";
|
|
8
8
|
import { runRemoteReview } from "../../remote/client.js";
|
|
9
|
-
import {
|
|
9
|
+
import { reviewExitCode } from "../review_output.js";
|
|
10
10
|
import { buildPrReviewJson, resolvedFromFirstReview, reviewExitCodeFromResolved, } from "../review_result.js";
|
|
11
11
|
import { parseFindings } from "../../pr/findings.js";
|
|
12
|
+
import { exitWith } from "../error.js";
|
|
13
|
+
import { printRunSummary, summaryFromMetrics } from "../../util/run_summary.js";
|
|
14
|
+
import { formatHumanReview, humanFindingsFromResolved, } from "../review_result.js";
|
|
12
15
|
export async function runReviewFromCli(args) {
|
|
13
16
|
const parsed = await parseReviewArgs(args);
|
|
14
17
|
if (parsed.mode === "local") {
|
|
@@ -35,50 +38,70 @@ async function runReviewPr(options, cli) {
|
|
|
35
38
|
const operationStarted = performance.now();
|
|
36
39
|
const aiMetrics = emptyAiMetrics();
|
|
37
40
|
const stopHeartbeat = startHeartbeat("reviewing pull request");
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const durationMs = Math.round(performance.now() - operationStarted);
|
|
53
|
-
const usage = {
|
|
54
|
-
tokensIn: aiMetrics.tokensIn,
|
|
55
|
-
tokensOut: aiMetrics.tokensOut,
|
|
56
|
-
costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
|
|
57
|
-
};
|
|
58
|
-
const codegraphState = options.useCodegraph ? "used" : "disabled";
|
|
59
|
-
const findings = resolvedFromFirstReview(parseFindings(result.text), new Map());
|
|
60
|
-
if (cli.json) {
|
|
61
|
-
console.log(buildPrReviewJson({
|
|
62
|
-
repo: options.repo,
|
|
63
|
-
prNumber: options.prNumber,
|
|
64
|
-
markdown: result.text,
|
|
65
|
-
guideBuiltAt: result.guideBuiltAt,
|
|
66
|
-
codegraphState,
|
|
67
|
-
codegraphReason: null,
|
|
68
|
-
usage,
|
|
69
|
-
durationMs,
|
|
41
|
+
try {
|
|
42
|
+
if (options.auth !== "gh") {
|
|
43
|
+
throw new Error("review supports gh authentication only");
|
|
44
|
+
}
|
|
45
|
+
log("review", `reading PR #${options.prNumber} in ${options.repo} via gh`);
|
|
46
|
+
const result = await timed("review GitHub collection and AI", options.logTime, () => reviewPullRequest(new GhClient(options.debug), options, async (response) => {
|
|
47
|
+
aiMetrics.calls++;
|
|
48
|
+
aiMetrics.tokensIn += response.tokensIn;
|
|
49
|
+
aiMetrics.tokensOut += response.tokensOut;
|
|
50
|
+
if (response.cost === undefined)
|
|
51
|
+
aiMetrics.costKnown = false;
|
|
52
|
+
else
|
|
53
|
+
aiMetrics.cost += response.cost;
|
|
54
|
+
await recordAiCost(options.repo, "review_pull_request", response);
|
|
70
55
|
}));
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
const durationMs = Math.round(performance.now() - operationStarted);
|
|
57
|
+
const usage = {
|
|
58
|
+
tokensIn: aiMetrics.tokensIn,
|
|
59
|
+
tokensOut: aiMetrics.tokensOut,
|
|
60
|
+
costUsd: aiMetrics.costKnown ? aiMetrics.cost : null,
|
|
61
|
+
};
|
|
62
|
+
// The engine reports the codegraph state it actually reached, instead of
|
|
63
|
+
// the old `useCodegraph ? "used" : "disabled"` that claimed "used" while
|
|
64
|
+
// stderr said codegraph was missing (CORE-43 / F23).
|
|
65
|
+
const codegraphState = result.codegraphState;
|
|
66
|
+
const resolved = resolvedFromFirstReview(parseFindings(result.text), new Map());
|
|
67
|
+
if (cli.json) {
|
|
68
|
+
console.log(buildPrReviewJson({
|
|
69
|
+
repo: options.repo,
|
|
70
|
+
prNumber: options.prNumber,
|
|
71
|
+
markdown: result.text,
|
|
72
|
+
guideBuiltAt: result.guideBuiltAt,
|
|
73
|
+
codegraphState,
|
|
74
|
+
codegraphReason: null,
|
|
75
|
+
usage,
|
|
76
|
+
durationMs,
|
|
77
|
+
reviewBlocking: options.reviewBlocking,
|
|
78
|
+
}));
|
|
77
79
|
}
|
|
80
|
+
else {
|
|
81
|
+
// One presentation layer for local, PR and remote reviews (CORE-43).
|
|
82
|
+
console.log("\n" +
|
|
83
|
+
formatHumanReview({
|
|
84
|
+
title: `co-maintainer review · ${options.repo} · PR #${options.prNumber}`,
|
|
85
|
+
guideBuiltAt: result.guideBuiltAt,
|
|
86
|
+
codegraphState,
|
|
87
|
+
findings: humanFindingsFromResolved(resolved, options.reviewBlocking),
|
|
88
|
+
}) +
|
|
89
|
+
"\n");
|
|
90
|
+
printRunSummary(summaryFromMetrics(aiMetrics, performance.now() - operationStarted));
|
|
91
|
+
if (options.logTime) {
|
|
92
|
+
log("time", `AI total · calls=${aiMetrics.calls} · input=${aiMetrics.tokensIn} tokens · output=${aiMetrics.tokensOut} tokens · cost=${aiMetrics.costKnown ? aiMetrics.cost.toFixed(4) : "unknown"}`);
|
|
93
|
+
console.error(`[time] total review · ${((performance.now() - operationStarted) / 1000).toFixed(2)}s`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exitWith(cli.json
|
|
97
|
+
? reviewExitCodeFromResolved(resolved, options.reviewBlocking)
|
|
98
|
+
: reviewExitCode(result.text, options.reviewBlocking));
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
// A heartbeat left running on the error path keeps the event loop alive
|
|
102
|
+
// forever, so the process would never drain and the exit code would never
|
|
103
|
+
// be applied. CORE-11.
|
|
104
|
+
stopHeartbeat();
|
|
78
105
|
}
|
|
79
|
-
stopHeartbeat();
|
|
80
|
-
process.exit(cli.json
|
|
81
|
-
? reviewExitCodeFromResolved(findings)
|
|
82
|
-
: reviewExitCode(result.text));
|
|
83
106
|
});
|
|
84
107
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { UserConfig } from "../../config.ts";
|
|
2
2
|
import type { AuthMethods, PasswordStore } from "../../server/auth.ts";
|
|
3
3
|
import { type Platform } from "../../util/runtime.ts";
|
|
4
|
-
/** `undefined` on Linux, otherwise one line naming the platform
|
|
5
|
-
*
|
|
4
|
+
/** `undefined` on Linux, otherwise one line naming the platform. The
|
|
5
|
+
* recommendation is spelled out rather than pointing at an internal
|
|
6
|
+
* document, so the message stands on its own and links to the public
|
|
7
|
+
* troubleshooting page for the details. */
|
|
6
8
|
export declare function platformWarning(os: Platform): string | undefined;
|
|
7
9
|
export declare function resolveTrustProxy(args: string[], env?: (name: string) => string | undefined): boolean;
|
|
8
10
|
/** `--password=` replaces the stored password. With no flag the stored one is
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readConfig } from "../../config.js";
|
|
1
|
+
import { readConfig, resolveAppPrivateKey, appPrivateKeyFileMissing, } from "../../config.js";
|
|
2
2
|
import { appDbPath, closeAppDb, openAppDb } from "../../store/app_db.js";
|
|
3
3
|
import { createApp } from "../../server/app.js";
|
|
4
4
|
import { configPasswordStore } from "../../server/auth.js";
|
|
@@ -12,15 +12,15 @@ import { startRemoteWatchdog } from "../../remote/server/sessions.js";
|
|
|
12
12
|
import { passwordProblem } from "../../util/password.js";
|
|
13
13
|
import { serveHttp } from "../../server/http.js";
|
|
14
14
|
import { currentPlatform, getEnv } from "../../util/runtime.js";
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
import { die } from "../error.js";
|
|
16
|
+
/** `undefined` on Linux, otherwise one line naming the platform. The
|
|
17
|
+
* recommendation is spelled out rather than pointing at an internal
|
|
18
|
+
* document, so the message stands on its own and links to the public
|
|
19
|
+
* troubleshooting page for the details. */
|
|
17
20
|
export function platformWarning(os) {
|
|
18
21
|
if (os === "linux")
|
|
19
22
|
return undefined;
|
|
20
|
-
return `running on ${os}. Linux (WSL included) is the recommended platform for serve
|
|
21
|
-
}
|
|
22
|
-
function die(message) {
|
|
23
|
-
throw new Error(message);
|
|
23
|
+
return `running on ${os}. Linux (WSL included) is the recommended platform for serve. See https://co-maintainer.com/docs/troubleshooting.html`;
|
|
24
24
|
}
|
|
25
25
|
function generatePassword() {
|
|
26
26
|
const bytes = new Uint8Array(12);
|
|
@@ -68,7 +68,7 @@ export function resolveAuthMethods(args, config) {
|
|
|
68
68
|
const password = disableAuth ? false : !config.passwordAuthDisabled;
|
|
69
69
|
const github = enableAuth ? true : Boolean(config.githubAuthEnabled);
|
|
70
70
|
if (!password && !github) {
|
|
71
|
-
die("at least one sign-in method is required
|
|
71
|
+
die("at least one sign-in method is required. Drop --disable-auth=password or pass --enable-auth=github");
|
|
72
72
|
}
|
|
73
73
|
return { password, github };
|
|
74
74
|
}
|
|
@@ -124,16 +124,19 @@ export async function runServe(args) {
|
|
|
124
124
|
}
|
|
125
125
|
const config = readConfig();
|
|
126
126
|
const webhookUrl = resolveWebhookUrl(args, port, config.webhookUrl);
|
|
127
|
-
if (!config.githubAppId || !config
|
|
127
|
+
if (!config.githubAppId || !resolveAppPrivateKey(config)) {
|
|
128
128
|
console.log("[serve] GitHub App is not configured yet. Add it in the dashboard settings or with co-maintainer set.");
|
|
129
129
|
}
|
|
130
|
+
if (appPrivateKeyFileMissing(config)) {
|
|
131
|
+
console.log(`[serve] warning: the GitHub App private key file listed in config.json cannot be read (${config.githubAppPrivateKeyPath}). The App will not work until it is restored or replaced.`);
|
|
132
|
+
}
|
|
130
133
|
const auth = resolveAuthMethods(args, config);
|
|
131
134
|
let githubOAuth;
|
|
132
135
|
if (auth.github) {
|
|
133
136
|
if (!config.githubOAuthClientId ||
|
|
134
137
|
!config.githubOAuthClientSecret ||
|
|
135
138
|
!config.githubOAuthAllowedUser) {
|
|
136
|
-
die("GitHub sign-in requires OAuth credentials
|
|
139
|
+
die("GitHub sign-in requires OAuth credentials. Run:\n" +
|
|
137
140
|
" co-maintainer set --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
|
|
138
141
|
}
|
|
139
142
|
githubOAuth = {
|
|
@@ -187,6 +190,7 @@ export async function runServe(args) {
|
|
|
187
190
|
trustProxy,
|
|
188
191
|
auth,
|
|
189
192
|
githubOAuth,
|
|
193
|
+
loginHint: getEnv("CM_LOGIN_HINT"),
|
|
190
194
|
});
|
|
191
195
|
const server = serveHttp((req, remoteAddr) => app.fetch(req, remoteAddr), port);
|
|
192
196
|
console.log(`[serve] listening on http://localhost:${port}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
|
|
2
2
|
* --auth=... --github-app-id=... --github-app-private-key=... (or
|
|
3
|
-
* --github-app-private-key-file=path
|
|
3
|
+
* --github-app-private-key-file=path or --github-app-private-key-path=path)
|
|
4
|
+
* --github-webhook-secret=...` —
|
|
4
5
|
* persists global defaults, including secrets, to config.json so every
|
|
5
6
|
* other command can skip both the flag and the interactive prompt. See
|
|
6
7
|
* docs/md/configuration.md for the tradeoff. */
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { writeUserConfig } from "../../config.js";
|
|
1
|
+
import { readConfig, configPath, writeUserConfig } from "../../config.js";
|
|
2
2
|
import { hashPassword, passwordProblem } from "../../util/password.js";
|
|
3
3
|
import { readTextFile } from "../../util/runtime.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
4
|
+
import { verifyOpenRouter } from "../../ai/verify.js";
|
|
5
|
+
import { die } from "../error.js";
|
|
6
|
+
import { renderCommandHelp, renderGlobalHelp } from "./registry.js";
|
|
7
7
|
function text(args, name) {
|
|
8
8
|
return args
|
|
9
9
|
.find((arg) => arg.startsWith(`--${name}=`))
|
|
@@ -20,22 +20,19 @@ const secretFields = new Set([
|
|
|
20
20
|
]);
|
|
21
21
|
/** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
|
|
22
22
|
* --auth=... --github-app-id=... --github-app-private-key=... (or
|
|
23
|
-
* --github-app-private-key-file=path
|
|
23
|
+
* --github-app-private-key-file=path or --github-app-private-key-path=path)
|
|
24
|
+
* --github-webhook-secret=...` —
|
|
24
25
|
* persists global defaults, including secrets, to config.json so every
|
|
25
26
|
* other command can skip both the flag and the interactive prompt. See
|
|
26
27
|
* docs/md/configuration.md for the tradeoff. */
|
|
27
28
|
export async function runSet(args) {
|
|
28
29
|
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
29
|
-
console.log("
|
|
30
|
-
console.log(" --github-app-id=... --github-app-private-key=... | --github-app-private-key-file=path");
|
|
31
|
-
console.log(" --github-webhook-secret=...");
|
|
32
|
-
console.log(" --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
|
|
33
|
-
console.log(" --password=... --disable-auth=password --enable-auth=github");
|
|
34
|
-
console.log("Writes to the user config file; unset an entry with --unset=name (e.g. --unset=token).");
|
|
30
|
+
console.log(renderCommandHelp("set") ?? renderGlobalHelp());
|
|
35
31
|
return;
|
|
36
32
|
}
|
|
37
33
|
const known = [
|
|
38
34
|
"token",
|
|
35
|
+
"ai-key",
|
|
39
36
|
"ai",
|
|
40
37
|
"low-model",
|
|
41
38
|
"high-model",
|
|
@@ -44,6 +41,7 @@ export async function runSet(args) {
|
|
|
44
41
|
"github-app-id",
|
|
45
42
|
"github-app-private-key",
|
|
46
43
|
"github-app-private-key-file",
|
|
44
|
+
"github-app-private-key-path",
|
|
47
45
|
"github-webhook-secret",
|
|
48
46
|
"github-oauth-client-id",
|
|
49
47
|
"github-oauth-client-secret",
|
|
@@ -52,10 +50,14 @@ export async function runSet(args) {
|
|
|
52
50
|
"enable-auth",
|
|
53
51
|
"remote-host",
|
|
54
52
|
"remote-token",
|
|
53
|
+
"review-blocking",
|
|
55
54
|
"password",
|
|
56
55
|
"unset",
|
|
57
56
|
];
|
|
57
|
+
const verify = !args.includes("--no-verify");
|
|
58
58
|
for (const arg of args) {
|
|
59
|
+
if (arg === "--no-verify")
|
|
60
|
+
continue;
|
|
59
61
|
if (!arg.startsWith("--") ||
|
|
60
62
|
!known.some((name) => arg.startsWith(`--${name}=`))) {
|
|
61
63
|
die(`Unknown option: ${arg}`);
|
|
@@ -69,12 +71,17 @@ export async function runSet(args) {
|
|
|
69
71
|
if (auth && !["gh", "pat"].includes(auth)) {
|
|
70
72
|
die("--auth must be one of: gh, pat");
|
|
71
73
|
}
|
|
72
|
-
|
|
73
|
-
text(args, "github-app-private-key
|
|
74
|
-
|
|
74
|
+
const providedKeySources = [
|
|
75
|
+
text(args, "github-app-private-key"),
|
|
76
|
+
text(args, "github-app-private-key-file"),
|
|
77
|
+
text(args, "github-app-private-key-path"),
|
|
78
|
+
].filter((value) => value !== undefined);
|
|
79
|
+
if (providedKeySources.length > 1) {
|
|
80
|
+
die("Pass only one of --github-app-private-key, --github-app-private-key-file, or --github-app-private-key-path");
|
|
75
81
|
}
|
|
76
82
|
const fieldByFlag = {
|
|
77
83
|
token: "token",
|
|
84
|
+
"ai-key": "token",
|
|
78
85
|
ai: "ai",
|
|
79
86
|
"low-model": "lowModel",
|
|
80
87
|
"high-model": "highModel",
|
|
@@ -82,6 +89,7 @@ export async function runSet(args) {
|
|
|
82
89
|
"github-pat": "githubPat",
|
|
83
90
|
"github-app-id": "githubAppId",
|
|
84
91
|
"github-app-private-key": "githubAppPrivateKey",
|
|
92
|
+
"github-app-private-key-path": "githubAppPrivateKeyPath",
|
|
85
93
|
"github-webhook-secret": "githubWebhookSecret",
|
|
86
94
|
"github-oauth-client-id": "githubOAuthClientId",
|
|
87
95
|
"github-oauth-client-secret": "githubOAuthClientSecret",
|
|
@@ -90,6 +98,7 @@ export async function runSet(args) {
|
|
|
90
98
|
"enable-auth": "githubAuthEnabled",
|
|
91
99
|
"remote-host": "remoteHost",
|
|
92
100
|
"remote-token": "remoteToken",
|
|
101
|
+
"review-blocking": "reviewBlocking",
|
|
93
102
|
password: "dashboardPasswordHash",
|
|
94
103
|
};
|
|
95
104
|
const unset = new Set(args
|
|
@@ -110,7 +119,7 @@ export async function runSet(args) {
|
|
|
110
119
|
const highModel = text(args, "high-model");
|
|
111
120
|
if (highModel)
|
|
112
121
|
patch.highModel = highModel;
|
|
113
|
-
const token = text(args, "token");
|
|
122
|
+
const token = text(args, "token") ?? text(args, "ai-key");
|
|
114
123
|
if (token)
|
|
115
124
|
patch.token = token;
|
|
116
125
|
const githubPat = text(args, "github-pat");
|
|
@@ -134,6 +143,17 @@ export async function runSet(args) {
|
|
|
134
143
|
die(`Could not read ${privateKeyFile}: ${String(error)}`);
|
|
135
144
|
}
|
|
136
145
|
}
|
|
146
|
+
// The path form keeps the key on disk: only the location is stored, and it
|
|
147
|
+
// is read at startup. Passing one of the other two forms also clears a
|
|
148
|
+
// previously saved path so the inline key or file wins unambiguously.
|
|
149
|
+
const privateKeyPath = text(args, "github-app-private-key-path");
|
|
150
|
+
if (privateKeyPath) {
|
|
151
|
+
patch.githubAppPrivateKeyPath = privateKeyPath;
|
|
152
|
+
patch.githubAppPrivateKey = undefined;
|
|
153
|
+
}
|
|
154
|
+
else if (githubAppPrivateKey || privateKeyFile) {
|
|
155
|
+
patch.githubAppPrivateKeyPath = undefined;
|
|
156
|
+
}
|
|
137
157
|
const oauthClientId = text(args, "github-oauth-client-id");
|
|
138
158
|
if (oauthClientId)
|
|
139
159
|
patch.githubOAuthClientId = oauthClientId;
|
|
@@ -161,6 +181,13 @@ export async function runSet(args) {
|
|
|
161
181
|
const remoteToken = text(args, "remote-token");
|
|
162
182
|
if (remoteToken)
|
|
163
183
|
patch.remoteToken = remoteToken;
|
|
184
|
+
const reviewBlocking = text(args, "review-blocking");
|
|
185
|
+
if (reviewBlocking) {
|
|
186
|
+
if (!["model", "severity"].includes(reviewBlocking)) {
|
|
187
|
+
die("--review-blocking must be one of: model, severity");
|
|
188
|
+
}
|
|
189
|
+
patch.reviewBlocking = reviewBlocking;
|
|
190
|
+
}
|
|
164
191
|
const password = text(args, "password");
|
|
165
192
|
if (password) {
|
|
166
193
|
const problem = passwordProblem(password);
|
|
@@ -169,14 +196,33 @@ export async function runSet(args) {
|
|
|
169
196
|
patch.dashboardPasswordHash = await hashPassword(password);
|
|
170
197
|
}
|
|
171
198
|
if (Object.keys(patch).length === 0) {
|
|
172
|
-
die("Nothing to set
|
|
173
|
-
"--github-app-id=, --github-app-private-key(-file)=, --github-webhook-secret=, " +
|
|
199
|
+
die("Nothing to set. Pass --token= (or --ai-key=), --ai=, --low-model=, --high-model=, --auth=, --github-pat=, " +
|
|
200
|
+
"--github-app-id=, --github-app-private-key(-file|-path)=, --github-webhook-secret=, " +
|
|
174
201
|
"--github-oauth-client-id=, --github-oauth-client-secret=, --github-oauth-allowed-user=, " +
|
|
202
|
+
"--remote-host=, --remote-token=, --review-blocking=model|severity, " +
|
|
175
203
|
"--password=, --disable-auth=password, --enable-auth=github, or --unset=name");
|
|
176
204
|
}
|
|
205
|
+
// Verify the key and model before writing, so a typo fails here instead of
|
|
206
|
+
// after a review has already fetched and cloned the pull request (CORE-22).
|
|
207
|
+
// A field present in the patch wins, including when its value is `undefined`
|
|
208
|
+
// — that is an unset, and there is nothing left to verify.
|
|
209
|
+
const before = readConfig();
|
|
210
|
+
const effective = (field) => field in patch ? patch[field] : before[field];
|
|
211
|
+
const effectiveAi = effective("ai");
|
|
212
|
+
const effectiveToken = effective("token");
|
|
213
|
+
const effectiveHighModel = effective("highModel");
|
|
214
|
+
if (verify && effectiveAi === "openrouter" && effectiveToken) {
|
|
215
|
+
const result = await verifyOpenRouter(String(effectiveToken), effectiveHighModel === undefined ? undefined : String(effectiveHighModel));
|
|
216
|
+
if (result.status === "rejected")
|
|
217
|
+
throw result.error;
|
|
218
|
+
if (result.status === "unreachable") {
|
|
219
|
+
console.error(`[set] could not verify the key and model (${result.reason}); saved anyway.`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
177
222
|
await writeUserConfig(patch);
|
|
178
223
|
const summary = Object.entries(patch).map(([key, value]) => value === undefined
|
|
179
224
|
? `${key} (unset)`
|
|
180
225
|
: `${key}=${secretFields.has(key) ? "•".repeat(8) : value}`);
|
|
181
226
|
console.log(`[set] updated: ${summary.join(", ")}`);
|
|
227
|
+
console.log(`Saved to ${configPath()}`);
|
|
182
228
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runView(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** `co-maintainer view`: print the guides co-maintainer already generated
|
|
2
|
+
* (CORE-23, F25). Read-only — the counterpart to `config` for the generated
|
|
3
|
+
* knowledge rather than the settings.
|
|
4
|
+
*
|
|
5
|
+
* Two sources, one presentation (CORE-44): the guide directory on this machine,
|
|
6
|
+
* or the servers a remote review token can read (`--remote`). Local and remote
|
|
7
|
+
* differ only in where the guide list comes from, so `--list`, a single guide
|
|
8
|
+
* and the every-guide headers are all rendered by the same code.
|
|
9
|
+
*
|
|
10
|
+
* Naming: the plan calls the guide kinds `skill`, `codebase`,
|
|
11
|
+
* `review-guide`, `detailed-guide`; `--list` and the headers show the real
|
|
12
|
+
* file names, and a kind name is accepted too so a user never has to type
|
|
13
|
+
* `PR_REVIEW_GUIDE.md` unless they want to. */
|
|
14
|
+
import { readConfig, reposDir } from "../../config.js";
|
|
15
|
+
import { detectRemoteRepo } from "../../local/git_ops.js";
|
|
16
|
+
import { CliError, EXIT_USAGE } from "../error.js";
|
|
17
|
+
import { renderCommandHelp, unknownOptionMessage } from "./registry.js";
|
|
18
|
+
import { readDir, readTextFile, stat } from "../../util/runtime.js";
|
|
19
|
+
import { baseUrl, die, readApiError, remoteFetch } from "../../remote/http.js";
|
|
20
|
+
/** The four generated guides, in the order a reader wants them: the
|
|
21
|
+
* contribution skill first, then the codebase notes, then the two review
|
|
22
|
+
* guides (short, then the evidence behind it). */
|
|
23
|
+
const GUIDES = [
|
|
24
|
+
{ kind: "skill", file: "SKILL.md" },
|
|
25
|
+
{ kind: "codebase", file: "CODEBASE.md" },
|
|
26
|
+
{ kind: "review-guide", file: "PR_REVIEW_GUIDE.md" },
|
|
27
|
+
{ kind: "detailed-guide", file: "PR_REVIEW_DETAILED_GUIDE.md" },
|
|
28
|
+
];
|
|
29
|
+
/** Resolves a user-supplied name to a guide. Accepts the plan's kind names
|
|
30
|
+
* (`review-guide`), the file name (`PR_REVIEW_GUIDE.md`), and the bare stem
|
|
31
|
+
* (`PR_REVIEW_GUIDE`), case-insensitively. */
|
|
32
|
+
function resolveGuide(name) {
|
|
33
|
+
const wanted = name.toLowerCase();
|
|
34
|
+
const match = GUIDES.find((guide) => guide.kind === wanted ||
|
|
35
|
+
guide.file.toLowerCase() === wanted ||
|
|
36
|
+
guide.file.toLowerCase().replace(/\.md$/, "") === wanted);
|
|
37
|
+
if (!match) {
|
|
38
|
+
throw new CliError("unknown_guide", `Unknown guide: ${name}.`, `Pass one of ${GUIDES.map((guide) => guide.kind).join(", ")}, or --list.`, EXIT_USAGE);
|
|
39
|
+
}
|
|
40
|
+
return match;
|
|
41
|
+
}
|
|
42
|
+
/** `2026-09-22` from an ISO timestamp, so a header says when the guide was
|
|
43
|
+
* built without a database lookup. */
|
|
44
|
+
function builtDate(iso) {
|
|
45
|
+
return iso.slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
/** A human size for `--list`, so a glance says whether a guide is present. */
|
|
48
|
+
function humanSize(bytes) {
|
|
49
|
+
if (bytes < 1024)
|
|
50
|
+
return `${bytes} B`;
|
|
51
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
52
|
+
}
|
|
53
|
+
/** The guides that actually exist on disk, in {@link GUIDES} order first so a
|
|
54
|
+
* local listing reads like a remote one. A file co-maintainer did not write
|
|
55
|
+
* (an older name, a hand-written note) is kept, after the four known ones. */
|
|
56
|
+
async function readLocalGuides(repo) {
|
|
57
|
+
const dir = `${reposDir()}/${repo}`;
|
|
58
|
+
const names = [];
|
|
59
|
+
try {
|
|
60
|
+
for await (const entry of readDir(dir)) {
|
|
61
|
+
if (entry.isFile)
|
|
62
|
+
names.push(entry.name);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const order = (name) => {
|
|
69
|
+
const index = GUIDES.findIndex((guide) => guide.file === name);
|
|
70
|
+
return index === -1 ? GUIDES.length : index;
|
|
71
|
+
};
|
|
72
|
+
const guides = [];
|
|
73
|
+
for (const file of names.sort((a, b) => order(a) - order(b) || a.localeCompare(b))) {
|
|
74
|
+
try {
|
|
75
|
+
const info = await stat(`${dir}/${file}`);
|
|
76
|
+
guides.push({
|
|
77
|
+
kind: GUIDES.find((guide) => guide.file === file)?.kind ?? "other",
|
|
78
|
+
file,
|
|
79
|
+
size: info.size,
|
|
80
|
+
builtAt: info.mtime.toISOString(),
|
|
81
|
+
content: await readTextFile(`${dir}/${file}`),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Vanished between the listing and the read: skip it rather than fail
|
|
86
|
+
// the whole directory.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return guides;
|
|
90
|
+
}
|
|
91
|
+
/** The guides a remote review server holds for `repo` (CORE-44). The token is
|
|
92
|
+
* the same one `review --remote` uses, so a host already configured for remote
|
|
93
|
+
* review needs no extra setup. */
|
|
94
|
+
async function readRemoteGuides(repo) {
|
|
95
|
+
const config = readConfig();
|
|
96
|
+
const host = config.remoteHost;
|
|
97
|
+
const token = config.remoteToken;
|
|
98
|
+
if (!host || !token) {
|
|
99
|
+
die("remote_not_configured", "Remote review is not configured.", "co-maintainer config set remote-host https://your-server " +
|
|
100
|
+
"&& co-maintainer config set remote-token <token>");
|
|
101
|
+
}
|
|
102
|
+
const response = await remoteFetch(host, token, `/api/remote/guides?repo=${encodeURIComponent(repo)}`, { method: "GET" });
|
|
103
|
+
if (!response.ok) {
|
|
104
|
+
die("remote_guides_failed", `The server at ${baseUrl(host)} could not list the guides for ${repo}.`, await readApiError(response));
|
|
105
|
+
}
|
|
106
|
+
const body = (await response.json());
|
|
107
|
+
return body.guides ?? [];
|
|
108
|
+
}
|
|
109
|
+
/** Resolves the repo: the explicit `owner/repo` when given, otherwise the
|
|
110
|
+
* git remote of the current directory. */
|
|
111
|
+
async function resolveRepo(args) {
|
|
112
|
+
const positional = args.find((arg) => !arg.startsWith("-"));
|
|
113
|
+
if (positional && /^[^/]+\/[^/]+$/.test(positional))
|
|
114
|
+
return positional;
|
|
115
|
+
return detectRemoteRepo(process.cwd(), positional);
|
|
116
|
+
}
|
|
117
|
+
/** Prints one guide list: `--list` rows, the raw single guide, or every guide
|
|
118
|
+
* behind its own header. Shared by the local and remote sources so the two
|
|
119
|
+
* cannot drift into different outputs. */
|
|
120
|
+
function printGuides(repo, guides, options) {
|
|
121
|
+
if (options.list) {
|
|
122
|
+
const width = Math.max(...guides.map((guide) => guide.file.length));
|
|
123
|
+
for (const guide of guides) {
|
|
124
|
+
console.log(`${guide.file.padEnd(width)} ${humanSize(guide.size).padStart(8)} ${builtDate(guide.builtAt)}`);
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (options.name) {
|
|
129
|
+
// A single guide is printed raw, with no header, so `view ... | pbcopy`
|
|
130
|
+
// and `view ... > file` produce exactly the markdown.
|
|
131
|
+
const wanted = resolveGuide(options.name);
|
|
132
|
+
const found = guides.find((guide) => guide.file === wanted.file);
|
|
133
|
+
if (!found) {
|
|
134
|
+
throw new CliError("guide_missing", `${repo} has no ${wanted.file}.`, `co-maintainer view ${repo} --list`, EXIT_USAGE);
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write(found.content);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// Every guide, each behind a header that says which file and when it was
|
|
140
|
+
// built, so a scroll through the output never loses its place.
|
|
141
|
+
console.log(guides
|
|
142
|
+
.map((guide) => `== ${guide.file} · built ${builtDate(guide.builtAt)} ==\n${guide.content.trimEnd()}`)
|
|
143
|
+
.join("\n\n"));
|
|
144
|
+
}
|
|
145
|
+
export async function runView(args) {
|
|
146
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
147
|
+
console.log(renderCommandHelp("view") ??
|
|
148
|
+
"Usage: co-maintainer view [owner/repo] [guide]");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const remote = args.includes("--remote");
|
|
152
|
+
for (const arg of args) {
|
|
153
|
+
if (arg.startsWith("--") &&
|
|
154
|
+
!["--list", "--path", "--remote", "--help", "-h"].includes(arg)) {
|
|
155
|
+
throw new CliError("usage", unknownOptionMessage(arg));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// The directory belongs to this machine, so it cannot answer for a server.
|
|
159
|
+
if (remote && args.includes("--path")) {
|
|
160
|
+
throw new CliError("usage", "--path and --remote cannot be combined.", "Drop --remote to print the local guide directory.", EXIT_USAGE);
|
|
161
|
+
}
|
|
162
|
+
const repo = await resolveRepo(args);
|
|
163
|
+
const name = args.find((arg) => !arg.startsWith("-") && arg !== repo);
|
|
164
|
+
if (!remote && args.includes("--path")) {
|
|
165
|
+
console.log(`${reposDir()}/${repo}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const guides = remote
|
|
169
|
+
? await readRemoteGuides(repo)
|
|
170
|
+
: await readLocalGuides(repo);
|
|
171
|
+
if (guides.length === 0) {
|
|
172
|
+
throw new CliError("no_guides", remote
|
|
173
|
+
? `The server has no guides for ${repo}.`
|
|
174
|
+
: `No guides for ${repo}.`, `co-maintainer probe ${repo}`, EXIT_USAGE);
|
|
175
|
+
}
|
|
176
|
+
printGuides(repo, guides, {
|
|
177
|
+
list: args.includes("--list"),
|
|
178
|
+
name,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** One error shape for every CLI failure (CORE-10).
|
|
2
|
+
*
|
|
3
|
+
* The message is the first line the user reads, `hint` is an optional
|
|
4
|
+
* follow-up line printed as `Hint: ...`, and `exitCode` is the documented
|
|
5
|
+
* contract: 1 only for blocking review findings, 2 for usage and precondition
|
|
6
|
+
* errors, 3 for runtime failures. Anything thrown that is not a `CliError` is
|
|
7
|
+
* treated as a runtime failure (3), which is the safe default for the network,
|
|
8
|
+
* provider and filesystem errors the CLI does not classify yet.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXIT_FINDINGS = 1;
|
|
11
|
+
export declare const EXIT_USAGE = 2;
|
|
12
|
+
export declare const EXIT_RUNTIME = 3;
|
|
13
|
+
export declare class CliError extends Error {
|
|
14
|
+
code: string;
|
|
15
|
+
hint?: string;
|
|
16
|
+
exitCode: number;
|
|
17
|
+
constructor(code: string, message: string, hint?: string, exitCode?: number);
|
|
18
|
+
}
|
|
19
|
+
/** Throws a usage or precondition error. Kept as a helper so the existing
|
|
20
|
+
* `die(...)` call sites read the same as before. */
|
|
21
|
+
export declare function die(message: string, code?: string): never;
|
|
22
|
+
/** Ends the CLI with `code`, letting the event loop drain first.
|
|
23
|
+
*
|
|
24
|
+
* `process.exit` while undici's fetch connection pool is still open asserts in
|
|
25
|
+
* libuv on Windows (`Assertion failed: !(handle->flags & UV_HANDLE_CLOSING),
|
|
26
|
+
* file src\win\async.c, line 94`) and the process dies with 0xC0000409 instead
|
|
27
|
+
* of the intended code. Every provider and remote call uses fetch, so this is
|
|
28
|
+
* reachable from any error path after a request. Setting `exitCode` and letting
|
|
29
|
+
* the loop empty is what the probe confirms is crash-free and immediate (the
|
|
30
|
+
* undici pool does not keep the process alive). CORE-11. */
|
|
31
|
+
export declare function exitWith(code: number): void;
|
|
32
|
+
/** Renders an error the way the CLI prints it: the message, then `Hint: ...`
|
|
33
|
+
* when there is one. Used where an error becomes a log line rather than the
|
|
34
|
+
* top-level exit (CORE-12), so a hint is not lost just because the failure was
|
|
35
|
+
* quarantined. */
|
|
36
|
+
export declare function formatError(error: unknown): string;
|
|
37
|
+
/** A failed `fetch` as a message that names the host and the cause (CORE-12).
|
|
38
|
+
* Node's own text is only `fetch failed`, which says neither. */
|
|
39
|
+
export declare function networkFailure(url: string, error: unknown): CliError;
|