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
|
@@ -2,3 +2,7 @@ export declare function cacheGet(namespace: string, key: string): Promise<string
|
|
|
2
2
|
export declare function cacheSet(namespace: string, key: string, value: string): Promise<void>;
|
|
3
3
|
export declare function cacheDelete(namespace: string, key: string): Promise<void>;
|
|
4
4
|
export declare function cacheDeletePrefix(namespace: string, prefix: string): Promise<void>;
|
|
5
|
+
/** Every value under `namespace` whose key starts with `prefix`, newest first.
|
|
6
|
+
* `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
|
|
7
|
+
* a repository's own job history without a second index to keep in sync. */
|
|
8
|
+
export declare function cacheValues(namespace: string, prefix: string): Promise<string[]>;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
import { Database } from "./sqlite.js";
|
|
2
2
|
import { cacheDbPath, getCacheDir } from "../config.js";
|
|
3
3
|
import { mkdirSync } from "../util/runtime.js";
|
|
4
|
-
|
|
4
|
+
/** Paths whose schema this process has already created. Keyed by path rather
|
|
5
|
+
* than a single boolean: `cacheDbPath()` reads the environment at call time
|
|
6
|
+
* and tests point it at a temp directory, so a process-level flag would skip
|
|
7
|
+
* `CREATE TABLE` for the second path and every query would then fail with
|
|
8
|
+
* `no such table: cache`. */
|
|
9
|
+
const initialized = new Set();
|
|
5
10
|
function openDatabase() {
|
|
6
11
|
mkdirSync(`${getCacheDir()}/co-maintainer`, { recursive: true });
|
|
7
|
-
const
|
|
8
|
-
|
|
12
|
+
const path = cacheDbPath();
|
|
13
|
+
const db = new Database(path);
|
|
14
|
+
if (!initialized.has(path)) {
|
|
9
15
|
db.exec(`
|
|
10
16
|
CREATE TABLE IF NOT EXISTS cache (
|
|
11
17
|
namespace TEXT NOT NULL,
|
|
@@ -15,7 +21,7 @@ function openDatabase() {
|
|
|
15
21
|
PRIMARY KEY (namespace, cache_key)
|
|
16
22
|
)
|
|
17
23
|
`);
|
|
18
|
-
initialized
|
|
24
|
+
initialized.add(path);
|
|
19
25
|
}
|
|
20
26
|
return db;
|
|
21
27
|
}
|
|
@@ -61,3 +67,20 @@ export async function cacheDeletePrefix(namespace, prefix) {
|
|
|
61
67
|
db.close();
|
|
62
68
|
}
|
|
63
69
|
}
|
|
70
|
+
/** Every value under `namespace` whose key starts with `prefix`, newest first.
|
|
71
|
+
* `recordAiCost` keys each job `${repo}:${uuid}`, so the probe estimate reads
|
|
72
|
+
* a repository's own job history without a second index to keep in sync. */
|
|
73
|
+
export async function cacheValues(namespace, prefix) {
|
|
74
|
+
const db = openDatabase();
|
|
75
|
+
try {
|
|
76
|
+
return db
|
|
77
|
+
.prepare(`SELECT value FROM cache
|
|
78
|
+
WHERE namespace = ? AND cache_key LIKE ?
|
|
79
|
+
ORDER BY updated_at DESC`)
|
|
80
|
+
.all(namespace, `${prefix}%`)
|
|
81
|
+
.map((row) => row.value);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -10,3 +10,10 @@ export declare function recordDelivery(row: {
|
|
|
10
10
|
}): void;
|
|
11
11
|
export declare function hasDelivery(deliveryId: string): boolean;
|
|
12
12
|
export declare function listSkipped(repo?: string): DeliveryRow[];
|
|
13
|
+
/** The newest delivery for one repository, whatever its outcome, so a repo
|
|
14
|
+
* page can say when GitHub last reached it instead of showing nothing. */
|
|
15
|
+
export declare function lastDeliveryForRepo(repo: string): DeliveryRow | undefined;
|
|
16
|
+
/** The newest delivery of any kind, for the setup checklist's webhook step:
|
|
17
|
+
* it answers "has GitHub ever reached this server" rather than "for which
|
|
18
|
+
* repo". */
|
|
19
|
+
export declare function lastDelivery(): DeliveryRow | undefined;
|
|
@@ -21,3 +21,18 @@ export function listSkipped(repo) {
|
|
|
21
21
|
const db = getAppDb().prepare(`SELECT * FROM deliveries ${where} ORDER BY received_at DESC`);
|
|
22
22
|
return repo ? db.all(repo) : db.all();
|
|
23
23
|
}
|
|
24
|
+
/** The newest delivery for one repository, whatever its outcome, so a repo
|
|
25
|
+
* page can say when GitHub last reached it instead of showing nothing. */
|
|
26
|
+
export function lastDeliveryForRepo(repo) {
|
|
27
|
+
return getAppDb()
|
|
28
|
+
.prepare(`SELECT * FROM deliveries WHERE repo = ? ORDER BY received_at DESC LIMIT 1`)
|
|
29
|
+
.get(repo);
|
|
30
|
+
}
|
|
31
|
+
/** The newest delivery of any kind, for the setup checklist's webhook step:
|
|
32
|
+
* it answers "has GitHub ever reached this server" rather than "for which
|
|
33
|
+
* repo". */
|
|
34
|
+
export function lastDelivery() {
|
|
35
|
+
return getAppDb()
|
|
36
|
+
.prepare(`SELECT * FROM deliveries ORDER BY received_at DESC LIMIT 1`)
|
|
37
|
+
.get();
|
|
38
|
+
}
|
|
@@ -23,6 +23,36 @@ export type EnsureOptions = {
|
|
|
23
23
|
/** Overrides `process.exit`, for tests. */
|
|
24
24
|
exit?: (code: number) => never;
|
|
25
25
|
};
|
|
26
|
+
/** codegraph ships a small launcher plus a per-platform binary (an
|
|
27
|
+
* `optionalDependencies` split), so the download a user is about to accept is
|
|
28
|
+
* the platform package, not the tiny launcher. Measured from the npm registry
|
|
29
|
+
* for {@link CODEGRAPH_VERSION}: ~249 MB unpacked on win32-x64. Kept as a
|
|
30
|
+
* rounded figure because it moves between releases and the point is the order
|
|
31
|
+
* of magnitude, not the exact byte count. */
|
|
32
|
+
export declare const CODEGRAPH_APPROX_SIZE = "~250 MB";
|
|
33
|
+
/** A prompt may only be shown when there is a human on the other end of stdin
|
|
34
|
+
* *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
|
|
35
|
+
* output, or `CI=1` must all take the non-interactive path: asking then would
|
|
36
|
+
* hang on a question nobody can answer (F01). `stdout` is checked because a
|
|
37
|
+
* prompt printed into a pipe is invisible.
|
|
38
|
+
*
|
|
39
|
+
* `streams` is injectable so the tests can stand in for a TTY without a real
|
|
40
|
+
* terminal. */
|
|
41
|
+
export declare function canPrompt(streams?: {
|
|
42
|
+
stdin?: {
|
|
43
|
+
isTTY?: boolean;
|
|
44
|
+
};
|
|
45
|
+
stdout?: {
|
|
46
|
+
isTTY?: boolean;
|
|
47
|
+
};
|
|
48
|
+
}, env?: Record<string, string | undefined>): boolean;
|
|
49
|
+
/** The one line a non-interactive run prints instead of asking (F01). It names
|
|
50
|
+
* the reason and both ways forward, so a CI log explains itself. */
|
|
51
|
+
export declare function skippedNotice(version?: string): string;
|
|
52
|
+
/** What the install prompt says: the package and version, where it comes from,
|
|
53
|
+
* what it is for, the approximate size, and the exact directory. A bare
|
|
54
|
+
* "Install ...? [y/N]" left the user guessing (F01). */
|
|
55
|
+
export declare function installPrompt(version: string, root: string): string;
|
|
26
56
|
export declare function runCommand(command: string, args: string[]): Promise<CommandResult>;
|
|
27
57
|
/** One version per directory, so several can sit side by side and an upgrade
|
|
28
58
|
* never half-overwrites a working install. */
|
|
@@ -7,6 +7,41 @@ import { askConfirm } from "../cli/prompt.js";
|
|
|
7
7
|
* someone else's machine. Raise it, adjust whatever reads the output, done. */
|
|
8
8
|
export const CODEGRAPH_VERSION = "1.6.0";
|
|
9
9
|
export const CODEGRAPH_PACKAGE = "@colbymchenry/codegraph";
|
|
10
|
+
/** codegraph ships a small launcher plus a per-platform binary (an
|
|
11
|
+
* `optionalDependencies` split), so the download a user is about to accept is
|
|
12
|
+
* the platform package, not the tiny launcher. Measured from the npm registry
|
|
13
|
+
* for {@link CODEGRAPH_VERSION}: ~249 MB unpacked on win32-x64. Kept as a
|
|
14
|
+
* rounded figure because it moves between releases and the point is the order
|
|
15
|
+
* of magnitude, not the exact byte count. */
|
|
16
|
+
export const CODEGRAPH_APPROX_SIZE = "~250 MB";
|
|
17
|
+
/** A prompt may only be shown when there is a human on the other end of stdin
|
|
18
|
+
* *and* stdout, and no CI marker. A closed stdin (`</dev/null`), a piped
|
|
19
|
+
* output, or `CI=1` must all take the non-interactive path: asking then would
|
|
20
|
+
* hang on a question nobody can answer (F01). `stdout` is checked because a
|
|
21
|
+
* prompt printed into a pipe is invisible.
|
|
22
|
+
*
|
|
23
|
+
* `streams` is injectable so the tests can stand in for a TTY without a real
|
|
24
|
+
* terminal. */
|
|
25
|
+
export function canPrompt(streams = {
|
|
26
|
+
stdin: process.stdin,
|
|
27
|
+
stdout: process.stdout,
|
|
28
|
+
}, env = process.env) {
|
|
29
|
+
return (streams.stdin?.isTTY === true && streams.stdout?.isTTY === true && !env.CI);
|
|
30
|
+
}
|
|
31
|
+
/** The one line a non-interactive run prints instead of asking (F01). It names
|
|
32
|
+
* the reason and both ways forward, so a CI log explains itself. */
|
|
33
|
+
export function skippedNotice(version = CODEGRAPH_VERSION) {
|
|
34
|
+
return (`codegraph ${version} is not installed, reviewing without it. ` +
|
|
35
|
+
"Pass --allow-tool-install to install it, or --disable-codegraph to skip this notice.");
|
|
36
|
+
}
|
|
37
|
+
/** What the install prompt says: the package and version, where it comes from,
|
|
38
|
+
* what it is for, the approximate size, and the exact directory. A bare
|
|
39
|
+
* "Install ...? [y/N]" left the user guessing (F01). */
|
|
40
|
+
export function installPrompt(version, root) {
|
|
41
|
+
return (`co-maintainer uses codegraph ${version} (${CODEGRAPH_PACKAGE} from npm, ${CODEGRAPH_APPROX_SIZE}) ` +
|
|
42
|
+
"to follow calls between files while reviewing.\n" +
|
|
43
|
+
`Install it into ${versionDir(version, root)} (your global PATH is not touched)?`);
|
|
44
|
+
}
|
|
10
45
|
export async function runCommand(command, args) {
|
|
11
46
|
// Windows resolves npm and other shims through the shell, not as bare exes.
|
|
12
47
|
const windows = isWindows();
|
|
@@ -84,7 +119,9 @@ export async function ensureCodegraph(options = {}) {
|
|
|
84
119
|
const root = options.root ?? toolsDir();
|
|
85
120
|
const run = options.run ?? runCommand;
|
|
86
121
|
const log = options.log ?? ((message) => console.log(message));
|
|
87
|
-
|
|
122
|
+
// `canPrompt` is the single source of truth for "may we ask": stdin and
|
|
123
|
+
// stdout must both be a TTY and `CI` must be unset.
|
|
124
|
+
const interactive = options.interactive ?? canPrompt();
|
|
88
125
|
// Annotated so TypeScript can see the calls below never return and keeps the
|
|
89
126
|
// `Presence` narrowing intact.
|
|
90
127
|
const exit = options.exit ?? process.exit;
|
|
@@ -97,17 +134,18 @@ export async function ensureCodegraph(options = {}) {
|
|
|
97
134
|
}
|
|
98
135
|
if (!options.allowInstall) {
|
|
99
136
|
if (!interactive) {
|
|
100
|
-
|
|
101
|
-
|
|
137
|
+
// This path exits rather than continuing, so it must not say "reviewing
|
|
138
|
+
// without it"; it says how to proceed.
|
|
139
|
+
log(`[codegraph] codegraph ${CODEGRAPH_VERSION} is not installed.\n` +
|
|
140
|
+
`Run with --allow-tool-install, or install it yourself:\n ${installHint(CODEGRAPH_VERSION, root)}`);
|
|
102
141
|
exit(1);
|
|
103
142
|
}
|
|
104
143
|
const confirm = options.confirm ?? defaultConfirm;
|
|
105
144
|
// `defaultConfirm` is async, so an unawaited call is always truthy and the
|
|
106
145
|
// user's "no" was silently ignored. Await it before deciding.
|
|
107
|
-
const approved = await confirm(
|
|
108
|
-
`Install it into ${versionDir(CODEGRAPH_VERSION, root)} (your global PATH is not touched)?`);
|
|
146
|
+
const approved = await confirm(installPrompt(CODEGRAPH_VERSION, root));
|
|
109
147
|
if (!approved) {
|
|
110
|
-
log("[codegraph] declined
|
|
148
|
+
log("[codegraph] declined. Nothing was installed");
|
|
111
149
|
exit(1);
|
|
112
150
|
}
|
|
113
151
|
}
|
|
@@ -132,7 +170,7 @@ export async function ensureCodegraphForReview(options = {}) {
|
|
|
132
170
|
const root = options.root ?? toolsDir();
|
|
133
171
|
const run = options.run ?? runCommand;
|
|
134
172
|
const log = options.log ?? ((message) => console.error(message));
|
|
135
|
-
const interactive = options.interactive ??
|
|
173
|
+
const interactive = options.interactive ?? canPrompt();
|
|
136
174
|
const present = await detect(CODEGRAPH_VERSION, root, run);
|
|
137
175
|
if (present.state === "ok")
|
|
138
176
|
return { path: present.path };
|
|
@@ -140,15 +178,14 @@ export async function ensureCodegraphForReview(options = {}) {
|
|
|
140
178
|
log(`[codegraph] expected ${CODEGRAPH_VERSION} but found ${present.version}; reinstalling`);
|
|
141
179
|
}
|
|
142
180
|
if (!options.allowInstall) {
|
|
181
|
+
// Non-interactive: do not ask, review without it. The caller logs the
|
|
182
|
+
// reason, so the run continues instead of hanging or exiting (F01).
|
|
143
183
|
if (!interactive) {
|
|
144
|
-
return {
|
|
145
|
-
reason: `codegraph ${CODEGRAPH_VERSION} is not installed (use --allow-tool-install)`,
|
|
146
|
-
};
|
|
184
|
+
return { reason: skippedNotice() };
|
|
147
185
|
}
|
|
148
186
|
const confirm = options.confirm ?? defaultConfirm;
|
|
149
187
|
// Same as `ensureCodegraph`: an unawaited async confirm is always truthy.
|
|
150
|
-
const approved = await confirm(
|
|
151
|
-
`Install into ${versionDir(CODEGRAPH_VERSION, root)}?`);
|
|
188
|
+
const approved = await confirm(installPrompt(CODEGRAPH_VERSION, root));
|
|
152
189
|
if (!approved)
|
|
153
190
|
return { reason: "codegraph install declined" };
|
|
154
191
|
}
|
package/dist/src/types.d.ts
CHANGED
|
@@ -43,6 +43,12 @@ export type Options = {
|
|
|
43
43
|
maxPrMonths?: number;
|
|
44
44
|
maxPullRequestChangeLines?: number;
|
|
45
45
|
maxComments?: number;
|
|
46
|
+
/** `probe --run`: after printing the plan, run the recommended init in the
|
|
47
|
+
* same process (CORE-24). */
|
|
48
|
+
run?: boolean;
|
|
49
|
+
/** How a review decides a blocking finding (CORE-41). `model` keeps the
|
|
50
|
+
* 0.4.13 behavior, `severity` uses a fixed P0/P1 threshold. */
|
|
51
|
+
reviewBlocking?: "model" | "severity";
|
|
46
52
|
};
|
|
47
53
|
export type GitHubClient = {
|
|
48
54
|
request<T>(endpoint: string): Promise<T>;
|
|
@@ -77,6 +83,10 @@ export type AiRequest = {
|
|
|
77
83
|
tools?: Json[];
|
|
78
84
|
maxTokens: number;
|
|
79
85
|
job: string;
|
|
86
|
+
/** A JSON schema the response must satisfy, sent as OpenRouter's
|
|
87
|
+
* `response_format`. Providers without structured output ignore it, so the
|
|
88
|
+
* model is also asked for a fenced JSON block in the prompt (CORE-40). */
|
|
89
|
+
responseFormat?: Json;
|
|
80
90
|
reasoningEffort?: "max" | "xhigh" | "high" | "medium" | "low" | "none";
|
|
81
91
|
};
|
|
82
92
|
export type AiResponse = {
|
package/dist/src/util/log.d.ts
CHANGED
|
@@ -6,6 +6,9 @@ type Sink = (phase: string, message: string) => void;
|
|
|
6
6
|
export declare function withCliLogsToStderr<T>(fn: () => Promise<T>): Promise<T>;
|
|
7
7
|
export declare function withLogSink<T>(sink: Sink, fn: () => Promise<T>): Promise<T>;
|
|
8
8
|
export declare function log(phase: string, message: string): void;
|
|
9
|
+
/** True inside a dashboard job (one wrapped with `withLogSink`), where a
|
|
10
|
+
* diagnostic line belongs in the job log rather than the server's terminal. */
|
|
11
|
+
export declare function hasLogSink(): boolean;
|
|
9
12
|
export declare function startHeartbeat(phase: string | (() => string)): () => void;
|
|
10
13
|
export declare function timed<T>(label: string, enabled: boolean, operation: () => Promise<T>): Promise<T>;
|
|
11
14
|
export {};
|
package/dist/src/util/log.js
CHANGED
|
@@ -21,12 +21,24 @@ export function log(phase, message) {
|
|
|
21
21
|
else
|
|
22
22
|
console.log(`[${phase}] ${message}`);
|
|
23
23
|
}
|
|
24
|
+
/** True inside a dashboard job (one wrapped with `withLogSink`), where a
|
|
25
|
+
* diagnostic line belongs in the job log rather than the server's terminal. */
|
|
26
|
+
export function hasLogSink() {
|
|
27
|
+
return sinkStorage.getStore() !== undefined;
|
|
28
|
+
}
|
|
24
29
|
export function startHeartbeat(phase) {
|
|
25
30
|
const started = Date.now();
|
|
26
31
|
const label = () => (typeof phase === "function" ? phase() : phase);
|
|
27
32
|
const timer = setInterval(() => {
|
|
28
33
|
log("progress", `still running ${label()} · ${Math.round((Date.now() - started) / 1000)}s elapsed`);
|
|
29
34
|
}, 15_000);
|
|
35
|
+
// Never keep the process alive on its own (CORE-11). A long operation holds
|
|
36
|
+
// the loop open through its own pending async work (a fetch, a child), so the
|
|
37
|
+
// heartbeat still fires; but once that work is gone the timer must not be the
|
|
38
|
+
// reason the process cannot drain and apply its exit code. Every call site
|
|
39
|
+
// used to clear this by hand on the success path only, so any thrown error
|
|
40
|
+
// leaked it and the CLI hung instead of exiting.
|
|
41
|
+
timer.unref();
|
|
30
42
|
return () => clearInterval(timer);
|
|
31
43
|
}
|
|
32
44
|
export async function timed(label, enabled, operation) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The one-line cost and time summary every AI command ends with (CORE-26).
|
|
2
|
+
*
|
|
3
|
+
* F18: the JSON already carried `usage.costUsd` and a duration, but the human
|
|
4
|
+
* output had neither and `init` printed no total at all, so the only place to
|
|
5
|
+
* see what a run cost was the OpenRouter dashboard. This renders the same
|
|
6
|
+
* numbers as a single stderr line:
|
|
7
|
+
*
|
|
8
|
+
* `Done in 21.0s · 3,125 in, 1,308 out tokens · $0.0016`
|
|
9
|
+
*
|
|
10
|
+
* stdout is untouched; this is progress/diagnostic output, and mixing it into
|
|
11
|
+
* stdout would corrupt `--json` and any piped text. When the provider does not
|
|
12
|
+
* report a cost the dollar field is `cost unknown` rather than `$0.0000`.
|
|
13
|
+
*/
|
|
14
|
+
import type { AiMetrics } from "../services/setup.ts";
|
|
15
|
+
export type RunSummary = {
|
|
16
|
+
/** Wall-clock milliseconds, or null when only the AI time is known. */
|
|
17
|
+
durationMs: number | null;
|
|
18
|
+
tokensIn: number;
|
|
19
|
+
tokensOut: number;
|
|
20
|
+
/** `null` when any call left the cost unknown, matching `usage.costUsd`. */
|
|
21
|
+
costUsd: number | null;
|
|
22
|
+
};
|
|
23
|
+
/** `21.0s`, `1.3s`, `2m 03s`; one decimal below a minute, whole seconds above. */
|
|
24
|
+
export declare function formatDuration(ms: number): string;
|
|
25
|
+
/** The whole line, without a trailing newline. */
|
|
26
|
+
export declare function formatRunSummary(summary: RunSummary): string;
|
|
27
|
+
/** Formats and writes the summary. To stderr for the CLI; inside a dashboard
|
|
28
|
+
* job (no stderr) it goes to the job log instead. It prints even when the run
|
|
29
|
+
* made no AI call, because a cache-hit `sync` that cost nothing is a fact the
|
|
30
|
+
* user asked for, not a reason to stay silent. */
|
|
31
|
+
export declare function printRunSummary(summary: RunSummary): void;
|
|
32
|
+
/** The metrics half of a summary, so call sites do not repeat the spread. */
|
|
33
|
+
export declare function summaryFromMetrics(metrics: AiMetrics, durationMs: number | null): RunSummary;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { hasLogSink, log } from "./log.js";
|
|
2
|
+
/** `3,125` — grouped the way the numbers read in the docs. */
|
|
3
|
+
function group(value) {
|
|
4
|
+
return Math.round(value).toLocaleString("en-US");
|
|
5
|
+
}
|
|
6
|
+
/** `21.0s`, `1.3s`, `2m 03s`; one decimal below a minute, whole seconds above. */
|
|
7
|
+
export function formatDuration(ms) {
|
|
8
|
+
const seconds = ms / 1000;
|
|
9
|
+
if (seconds < 60)
|
|
10
|
+
return `${seconds.toFixed(1)}s`;
|
|
11
|
+
const whole = Math.round(seconds);
|
|
12
|
+
const minutes = Math.floor(whole / 60);
|
|
13
|
+
const rest = whole % 60;
|
|
14
|
+
return `${minutes}m ${String(rest).padStart(2, "0")}s`;
|
|
15
|
+
}
|
|
16
|
+
/** The whole line, without a trailing newline. */
|
|
17
|
+
export function formatRunSummary(summary) {
|
|
18
|
+
const parts = [];
|
|
19
|
+
if (summary.durationMs !== null) {
|
|
20
|
+
parts.push(`Done in ${formatDuration(summary.durationMs)}`);
|
|
21
|
+
}
|
|
22
|
+
parts.push(`${group(summary.tokensIn)} in, ${group(summary.tokensOut)} out tokens`);
|
|
23
|
+
parts.push(summary.costUsd === null
|
|
24
|
+
? "cost unknown"
|
|
25
|
+
: `$${summary.costUsd.toFixed(4)}`);
|
|
26
|
+
return parts.join(" · ");
|
|
27
|
+
}
|
|
28
|
+
/** Formats and writes the summary. To stderr for the CLI; inside a dashboard
|
|
29
|
+
* job (no stderr) it goes to the job log instead. It prints even when the run
|
|
30
|
+
* made no AI call, because a cache-hit `sync` that cost nothing is a fact the
|
|
31
|
+
* user asked for, not a reason to stay silent. */
|
|
32
|
+
export function printRunSummary(summary) {
|
|
33
|
+
const line = formatRunSummary(summary);
|
|
34
|
+
if (hasLogSink())
|
|
35
|
+
log("done", line);
|
|
36
|
+
else
|
|
37
|
+
console.error(line);
|
|
38
|
+
}
|
|
39
|
+
/** The metrics half of a summary, so call sites do not repeat the spread. */
|
|
40
|
+
export function summaryFromMetrics(metrics, durationMs) {
|
|
41
|
+
return {
|
|
42
|
+
durationMs,
|
|
43
|
+
tokensIn: metrics.tokensIn,
|
|
44
|
+
tokensOut: metrics.tokensOut,
|
|
45
|
+
costUsd: metrics.costKnown ? metrics.cost : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Why GitHub cannot deliver webhooks to this URL, or `undefined` when the
|
|
2
|
+
* host looks reachable from the public internet.
|
|
3
|
+
*
|
|
4
|
+
* `serve` accepts any absolute http(s) URL because a single-label internal
|
|
5
|
+
* host (Docker Compose, k8s) or an IPv6 literal is legitimate for other
|
|
6
|
+
* callers. For GitHub's webhook deliveries they are not: localhost and the
|
|
7
|
+
* private ranges below are unreachable from outside, and a fresh install
|
|
8
|
+
* defaults to `http://localhost:<port>/github/webhook`, so the most common
|
|
9
|
+
* first-run mistake is silent otherwise. This only classifies the host, it
|
|
10
|
+
* never rejects a URL: `set` and `serve` keep accepting them. */
|
|
11
|
+
export declare function webhookReachabilityProblem(webhookUrl: string): string | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Why GitHub cannot deliver webhooks to this URL, or `undefined` when the
|
|
2
|
+
* host looks reachable from the public internet.
|
|
3
|
+
*
|
|
4
|
+
* `serve` accepts any absolute http(s) URL because a single-label internal
|
|
5
|
+
* host (Docker Compose, k8s) or an IPv6 literal is legitimate for other
|
|
6
|
+
* callers. For GitHub's webhook deliveries they are not: localhost and the
|
|
7
|
+
* private ranges below are unreachable from outside, and a fresh install
|
|
8
|
+
* defaults to `http://localhost:<port>/github/webhook`, so the most common
|
|
9
|
+
* first-run mistake is silent otherwise. This only classifies the host, it
|
|
10
|
+
* never rejects a URL: `set` and `serve` keep accepting them. */
|
|
11
|
+
export function webhookReachabilityProblem(webhookUrl) {
|
|
12
|
+
let host;
|
|
13
|
+
try {
|
|
14
|
+
host = new URL(webhookUrl).hostname;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return "The webhook URL is not a valid absolute URL.";
|
|
18
|
+
}
|
|
19
|
+
// `URL` keeps IPv6 literals in brackets.
|
|
20
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
21
|
+
const lower = bare.toLowerCase();
|
|
22
|
+
if (lower === "localhost" || lower.endsWith(".localhost")) {
|
|
23
|
+
return "localhost only resolves on the machine running serve.";
|
|
24
|
+
}
|
|
25
|
+
if (lower === "0.0.0.0" || lower === "::" || lower === "::1") {
|
|
26
|
+
return `${bare} only resolves on the machine running serve.`;
|
|
27
|
+
}
|
|
28
|
+
if (lower.startsWith("127.")) {
|
|
29
|
+
return "A loopback address only resolves on the machine running serve.";
|
|
30
|
+
}
|
|
31
|
+
if (isPrivateIpv4(lower)) {
|
|
32
|
+
return `${bare} is a private network address, not one GitHub can route to.`;
|
|
33
|
+
}
|
|
34
|
+
if (isPrivateIpv6(lower)) {
|
|
35
|
+
return `${bare} is a private network address, not one GitHub can route to.`;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function isPrivateIpv4(host) {
|
|
40
|
+
const parts = host.split(".");
|
|
41
|
+
if (parts.length !== 4)
|
|
42
|
+
return false;
|
|
43
|
+
const octets = parts.map((part) => Number(part));
|
|
44
|
+
if (octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const [a, b] = octets;
|
|
48
|
+
if (a === 10)
|
|
49
|
+
return true;
|
|
50
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
51
|
+
return true;
|
|
52
|
+
if (a === 192 && b === 168)
|
|
53
|
+
return true;
|
|
54
|
+
// Link-local, as in the 169.254.169.254 metadata address.
|
|
55
|
+
if (a === 169 && b === 254)
|
|
56
|
+
return true;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
function isPrivateIpv6(host) {
|
|
60
|
+
if (!host.includes(":"))
|
|
61
|
+
return false;
|
|
62
|
+
// Unique local (fc00::/7) and link-local (fe80::/10).
|
|
63
|
+
return /^f[cd]/.test(host) || /^fe[89ab]/.test(host);
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "co-maintainer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0-beta.1",
|
|
4
|
+
"description": "An imitator of yours, A PR review tool like co-maintainer.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"code-review",
|
|
8
|
+
"github",
|
|
9
|
+
"maintainer",
|
|
10
|
+
"pull-request"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://co-maintainer.com",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
15
|
+
},
|
|
5
16
|
"license": "MIT",
|
|
6
17
|
"repository": {
|
|
7
18
|
"type": "git",
|
|
8
19
|
"url": "https://github.com/GroophyLifefor/co-maintainer"
|
|
9
20
|
},
|
|
10
|
-
"homepage": "https://github.com/GroophyLifefor/co-maintainer#readme",
|
|
11
|
-
"bugs": {
|
|
12
|
-
"url": "https://github.com/GroophyLifefor/co-maintainer/issues"
|
|
13
|
-
},
|
|
14
|
-
"keywords": [
|
|
15
|
-
"github",
|
|
16
|
-
"pull-request",
|
|
17
|
-
"code-review",
|
|
18
|
-
"maintainer",
|
|
19
|
-
"ai"
|
|
20
|
-
],
|
|
21
21
|
"bin": {
|
|
22
22
|
"co-maintainer": "dist/main.js"
|
|
23
23
|
},
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"build": "npm run assets:embed && tsc -p tsconfig.build.json",
|
|
35
35
|
"assets:embed": "node scripts/embed_assets.mjs",
|
|
36
36
|
"prepack": "npm run build",
|
|
37
|
-
"test": "node --test \"src/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
|
|
37
|
+
"test": "node --test \"src/**/*.test.ts\" \"scripts/**/*.test.ts\" \"benchmark/**/*.test.ts\"",
|
|
38
38
|
"check": "tsc --noEmit",
|
|
39
39
|
"lint": "oxlint",
|
|
40
40
|
"fmt": "oxfmt --write .",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"probe": "node main.ts probe",
|
|
44
44
|
"init": "node main.ts init",
|
|
45
45
|
"remake": "node main.ts remake",
|
|
46
|
+
"sync": "node main.ts sync",
|
|
46
47
|
"review": "node main.ts review",
|
|
47
48
|
"serve": "node main.ts serve",
|
|
48
49
|
"bench": "node benchmark/core_v2/run.ts",
|
|
@@ -52,7 +53,11 @@
|
|
|
52
53
|
"swe-prbench-prepare": "node benchmark/swe-prbench/prepare_dataset.ts",
|
|
53
54
|
"e2e": "node scripts/e2e.ts",
|
|
54
55
|
"review-local-e2e": "node scripts/review_local_e2e.ts",
|
|
55
|
-
"docs:build": "node scripts/build_docs.ts"
|
|
56
|
+
"docs:build": "node scripts/build_docs.ts",
|
|
57
|
+
"pack:smoke": "node scripts/pack_smoke.ts",
|
|
58
|
+
"downgrade:check": "node scripts/downgrade_check.ts",
|
|
59
|
+
"dx:scenario": "node scripts/dx_scenario.ts",
|
|
60
|
+
"dashboard:smoke": "node scripts/dashboard_smoke.ts"
|
|
56
61
|
},
|
|
57
62
|
"dependencies": {
|
|
58
63
|
"marked": "^15.0.7"
|
|
@@ -61,6 +66,7 @@
|
|
|
61
66
|
"@types/node": "^24.0.0",
|
|
62
67
|
"oxfmt": "^0.68.0",
|
|
63
68
|
"oxlint": "^1.83.0",
|
|
69
|
+
"playwright": "^1.63.0",
|
|
64
70
|
"typescript": "^5.7.0"
|
|
65
71
|
},
|
|
66
72
|
"engines": {
|