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,53 +1,33 @@
|
|
|
1
1
|
import { VERSION } from "../version.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { CliError, EXIT_USAGE, exitWith } from "../cli/error.js";
|
|
3
|
+
import { formatHumanReview, humanFindingsFromJson, reviewExitCodeFromJsonFindings, } from "../cli/review_result.js";
|
|
4
|
+
import { baseUrl, die, readApiError, remoteFetch } from "./http.js";
|
|
4
5
|
import { readConfig, writeUserConfig } from "../config.js";
|
|
5
6
|
import { prepareLocalCodegraph } from "../local/codegraph_prepare.js";
|
|
7
|
+
import { canPrompt } from "../tools/codegraph.js";
|
|
6
8
|
import { runRemoteToolCalls, toolHandlerMap, } from "./client_tools.js";
|
|
7
9
|
import { REMOTE_KNOWN_TOOL_NAMES } from "./schema.js";
|
|
8
10
|
import { runCommand } from "../pr/checkout.js";
|
|
9
|
-
import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot,
|
|
11
|
+
import { assertGitQuiet, currentBranch, detectRemoteRepo, gitRoot, } from "../local/git_ops.js";
|
|
10
12
|
import { buildLocalRevision, mergeBase, resolveBaseRef, } from "../local/git_revision.js";
|
|
11
13
|
import { withCliLogsToStderr } from "../util/log.js";
|
|
14
|
+
import { printRunSummary, summaryFromMetrics } from "../util/run_summary.js";
|
|
12
15
|
import { setCliInteractive } from "../cli/args.js";
|
|
13
16
|
import { MIN_SERVER_SCHEMA, REMOTE_CLI_UPGRADE_COMMAND, REMOTE_SCHEMA_VERSION, } from "./schema.js";
|
|
14
|
-
function die(code, message, hint, exitCode = 2) {
|
|
15
|
-
throw new ReviewCliError(code, message, hint, exitCode);
|
|
16
|
-
}
|
|
17
|
-
function baseUrl(host) {
|
|
18
|
-
const trimmed = host.trim().replace(/\/+$/, "");
|
|
19
|
-
if (!/^https?:\/\//i.test(trimmed)) {
|
|
20
|
-
die("remote_not_configured", "remoteHost must be an absolute http(s) URL.", "co-maintainer set --remote-host=https://your-server");
|
|
21
|
-
}
|
|
22
|
-
return trimmed;
|
|
23
|
-
}
|
|
24
|
-
async function remoteFetch(host, token, path, init) {
|
|
25
|
-
const url = `${baseUrl(host)}${path}`;
|
|
26
|
-
const headers = new Headers(init.headers);
|
|
27
|
-
headers.set("authorization", `Bearer ${token}`);
|
|
28
|
-
if (init.body && !headers.has("content-type")) {
|
|
29
|
-
headers.set("content-type", "application/json");
|
|
30
|
-
}
|
|
31
|
-
return await fetch(url, { ...init, headers });
|
|
32
|
-
}
|
|
33
|
-
async function readApiError(response) {
|
|
34
|
-
try {
|
|
35
|
-
const body = await response.json();
|
|
36
|
-
return String(body.error?.message ?? response.statusText);
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return response.statusText;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
17
|
export async function runRemoteReview(cli) {
|
|
43
18
|
const config = readConfig();
|
|
44
|
-
|
|
45
|
-
|
|
19
|
+
// Inline flags win over the saved config for this run only (CORE-25), so a
|
|
20
|
+
// one-off review does not have to be written to disk first.
|
|
21
|
+
const host = cli.remoteHost ?? config.remoteHost;
|
|
22
|
+
const token = cli.remoteToken ?? config.remoteToken;
|
|
46
23
|
if (!host || !token) {
|
|
47
|
-
die("remote_not_configured", "Remote review is not configured.", "co-maintainer set
|
|
24
|
+
die("remote_not_configured", "Remote review is not configured.", "co-maintainer config set remote-host https://your-server " +
|
|
25
|
+
"&& co-maintainer config set remote-token <token> " +
|
|
26
|
+
"(or pass --remote-host=... --remote-token=... for one run)");
|
|
48
27
|
}
|
|
49
28
|
setCliInteractive(!cli.json);
|
|
50
29
|
await withCliLogsToStderr(async () => {
|
|
30
|
+
const reviewStarted = performance.now();
|
|
51
31
|
const root = await gitRoot(process.cwd());
|
|
52
32
|
await assertGitQuiet(root);
|
|
53
33
|
const repo = await detectRemoteRepo(root, cli.repoOverride);
|
|
@@ -76,7 +56,7 @@ export async function runRemoteReview(cli) {
|
|
|
76
56
|
else {
|
|
77
57
|
console.log("No changes to review.");
|
|
78
58
|
}
|
|
79
|
-
|
|
59
|
+
exitWith(0);
|
|
80
60
|
}
|
|
81
61
|
const handshake = await remoteFetch(host, token, "/api/remote/handshake", {
|
|
82
62
|
method: "POST",
|
|
@@ -109,7 +89,8 @@ export async function runRemoteReview(cli) {
|
|
|
109
89
|
gitRoot: root,
|
|
110
90
|
enabled: !cli.disableCodegraph,
|
|
111
91
|
allowInstall: cli.allowToolInstall,
|
|
112
|
-
|
|
92
|
+
// `--json` must stay machine-readable; `canPrompt` adds TTY and CI (F01).
|
|
93
|
+
interactive: !cli.json && canPrompt(),
|
|
113
94
|
});
|
|
114
95
|
const localTools = toolHandlerMap(codegraphPrep.tools);
|
|
115
96
|
const capabilityTools = [...localTools.keys()]
|
|
@@ -181,14 +162,33 @@ export async function runRemoteReview(cli) {
|
|
|
181
162
|
else {
|
|
182
163
|
const findings = (payload.result.findings ??
|
|
183
164
|
[]);
|
|
184
|
-
const
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
165
|
+
const resultGuide = payload.result.guide;
|
|
166
|
+
const resultCodegraph = payload.result.codegraph;
|
|
167
|
+
console.log("\n" +
|
|
168
|
+
formatHumanReview({
|
|
169
|
+
title: `co-maintainer review · ${repo} · ${branch} (remote)`,
|
|
170
|
+
guideBuiltAt: resultGuide?.builtAt ?? null,
|
|
171
|
+
codegraphState: resultCodegraph?.state ?? null,
|
|
172
|
+
findings: humanFindingsFromJson(findings),
|
|
173
|
+
}) +
|
|
174
|
+
"\n");
|
|
175
|
+
const usage = payload.result.usage;
|
|
176
|
+
if (usage) {
|
|
177
|
+
printRunSummary(summaryFromMetrics({
|
|
178
|
+
calls: 0,
|
|
179
|
+
tokensIn: usage.tokensIn ?? 0,
|
|
180
|
+
tokensOut: usage.tokensOut ?? 0,
|
|
181
|
+
cost: usage.costUsd ?? 0,
|
|
182
|
+
costKnown: usage.costUsd !== null && usage.costUsd !== undefined,
|
|
183
|
+
}, performance.now() - reviewStarted));
|
|
184
|
+
}
|
|
189
185
|
}
|
|
190
186
|
const findings = (payload.result.findings ?? []);
|
|
191
|
-
|
|
187
|
+
exitWith(reviewExitCodeFromJsonFindings(findings));
|
|
188
|
+
// `done` is terminal. Without this return the loop polls `sync` again
|
|
189
|
+
// and, since the server keeps reporting the same finished job, the
|
|
190
|
+
// client reprints the result and re-requests forever (CORE-25).
|
|
191
|
+
return;
|
|
192
192
|
}
|
|
193
193
|
if (payload.status === "failed" || payload.status === "canceled") {
|
|
194
194
|
die(payload.abort?.reason ?? "remote_failed", payload.abort?.message ?? "Remote review did not complete.");
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** A usage error the CLI prints with its hint and exit code (CORE-12). */
|
|
2
|
+
export declare function die(code: string, message: string, hint?: string, exitCode?: number): never;
|
|
3
|
+
export declare function baseUrl(host: string): string;
|
|
4
|
+
export declare function remoteFetch(host: string, token: string, path: string, init: RequestInit): Promise<Response>;
|
|
5
|
+
export declare function readApiError(response: Response): Promise<string>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Shared HTTPS plumbing for the remote endpoints (CORE-25, CORE-44).
|
|
2
|
+
*
|
|
3
|
+
* `review --remote` and `view --remote` talk to the same server with the same
|
|
4
|
+
* bearer token, so the base URL, the token header and the explanations for a
|
|
5
|
+
* rejected token or an unreachable host live here rather than in the review
|
|
6
|
+
* client alone. */
|
|
7
|
+
import { CliError, EXIT_USAGE, networkFailure } from "../cli/error.js";
|
|
8
|
+
/** A usage error the CLI prints with its hint and exit code (CORE-12). */
|
|
9
|
+
export function die(code, message, hint, exitCode = EXIT_USAGE) {
|
|
10
|
+
throw new CliError(code, message, hint, exitCode);
|
|
11
|
+
}
|
|
12
|
+
export function baseUrl(host) {
|
|
13
|
+
const trimmed = host.trim().replace(/\/+$/, "");
|
|
14
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
15
|
+
die("remote_not_configured", "remoteHost must be an absolute http(s) URL.", "co-maintainer config set remote-host https://your-server");
|
|
16
|
+
}
|
|
17
|
+
return trimmed;
|
|
18
|
+
}
|
|
19
|
+
export async function remoteFetch(host, token, path, init) {
|
|
20
|
+
const url = `${baseUrl(host)}${path}`;
|
|
21
|
+
const headers = new Headers(init.headers);
|
|
22
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
23
|
+
if (init.body && !headers.has("content-type")) {
|
|
24
|
+
headers.set("content-type", "application/json");
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(url, { ...init, headers });
|
|
28
|
+
// The server's 401 text is `invalid or missing token`, which does not say
|
|
29
|
+
// where the token comes from or how to get another one (CORE-12).
|
|
30
|
+
if (response.status === 401 || response.status === 403) {
|
|
31
|
+
die("remote_token_rejected", `The server at ${baseUrl(host)} rejected the remote review token.`, "Create one in its dashboard under Settings, Remote review tokens.", EXIT_USAGE);
|
|
32
|
+
}
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof CliError)
|
|
37
|
+
throw error;
|
|
38
|
+
throw networkFailure(url, error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export async function readApiError(response) {
|
|
42
|
+
try {
|
|
43
|
+
const body = await response.json();
|
|
44
|
+
return String(body.error?.message ?? response.statusText);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return response.statusText;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type RemoteGuide = {
|
|
2
|
+
kind: string;
|
|
3
|
+
file: string;
|
|
4
|
+
size: number;
|
|
5
|
+
builtAt: string;
|
|
6
|
+
content: string;
|
|
7
|
+
};
|
|
8
|
+
/** Canonicalizes `owner/repo` from the query string. A value that escapes is
|
|
9
|
+
* refused before any directory is touched, the same guard the revision payload
|
|
10
|
+
* uses. */
|
|
11
|
+
export declare function parseRepoQuery(value: string | null): {
|
|
12
|
+
repo: string;
|
|
13
|
+
} | {
|
|
14
|
+
error: string;
|
|
15
|
+
};
|
|
16
|
+
/** Every file in `repo`'s guide directory, in `GUIDES` order first so the
|
|
17
|
+
* reading order matches a local `view`, with an unexpected file (an older
|
|
18
|
+
* name, a hand-written note) last. */
|
|
19
|
+
export declare function readRemoteGuides(repo: string): Promise<{
|
|
20
|
+
guides: RemoteGuide[];
|
|
21
|
+
guideBuiltAt: string | null;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** `GET /api/remote/guides?repo=owner/repo` — read a repository's guides with a
|
|
2
|
+
* remote token (CORE-44).
|
|
3
|
+
*
|
|
4
|
+
* This is what `view --remote` calls. It is a read-only addition: no existing
|
|
5
|
+
* route changes, and it uses the same bearer model the review endpoints use
|
|
6
|
+
* (valid token, active token, lockout after repeated failures). The response
|
|
7
|
+
* carries every file in the guide directory with its size and build date, so
|
|
8
|
+
* the CLI prints the same headers a local `view` prints without a round trip
|
|
9
|
+
* per file. */
|
|
10
|
+
import { reposDir } from "../../config.js";
|
|
11
|
+
import { loadGuides } from "../../review/guides.js";
|
|
12
|
+
import { readDir, readTextFile, stat } from "../../util/runtime.js";
|
|
13
|
+
import { validateRemotePath } from "../validate.js";
|
|
14
|
+
/** The four generated guides, in the order a reader wants them (CORE-23). */
|
|
15
|
+
const GUIDES = [
|
|
16
|
+
{ kind: "skill", file: "SKILL.md" },
|
|
17
|
+
{ kind: "codebase", file: "CODEBASE.md" },
|
|
18
|
+
{ kind: "review-guide", file: "PR_REVIEW_GUIDE.md" },
|
|
19
|
+
{ kind: "detailed-guide", file: "PR_REVIEW_DETAILED_GUIDE.md" },
|
|
20
|
+
];
|
|
21
|
+
/** Canonicalizes `owner/repo` from the query string. A value that escapes is
|
|
22
|
+
* refused before any directory is touched, the same guard the revision payload
|
|
23
|
+
* uses. */
|
|
24
|
+
export function parseRepoQuery(value) {
|
|
25
|
+
if (!value)
|
|
26
|
+
return { error: "repo: required" };
|
|
27
|
+
if (!/^[^/]+\/[^/]+$/.test(value)) {
|
|
28
|
+
return { error: "repo: must be owner/repo" };
|
|
29
|
+
}
|
|
30
|
+
const pathErr = validateRemotePath("repo", value);
|
|
31
|
+
if (pathErr)
|
|
32
|
+
return { error: pathErr };
|
|
33
|
+
return { repo: value };
|
|
34
|
+
}
|
|
35
|
+
/** Every file in `repo`'s guide directory, in `GUIDES` order first so the
|
|
36
|
+
* reading order matches a local `view`, with an unexpected file (an older
|
|
37
|
+
* name, a hand-written note) last. */
|
|
38
|
+
export async function readRemoteGuides(repo) {
|
|
39
|
+
const loaded = await loadGuides(repo);
|
|
40
|
+
const byFile = new Map([
|
|
41
|
+
["PR_REVIEW_GUIDE.md", loaded.shortGuide],
|
|
42
|
+
["PR_REVIEW_DETAILED_GUIDE.md", loaded.detailed],
|
|
43
|
+
["CODEBASE.md", loaded.codebase],
|
|
44
|
+
["SKILL.md", loaded.skill],
|
|
45
|
+
]);
|
|
46
|
+
const dir = `${reposDir()}/${repo}`;
|
|
47
|
+
const names = [];
|
|
48
|
+
try {
|
|
49
|
+
for await (const entry of readDir(dir)) {
|
|
50
|
+
if (entry.isFile)
|
|
51
|
+
names.push(entry.name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// No directory: `loadGuides` already returned empty strings, so the caller
|
|
56
|
+
// gets an empty list rather than an error.
|
|
57
|
+
return { guides: [], guideBuiltAt: loaded.guideBuiltAt };
|
|
58
|
+
}
|
|
59
|
+
const order = (name) => {
|
|
60
|
+
const index = GUIDES.findIndex((guide) => guide.file === name);
|
|
61
|
+
return index === -1 ? GUIDES.length : index;
|
|
62
|
+
};
|
|
63
|
+
const guides = [];
|
|
64
|
+
for (const file of names.sort((a, b) => order(a) - order(b) || a.localeCompare(b))) {
|
|
65
|
+
try {
|
|
66
|
+
const info = await stat(`${dir}/${file}`);
|
|
67
|
+
guides.push({
|
|
68
|
+
kind: GUIDES.find((guide) => guide.file === file)?.kind ?? "other",
|
|
69
|
+
file,
|
|
70
|
+
size: info.size,
|
|
71
|
+
builtAt: info.mtime.toISOString(),
|
|
72
|
+
content: byFile.get(file) ?? (await readTextFile(`${dir}/${file}`)),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The file vanished between the listing and the read: skip it rather
|
|
77
|
+
// than fail the whole directory.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { guides, guideBuiltAt: loaded.guideBuiltAt };
|
|
81
|
+
}
|
|
@@ -11,6 +11,7 @@ import { MIN_CLIENT_SCHEMA, REMOTE_CLI_UPGRADE_COMMAND, REMOTE_MAX_BODY_BYTES, R
|
|
|
11
11
|
import { readBoundedJson } from "./body.js";
|
|
12
12
|
import { submitRemoteReview } from "../../services/remote_review.js";
|
|
13
13
|
import { handleRemoteCancel, handleRemoteSync } from "./sync.js";
|
|
14
|
+
import { parseRepoQuery, readRemoteGuides } from "./guides.js";
|
|
14
15
|
import { validateHandshakeRequest, validateSubmitRequest, } from "../validate.js";
|
|
15
16
|
import { stat } from "../../util/runtime.js";
|
|
16
17
|
const REPO_UNAVAILABLE = "Sorry, we could not access this repository.";
|
|
@@ -128,10 +129,33 @@ async function handleSubmit(request, ip) {
|
|
|
128
129
|
reviewId: result.reviewId,
|
|
129
130
|
}, { status: 202 });
|
|
130
131
|
}
|
|
132
|
+
async function handleGuides(request, ip, url) {
|
|
133
|
+
const auth = await authenticateRemote(request, ip);
|
|
134
|
+
if (auth instanceof Response)
|
|
135
|
+
return auth;
|
|
136
|
+
const parsed = parseRepoQuery(url.searchParams.get("repo"));
|
|
137
|
+
if ("error" in parsed) {
|
|
138
|
+
return errorResponse(400, "bad_request", parsed.error);
|
|
139
|
+
}
|
|
140
|
+
const repoRow = findRepoByFullName(parsed.repo);
|
|
141
|
+
if (!repoRow || repoRow.active !== 1) {
|
|
142
|
+
return errorResponse(404, "repo_unavailable", REPO_UNAVAILABLE);
|
|
143
|
+
}
|
|
144
|
+
const { guides, guideBuiltAt } = await readRemoteGuides(repoRow.full_name);
|
|
145
|
+
return Response.json({
|
|
146
|
+
schemaVersion: REMOTE_SCHEMA_VERSION,
|
|
147
|
+
repo: { fullName: repoRow.full_name },
|
|
148
|
+
guideBuiltAt,
|
|
149
|
+
guides,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
131
152
|
export async function handleRemoteRoute(request, url, remoteAddr) {
|
|
132
153
|
if (url.pathname === "/api/remote/handshake" && request.method === "POST") {
|
|
133
154
|
return await handleHandshake(request, remoteAddr);
|
|
134
155
|
}
|
|
156
|
+
if (url.pathname === "/api/remote/guides" && request.method === "GET") {
|
|
157
|
+
return await handleGuides(request, remoteAddr, url);
|
|
158
|
+
}
|
|
135
159
|
if (url.pathname === "/api/remote/reviews" && request.method === "POST") {
|
|
136
160
|
return await handleSubmit(request, remoteAddr);
|
|
137
161
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** How a review decides a blocking finding (CORE-41, plan decision 2 option C).
|
|
2
|
+
*
|
|
3
|
+
* F19: the same pull request was reviewed twice, the same P2 finding came back
|
|
4
|
+
* `non-blocking` the first time and `blocking` the second, and the exit code
|
|
5
|
+
* changed because the model's own label moved. `model` keeps that behavior
|
|
6
|
+
* (the model decides) because it is what 0.4.13 shipped; `severity` ignores
|
|
7
|
+
* the model's label and uses a fixed threshold, so the same finding set always
|
|
8
|
+
* yields the same exit code.
|
|
9
|
+
*
|
|
10
|
+
* This is the one place that answers "does this finding block". Every review
|
|
11
|
+
* path — local, PR, remote and the GitHub App — calls it, so the modes cannot
|
|
12
|
+
* drift apart.
|
|
13
|
+
*/
|
|
14
|
+
export type ReviewBlocking = "model" | "severity";
|
|
15
|
+
export declare const DEFAULT_REVIEW_BLOCKING: ReviewBlocking;
|
|
16
|
+
/** Reads a config, flag or env value. Anything but `severity` is the default,
|
|
17
|
+
* so an unset or mistyped value never silently tightens the rule. */
|
|
18
|
+
export declare function reviewBlockingFrom(value: unknown): ReviewBlocking;
|
|
19
|
+
/** One finding, in the shape any of the review paths happens to have. Only
|
|
20
|
+
* `severity` is always available; the other two describe the model's own
|
|
21
|
+
* claim, which `severity` mode ignores. */
|
|
22
|
+
export type BlockingInput = {
|
|
23
|
+
severity?: string;
|
|
24
|
+
/** The model's structured `blocking` field, when the answer was JSON. */
|
|
25
|
+
blocked?: boolean;
|
|
26
|
+
/** The heading or title text, when the answer came from Markdown. */
|
|
27
|
+
text?: string;
|
|
28
|
+
};
|
|
29
|
+
/** Whether one finding blocks under the configured rule.
|
|
30
|
+
|
|
31
|
+
* `model`: the model decides. A P0 always blocks even when the model labeled
|
|
32
|
+
* it otherwise, which is the 0.4.13 rule and is kept so no existing review
|
|
33
|
+
* changes its exit code.
|
|
34
|
+
* `severity`: P0 and P1 block, P2 and P3 do not, whatever the model said. */
|
|
35
|
+
export declare function isBlocking(mode: ReviewBlocking, finding: BlockingInput): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** How a review decides a blocking finding (CORE-41, plan decision 2 option C).
|
|
2
|
+
*
|
|
3
|
+
* F19: the same pull request was reviewed twice, the same P2 finding came back
|
|
4
|
+
* `non-blocking` the first time and `blocking` the second, and the exit code
|
|
5
|
+
* changed because the model's own label moved. `model` keeps that behavior
|
|
6
|
+
* (the model decides) because it is what 0.4.13 shipped; `severity` ignores
|
|
7
|
+
* the model's label and uses a fixed threshold, so the same finding set always
|
|
8
|
+
* yields the same exit code.
|
|
9
|
+
*
|
|
10
|
+
* This is the one place that answers "does this finding block". Every review
|
|
11
|
+
* path — local, PR, remote and the GitHub App — calls it, so the modes cannot
|
|
12
|
+
* drift apart.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_REVIEW_BLOCKING = "model";
|
|
15
|
+
/** Reads a config, flag or env value. Anything but `severity` is the default,
|
|
16
|
+
* so an unset or mistyped value never silently tightens the rule. */
|
|
17
|
+
export function reviewBlockingFrom(value) {
|
|
18
|
+
return value === "severity" ? "severity" : DEFAULT_REVIEW_BLOCKING;
|
|
19
|
+
}
|
|
20
|
+
/** The model's claim as written into a heading: `[P2 · non-blocking] …`. An
|
|
21
|
+
* unrecognised heading says nothing, rather than guessing "not blocking". */
|
|
22
|
+
function claimFromText(text) {
|
|
23
|
+
if (/non-blocking\]/i.test(text))
|
|
24
|
+
return false;
|
|
25
|
+
if (/·\s*blocking\]/i.test(text))
|
|
26
|
+
return true;
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Whether one finding blocks under the configured rule.
|
|
30
|
+
|
|
31
|
+
* `model`: the model decides. A P0 always blocks even when the model labeled
|
|
32
|
+
* it otherwise, which is the 0.4.13 rule and is kept so no existing review
|
|
33
|
+
* changes its exit code.
|
|
34
|
+
* `severity`: P0 and P1 block, P2 and P3 do not, whatever the model said. */
|
|
35
|
+
export function isBlocking(mode, finding) {
|
|
36
|
+
const severity = finding.severity?.toUpperCase();
|
|
37
|
+
if (mode === "severity") {
|
|
38
|
+
return severity === "P0" || severity === "P1";
|
|
39
|
+
}
|
|
40
|
+
if (severity === "P0")
|
|
41
|
+
return true;
|
|
42
|
+
if (finding.blocked === true)
|
|
43
|
+
return true;
|
|
44
|
+
return finding.text ? claimFromText(finding.text) === true : false;
|
|
45
|
+
}
|
|
@@ -45,6 +45,17 @@ export declare function relocate(anchorText: string | null, oldFrom: number, pat
|
|
|
45
45
|
from: number;
|
|
46
46
|
to: number;
|
|
47
47
|
} | null;
|
|
48
|
+
/** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
|
|
49
|
+
* review's verdicts, its "unchanged" suppression and its open findings — they
|
|
50
|
+
* were all judged against the old rules. Such a run starts fresh instead:
|
|
51
|
+
* every file is scanned again and old findings are no longer carried over.
|
|
52
|
+
*
|
|
53
|
+
* `previousBuiltAt` is the guide's own build time as of that review. A
|
|
54
|
+
* previous review that never recorded one (a snapshot written before CORE-42)
|
|
55
|
+
* counts as rebuilt too: re-scanning once is cheaper than letting a stale
|
|
56
|
+
* verdict mask a new violation. With no current time there is nothing to
|
|
57
|
+
* compare, so nothing is treated as rebuilt. */
|
|
58
|
+
export declare function guideRebuiltSince(previousBuiltAt: string | null, currentBuiltAt: string | null): boolean;
|
|
48
59
|
export declare function classifyCarryItems(previous: CarryPrevious, current: Revision, visiblePaths: Set<string>, currentGuideBuiltAt: string | null): CarryItem[];
|
|
49
60
|
export declare function buildCarryPromptSection(items: CarryItem[], current: Revision): string;
|
|
50
61
|
export type PreviousVerdict = {
|
|
@@ -56,7 +67,7 @@ export type PreviousVerdict = {
|
|
|
56
67
|
};
|
|
57
68
|
export declare function parsePreviousVerdicts(markdown: string): PreviousVerdict[];
|
|
58
69
|
export declare function findingsMarkdownForParse(markdown: string): string;
|
|
59
|
-
export declare function resolveCarryOutcomes(items: CarryItem[], current: Revision, visiblePaths: Set<string>, verdicts: PreviousVerdict[], parsedNew: ParsedFinding[]
|
|
70
|
+
export declare function resolveCarryOutcomes(items: CarryItem[], current: Revision, visiblePaths: Set<string>, verdicts: PreviousVerdict[], parsedNew: ParsedFinding[]): ResolvedFinding[];
|
|
60
71
|
export declare function incrementalDiffPaths(current: Revision, previousFiles: RevisionFile[] | null): {
|
|
61
72
|
changed: RevisionFile[];
|
|
62
73
|
unchanged: string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spansOverlap } from "../pr/rounds.js";
|
|
2
2
|
import { rightLines } from "../pr/hunks.js";
|
|
3
|
-
import { normalizeAnchor,
|
|
3
|
+
import { normalizeAnchor, normalizePath, revisionFilesEquivalent, resolvePathAfterRename, } from "./revision.js";
|
|
4
4
|
export function anchorTextFromPatch(patch, from, to) {
|
|
5
5
|
const lines = rightLines(patch);
|
|
6
6
|
const low = Math.min(from, to);
|
|
@@ -70,6 +70,28 @@ export function relocate(anchorText, oldFrom, patch) {
|
|
|
70
70
|
function guideChanged(previous, currentGuideBuiltAt) {
|
|
71
71
|
return (previous.guideBuiltAt ?? "") !== (currentGuideBuiltAt ?? "");
|
|
72
72
|
}
|
|
73
|
+
/** CORE-42 / F03: a guide rebuilt after the previous review invalidates that
|
|
74
|
+
* review's verdicts, its "unchanged" suppression and its open findings — they
|
|
75
|
+
* were all judged against the old rules. Such a run starts fresh instead:
|
|
76
|
+
* every file is scanned again and old findings are no longer carried over.
|
|
77
|
+
*
|
|
78
|
+
* `previousBuiltAt` is the guide's own build time as of that review. A
|
|
79
|
+
* previous review that never recorded one (a snapshot written before CORE-42)
|
|
80
|
+
* counts as rebuilt too: re-scanning once is cheaper than letting a stale
|
|
81
|
+
* verdict mask a new violation. With no current time there is nothing to
|
|
82
|
+
* compare, so nothing is treated as rebuilt. */
|
|
83
|
+
export function guideRebuiltSince(previousBuiltAt, currentBuiltAt) {
|
|
84
|
+
if (!currentBuiltAt)
|
|
85
|
+
return false;
|
|
86
|
+
if (!previousBuiltAt)
|
|
87
|
+
return true;
|
|
88
|
+
const before = Date.parse(previousBuiltAt);
|
|
89
|
+
const after = Date.parse(currentBuiltAt);
|
|
90
|
+
if (!Number.isFinite(before) || !Number.isFinite(after)) {
|
|
91
|
+
return previousBuiltAt !== currentBuiltAt;
|
|
92
|
+
}
|
|
93
|
+
return after > before;
|
|
94
|
+
}
|
|
73
95
|
export function classifyCarryItems(previous, current, visiblePaths, currentGuideBuiltAt) {
|
|
74
96
|
const currentByPath = new Map(current.files.map((file) => [normalizePath(file.path), file]));
|
|
75
97
|
const items = [];
|
|
@@ -86,7 +108,6 @@ export function classifyCarryItems(previous, current, visiblePaths, currentGuide
|
|
|
86
108
|
const prevFile = finding.path
|
|
87
109
|
? previous.files.find((f) => normalizePath(f.path) === normalizePath(finding.path))
|
|
88
110
|
: undefined;
|
|
89
|
-
const prevPatch = prevFile?.patch ?? "";
|
|
90
111
|
const sameBody = Boolean(prevFile && file && revisionFilesEquivalent(prevFile, file));
|
|
91
112
|
if (!finding.path) {
|
|
92
113
|
klass = "unverifiable";
|
|
@@ -178,12 +199,17 @@ export function buildCarryPromptSection(items, current) {
|
|
|
178
199
|
if (verify.length === 0 && unchanged.length === 0)
|
|
179
200
|
return "";
|
|
180
201
|
const lines = [
|
|
181
|
-
"PREVIOUS FINDINGS:",
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
202
|
+
"PREVIOUS FINDINGS (this run has two separate jobs):",
|
|
203
|
+
"1. Scan the DIFF from scratch for every actionable violation under the",
|
|
204
|
+
" current review guide, exactly as a first review would. Do not let the",
|
|
205
|
+
" list below shorten or replace that scan. A changed file can contain a",
|
|
206
|
+
" new finding even when a previous finding already covers its location.",
|
|
207
|
+
"2. For each entry under VERIFY, decide whether the code in the DIFF still",
|
|
208
|
+
" has that problem. Report every decision in a final section headed",
|
|
209
|
+
' exactly "## Previous findings", one line per id, exactly as',
|
|
210
|
+
' "- F1: open path:from-to" or "- F1: closed".',
|
|
211
|
+
'Do not repeat an open previous finding under "## Findings"; report there',
|
|
212
|
+
"only findings from job 1 that are not already covered below.",
|
|
187
213
|
"",
|
|
188
214
|
];
|
|
189
215
|
if (verify.length) {
|
|
@@ -219,7 +245,7 @@ export function findingsMarkdownForParse(markdown) {
|
|
|
219
245
|
const cut = markdown.search(/^## Previous findings\b/im);
|
|
220
246
|
return cut === -1 ? markdown : markdown.slice(0, cut);
|
|
221
247
|
}
|
|
222
|
-
export function resolveCarryOutcomes(items, current, visiblePaths, verdicts, parsedNew
|
|
248
|
+
export function resolveCarryOutcomes(items, current, visiblePaths, verdicts, parsedNew) {
|
|
223
249
|
const verdictByPromptId = new Map(verdicts.map((v) => [v.id, v]));
|
|
224
250
|
const promptIds = new Map();
|
|
225
251
|
let seq = 0;
|
|
@@ -9,5 +9,6 @@ type ProgressSink = (message: string) => void;
|
|
|
9
9
|
export declare function runReviewEngine(client: GitHubClient, options: Options, usage?: UsageSink, snapshot?: Snapshot, ai?: AiProvider, progress?: ProgressSink, extras?: ReviewExtras): Promise<AiResponse & {
|
|
10
10
|
visiblePaths: string[];
|
|
11
11
|
guideBuiltAt: string | null;
|
|
12
|
+
codegraphState: "used" | "disabled" | "unavailable";
|
|
12
13
|
}>;
|
|
13
14
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { reposDir } from "../config.js";
|
|
2
2
|
import { getRepo } from "../store/repos.js";
|
|
3
3
|
import { withKeyedLock } from "../util/keyed_lock.js";
|
|
4
|
-
import { readTextFile } from "../util/runtime.js";
|
|
4
|
+
import { readTextFile, stat } from "../util/runtime.js";
|
|
5
5
|
export function knowledgeLockKey(repo) {
|
|
6
6
|
return `knowledge:${repo}`;
|
|
7
7
|
}
|
|
@@ -13,6 +13,29 @@ async function readGuideFile(repo, name) {
|
|
|
13
13
|
return "";
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/** When the guides were generated, taken from the files themselves when this
|
|
17
|
+
* process has no `repos` row (a plain local `review` never opens app.db).
|
|
18
|
+
* The newest mtime wins, so rewriting any guide file counts as a rebuild. */
|
|
19
|
+
async function guideFilesBuiltAt(repo) {
|
|
20
|
+
const names = [
|
|
21
|
+
"PR_REVIEW_GUIDE.md",
|
|
22
|
+
"PR_REVIEW_DETAILED_GUIDE.md",
|
|
23
|
+
"CODEBASE.md",
|
|
24
|
+
"SKILL.md",
|
|
25
|
+
];
|
|
26
|
+
const times = await Promise.all(names.map(async (name) => {
|
|
27
|
+
try {
|
|
28
|
+
return (await stat(`${reposDir()}/${repo}/${name}`)).mtime;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}));
|
|
34
|
+
const known = times
|
|
35
|
+
.filter((time) => time !== null)
|
|
36
|
+
.map((time) => time.getTime());
|
|
37
|
+
return known.length ? new Date(Math.max(...known)).toISOString() : null;
|
|
38
|
+
}
|
|
16
39
|
/** Reads all review guides and `knowledge_built_at` under one lock so every
|
|
17
40
|
* file belongs to the same generation. */
|
|
18
41
|
export async function loadGuides(repo) {
|
|
@@ -30,6 +53,10 @@ export async function loadGuides(repo) {
|
|
|
30
53
|
catch {
|
|
31
54
|
// Local CLI review without `serve` — guides still load from disk.
|
|
32
55
|
}
|
|
56
|
+
// With no `repos` row, the files are the only evidence of when the guide
|
|
57
|
+
// was built. Without this a local review always says `guide unknown` and
|
|
58
|
+
// can never notice that a rebuild made its carry-over stale (CORE-42).
|
|
59
|
+
guideBuiltAt ??= await guideFilesBuiltAt(repo);
|
|
33
60
|
return {
|
|
34
61
|
shortGuide,
|
|
35
62
|
detailed,
|
|
@@ -6,7 +6,7 @@ export async function handleInstallationsRoute(request, githubApp) {
|
|
|
6
6
|
return errorResponse(404, "not_found", "no route for /api/installations");
|
|
7
7
|
}
|
|
8
8
|
if (!githubApp) {
|
|
9
|
-
return errorResponse(422, "app_not_configured", "GitHub App is not configured
|
|
9
|
+
return errorResponse(422, "app_not_configured", "GitHub App is not configured. Run: co-maintainer set --github-app-id=... --github-app-private-key=...");
|
|
10
10
|
}
|
|
11
11
|
const installations = await listInstallationsWithRepos(githubApp.appId, githubApp.privateKeyPem);
|
|
12
12
|
return Response.json({
|