@open-cr-agent/cli 0.2.0 → 0.4.0
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/README.md +3 -3
- package/dist/cloud.d.ts +32 -0
- package/dist/cloud.js +262 -0
- package/dist/memory.js +2 -2
- package/dist/metrics.d.ts +47 -0
- package/dist/metrics.js +227 -0
- package/dist/review/args.d.ts +9 -2
- package/dist/review/args.js +46 -2
- package/dist/review/cloud-providers.d.ts +12 -0
- package/dist/review/cloud-providers.js +59 -0
- package/dist/review/cloud-upload.d.ts +40 -0
- package/dist/review/cloud-upload.js +135 -0
- package/dist/review/command.d.ts +4 -0
- package/dist/review/command.js +54 -14
- package/dist/review/config.d.ts +66 -4
- package/dist/review/config.js +101 -10
- package/dist/review/plan-render.d.ts +1 -1
- package/dist/review/plan-render.js +1 -1
- package/dist/review/progress.js +1 -1
- package/dist/review/provenance.d.ts +6 -0
- package/dist/review/provenance.js +41 -0
- package/dist/review/remote-config.d.ts +7 -0
- package/dist/review/remote-config.js +12 -8
- package/dist/review/render.d.ts +2 -1
- package/dist/review/render.js +3 -3
- package/dist/review/runtimes.d.ts +5 -0
- package/dist/review/runtimes.js +29 -0
- package/dist/review/sarif-input.d.ts +4 -0
- package/dist/review/sarif-input.js +34 -0
- package/dist/review/sarif.js +9 -3
- package/dist/review/target.d.ts +3 -3
- package/dist/review/target.js +21 -13
- package/dist/review/terminal.js +1 -1
- package/dist/run.js +34 -5
- package/package.json +13 -8
package/dist/review/args.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
-
|
|
2
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
3
|
+
export class UsageError extends OcraError {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super("INPUT_USAGE", message);
|
|
6
|
+
this.name = "UsageError";
|
|
7
|
+
}
|
|
3
8
|
}
|
|
4
9
|
export const REVIEW_USAGE = `Usage: ocra review [options]
|
|
5
10
|
|
|
@@ -20,10 +25,18 @@ Options:
|
|
|
20
25
|
--output <file> Write the result to a file instead of stdout
|
|
21
26
|
--reviewers <ids> Run only these reviewers (comma-separated)
|
|
22
27
|
--max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest
|
|
28
|
+
--import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, …)
|
|
29
|
+
that fall on the change; repeatable; not with --plan
|
|
23
30
|
--plan Show files, bundles, review tasks and prompt sizes; call no model
|
|
24
31
|
--ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)
|
|
32
|
+
--temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)
|
|
33
|
+
--seed <n> Sampling seed, where the runtime and provider support one
|
|
34
|
+
--config <file> Read this configuration file instead of the repository's
|
|
35
|
+
.ocra/config.json; it applies with --no-repo-config too
|
|
25
36
|
--no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
|
|
26
|
-
code); models come from OCRA_MODEL_* variables
|
|
37
|
+
code); models come from OCRA_MODEL_* variables or --config
|
|
38
|
+
--no-upload When signed in to ocra Cloud, do not send this review's
|
|
39
|
+
counts (verdict, numbers of findings, files, tokens)
|
|
27
40
|
-h, --help Show help
|
|
28
41
|
`;
|
|
29
42
|
export function parseReviewArgs(argv) {
|
|
@@ -59,6 +72,8 @@ export function parseReviewArgs(argv) {
|
|
|
59
72
|
args.output = values.output;
|
|
60
73
|
if (values["no-repo-config"])
|
|
61
74
|
args.ignoreRepoConfig = true;
|
|
75
|
+
if (values["no-upload"])
|
|
76
|
+
args.noUpload = true;
|
|
62
77
|
if (values.ultra)
|
|
63
78
|
args.ultra = true;
|
|
64
79
|
if (values.full) {
|
|
@@ -79,6 +94,30 @@ export function parseReviewArgs(argv) {
|
|
|
79
94
|
throw new UsageError(`--max-cost-usd must be a positive number, got ${values["max-cost-usd"]}`);
|
|
80
95
|
args.maxCostUsd = max;
|
|
81
96
|
}
|
|
97
|
+
if (values.temperature !== undefined) {
|
|
98
|
+
const temperature = Number(values.temperature);
|
|
99
|
+
if (values.temperature.trim() === "" || !(temperature >= 0 && temperature <= 2)) {
|
|
100
|
+
throw new UsageError(`--temperature must be a number from 0 to 2, got ${values.temperature}`);
|
|
101
|
+
}
|
|
102
|
+
args.temperature = temperature;
|
|
103
|
+
}
|
|
104
|
+
if (values.seed !== undefined) {
|
|
105
|
+
const seed = Number(values.seed);
|
|
106
|
+
if (!/^\d+$/.test(values.seed) || seed > 2 ** 31 - 1) {
|
|
107
|
+
throw new UsageError(`--seed must be a whole number from 0 to 2147483647, got ${values.seed}`);
|
|
108
|
+
}
|
|
109
|
+
args.seed = seed;
|
|
110
|
+
}
|
|
111
|
+
if (values.config !== undefined) {
|
|
112
|
+
if (values.config === "")
|
|
113
|
+
throw new UsageError("--config needs a file");
|
|
114
|
+
args.configFile = values.config;
|
|
115
|
+
}
|
|
116
|
+
if (values["import-sarif"] !== undefined && values["import-sarif"].length > 0) {
|
|
117
|
+
if (values.plan)
|
|
118
|
+
throw new UsageError("--import-sarif is not used by --plan");
|
|
119
|
+
args.importSarif = values["import-sarif"];
|
|
120
|
+
}
|
|
82
121
|
if (values.reviewers !== undefined) {
|
|
83
122
|
const ids = values.reviewers
|
|
84
123
|
.split(",")
|
|
@@ -102,9 +141,14 @@ function parse(argv) {
|
|
|
102
141
|
format: { type: "string" },
|
|
103
142
|
output: { type: "string" },
|
|
104
143
|
"no-repo-config": { type: "boolean" },
|
|
144
|
+
"no-upload": { type: "boolean" },
|
|
145
|
+
config: { type: "string" },
|
|
105
146
|
reviewers: { type: "string" },
|
|
106
147
|
"max-cost-usd": { type: "string" },
|
|
148
|
+
"import-sarif": { type: "string", multiple: true },
|
|
107
149
|
ultra: { type: "boolean" },
|
|
150
|
+
temperature: { type: "string" },
|
|
151
|
+
seed: { type: "string" },
|
|
108
152
|
full: { type: "boolean" },
|
|
109
153
|
plan: { type: "boolean" },
|
|
110
154
|
pr: { type: "string" },
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CustomProvider, ModelChains } from "@open-cr-agent/core";
|
|
2
|
+
import { type CloudDeps } from "../cloud.js";
|
|
3
|
+
export declare const CLOUD_PREFIX = "ocra-";
|
|
4
|
+
/** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
|
|
5
|
+
export declare const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
|
|
6
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
7
|
+
export declare function withCloudProviders(models: ModelChains, providers: Readonly<Record<string, CustomProvider>>, env: Env, deps: CloudDeps | undefined, warn: (message: string) => void): Promise<{
|
|
8
|
+
providers: Record<string, CustomProvider>;
|
|
9
|
+
env: Env;
|
|
10
|
+
}>;
|
|
11
|
+
export {};
|
|
12
|
+
//# sourceMappingURL=cloud-providers.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
2
|
+
import { cloudSession } from "../cloud.js";
|
|
3
|
+
// Models named ocra-<provider>/<model> go through the ocra Cloud gateway with
|
|
4
|
+
// the key stored there for <provider> (ADR-0024). Each such provider becomes
|
|
5
|
+
// an OpenAI-compatible endpoint declared for this run, authorized by the
|
|
6
|
+
// signed-in session's access token. Without such a model nothing here reads
|
|
7
|
+
// the session or touches the network.
|
|
8
|
+
export const CLOUD_PREFIX = "ocra-";
|
|
9
|
+
/** Where the access token travels to the runtime; never a user's variable (OCRA_ is refused in config). */
|
|
10
|
+
export const CLOUD_TOKEN_ENV = "OCRA_CLOUD_ACCESS_TOKEN";
|
|
11
|
+
// A review may run for half an hour; start it with a token that outlives it.
|
|
12
|
+
const MIN_TOKEN_MS = 35 * 60_000;
|
|
13
|
+
export async function withCloudProviders(models, providers, env, deps, warn) {
|
|
14
|
+
const wanted = new Map();
|
|
15
|
+
for (const model of Object.values(models).flat()) {
|
|
16
|
+
const slash = model.indexOf("/");
|
|
17
|
+
const id = model.slice(0, slash);
|
|
18
|
+
if (slash < 0 || !id.startsWith(CLOUD_PREFIX) || providers[id])
|
|
19
|
+
continue;
|
|
20
|
+
const set = wanted.get(id) ?? new Set();
|
|
21
|
+
set.add(model.slice(slash + 1));
|
|
22
|
+
wanted.set(id, set);
|
|
23
|
+
}
|
|
24
|
+
if (wanted.size === 0)
|
|
25
|
+
return { providers: { ...providers }, env };
|
|
26
|
+
const names = [...wanted.keys()].join(", ");
|
|
27
|
+
if (env.OCRA_CLOUD === "off") {
|
|
28
|
+
throw new OcraError("CONFIG_INVALID", `Models name ocra Cloud (${names}) but OCRA_CLOUD=off`);
|
|
29
|
+
}
|
|
30
|
+
const session = deps && (await cloudSession(deps, MIN_TOKEN_MS));
|
|
31
|
+
if (!session) {
|
|
32
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): sign in with ocra login`);
|
|
33
|
+
}
|
|
34
|
+
const res = await deps.fetch(`${session.server}/api/providers`, {
|
|
35
|
+
signal: AbortSignal.timeout(30_000),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
throw new OcraError("RUNTIME_START_FAILED", `ocra Cloud answered ${res.status} for its providers`);
|
|
39
|
+
}
|
|
40
|
+
const listed = (await res.json()).providers ?? [];
|
|
41
|
+
const declared = { ...providers };
|
|
42
|
+
for (const [id, modelIds] of wanted) {
|
|
43
|
+
const name = id.slice(CLOUD_PREFIX.length);
|
|
44
|
+
const chat = listed
|
|
45
|
+
.find((p) => p.name === name)
|
|
46
|
+
?.paths.find((path) => path.endsWith("/chat/completions"));
|
|
47
|
+
if (!chat) {
|
|
48
|
+
throw new OcraError("CONFIG_INVALID", `ocra Cloud has no OpenAI-compatible chat endpoint for "${name}" (named by ${id})`);
|
|
49
|
+
}
|
|
50
|
+
declared[id] = {
|
|
51
|
+
baseUrl: `${session.server}/api/gateway/${name}${chat.slice(0, -"/chat/completions".length)}`,
|
|
52
|
+
apiKeyEnv: CLOUD_TOKEN_ENV,
|
|
53
|
+
models: Object.fromEntries([...modelIds].map((m) => [m, { input: 0, output: 0 }])),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
warn(`${names}: models through ocra Cloud are not priced, so reported cost and --max-cost-usd do not count them`);
|
|
57
|
+
return { providers: declared, env: { ...env, [CLOUD_TOKEN_ENV]: session.access_token } };
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=cloud-providers.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ModelChains, type ReviewReport } from "@open-cr-agent/core";
|
|
2
|
+
import { type CloudDeps } from "../cloud.js";
|
|
3
|
+
export type ReviewSource = "local" | "github" | "gitlab";
|
|
4
|
+
export type ReviewUpload = {
|
|
5
|
+
runId: string;
|
|
6
|
+
repoHash: string;
|
|
7
|
+
source: ReviewSource;
|
|
8
|
+
tier: string;
|
|
9
|
+
verdict: string;
|
|
10
|
+
complete: boolean;
|
|
11
|
+
findings: {
|
|
12
|
+
critical: number;
|
|
13
|
+
warning: number;
|
|
14
|
+
suggestion: number;
|
|
15
|
+
};
|
|
16
|
+
files: {
|
|
17
|
+
reviewed: number;
|
|
18
|
+
notReviewed: number;
|
|
19
|
+
};
|
|
20
|
+
tasks: {
|
|
21
|
+
completed: number;
|
|
22
|
+
failed: number;
|
|
23
|
+
};
|
|
24
|
+
usage: {
|
|
25
|
+
inputTokens: number;
|
|
26
|
+
outputTokens: number;
|
|
27
|
+
costUsd: number;
|
|
28
|
+
};
|
|
29
|
+
durationMs: number;
|
|
30
|
+
ocraVersion: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function uploadOf(report: ReviewReport, source: ReviewSource, repoHash: string, durationMs: number): ReviewUpload;
|
|
33
|
+
export declare function repoHash(root: string, credentialsPath: string): Promise<string>;
|
|
34
|
+
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
35
|
+
export declare function cloudEnabled(deps: CloudDeps): Promise<boolean>;
|
|
36
|
+
/** Sends the counts; a failure is a warning, never a failed review. */
|
|
37
|
+
export declare function uploadReview(upload: ReviewUpload, deps: CloudDeps, warn: (message: string) => void): Promise<boolean>;
|
|
38
|
+
/** The default models chosen on the web, for a review that configures none. */
|
|
39
|
+
export declare function defaultModels(deps: CloudDeps): Promise<ModelChains>;
|
|
40
|
+
//# sourceMappingURL=cloud-upload.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { coverageGaps } from "@open-cr-agent/core";
|
|
7
|
+
import { cloudSession, readCredentials } from "../cloud.js";
|
|
8
|
+
import { VERSION } from "../version.js";
|
|
9
|
+
// After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
|
|
10
|
+
// the verdict, how many findings of each severity, files and tasks, tokens
|
|
11
|
+
// and time. Never a path, a title, a finding's text or code. The repository
|
|
12
|
+
// is a hash salted with a value that stays on this machine, so the server
|
|
13
|
+
// can group reviews of one repository without learning which it is.
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
export function uploadOf(report, source, repoHash, durationMs) {
|
|
16
|
+
const severity = (s) => report.findings.filter((f) => f.severity === s).length;
|
|
17
|
+
const gaps = coverageGaps(report);
|
|
18
|
+
return {
|
|
19
|
+
runId: report.runId,
|
|
20
|
+
repoHash,
|
|
21
|
+
source,
|
|
22
|
+
tier: report.tier,
|
|
23
|
+
verdict: report.verdict,
|
|
24
|
+
complete: gaps.notReviewed === 0,
|
|
25
|
+
findings: {
|
|
26
|
+
critical: severity("critical"),
|
|
27
|
+
warning: severity("warning"),
|
|
28
|
+
suggestion: severity("suggestion"),
|
|
29
|
+
},
|
|
30
|
+
files: {
|
|
31
|
+
reviewed: report.coverage.filter((c) => c.status === "reviewed").length,
|
|
32
|
+
notReviewed: gaps.notReviewed,
|
|
33
|
+
},
|
|
34
|
+
tasks: {
|
|
35
|
+
completed: report.tasks.filter((t) => t.status === "completed").length,
|
|
36
|
+
failed: report.tasks.filter((t) => t.status === "failed").length,
|
|
37
|
+
},
|
|
38
|
+
usage: {
|
|
39
|
+
inputTokens: report.usage.inputTokens,
|
|
40
|
+
outputTokens: report.usage.outputTokens,
|
|
41
|
+
costUsd: report.usage.costUsd ?? 0,
|
|
42
|
+
},
|
|
43
|
+
durationMs: Math.round(durationMs),
|
|
44
|
+
ocraVersion: VERSION,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** The repository's origin without credentials, or its root path when it has none. */
|
|
48
|
+
async function repositoryId(root) {
|
|
49
|
+
try {
|
|
50
|
+
const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], {
|
|
51
|
+
timeout: 10_000,
|
|
52
|
+
});
|
|
53
|
+
const url = stdout.trim();
|
|
54
|
+
return url
|
|
55
|
+
.replace(/^[a-z+]+:\/\/[^@/]*@/i, (m) => m.slice(0, m.indexOf("//") + 2))
|
|
56
|
+
.replace(/\.git$/, "")
|
|
57
|
+
.toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** A random salt kept beside the credentials, made on first use. */
|
|
64
|
+
async function salt(credentialsPath) {
|
|
65
|
+
const path = join(dirname(credentialsPath), "upload-salt");
|
|
66
|
+
try {
|
|
67
|
+
const existing = (await readFile(path, "utf8")).trim();
|
|
68
|
+
if (/^[0-9a-f]{64}$/.test(existing))
|
|
69
|
+
return existing;
|
|
70
|
+
}
|
|
71
|
+
catch { }
|
|
72
|
+
const fresh = randomBytes(32).toString("hex");
|
|
73
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
74
|
+
await writeFile(path, `${fresh}\n`, { mode: 0o600 });
|
|
75
|
+
return fresh;
|
|
76
|
+
}
|
|
77
|
+
export async function repoHash(root, credentialsPath) {
|
|
78
|
+
return createHash("sha256")
|
|
79
|
+
.update(`${await salt(credentialsPath)}:${await repositoryId(root)}`)
|
|
80
|
+
.digest("hex");
|
|
81
|
+
}
|
|
82
|
+
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
83
|
+
export async function cloudEnabled(deps) {
|
|
84
|
+
return (deps.env.OCRA_CLOUD !== "off" && (await readCredentials(deps.credentialsPath)) !== undefined);
|
|
85
|
+
}
|
|
86
|
+
/** Sends the counts; a failure is a warning, never a failed review. */
|
|
87
|
+
export async function uploadReview(upload, deps, warn) {
|
|
88
|
+
try {
|
|
89
|
+
const session = await cloudSession(deps);
|
|
90
|
+
if (!session)
|
|
91
|
+
return false;
|
|
92
|
+
const res = await deps.fetch(`${session.server}/api/reviews`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
authorization: `Bearer ${session.access_token}`,
|
|
97
|
+
"user-agent": `ocra/${VERSION}`,
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(upload),
|
|
100
|
+
signal: AbortSignal.timeout(15_000),
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
warn(`could not send the review's counts to ocra Cloud: ${error instanceof Error ? error.name : "error"}`);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** The default models chosen on the web, for a review that configures none. */
|
|
114
|
+
export async function defaultModels(deps) {
|
|
115
|
+
const session = await cloudSession(deps);
|
|
116
|
+
if (!session)
|
|
117
|
+
return {};
|
|
118
|
+
const res = await deps.fetch(`${session.server}/api/preferences/models`, {
|
|
119
|
+
headers: { authorization: `Bearer ${session.access_token}`, "user-agent": `ocra/${VERSION}` },
|
|
120
|
+
signal: AbortSignal.timeout(15_000),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
return {};
|
|
124
|
+
const body = (await res.json());
|
|
125
|
+
const chains = {};
|
|
126
|
+
for (const tier of ["top", "standard", "light"]) {
|
|
127
|
+
const list = body.models?.[tier];
|
|
128
|
+
if (Array.isArray(list) && list.every((m) => typeof m === "string" && m.startsWith("ocra-"))) {
|
|
129
|
+
if (list.length > 0)
|
|
130
|
+
chains[tier] = list;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return chains;
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=cloud-upload.js.map
|
package/dist/review/command.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
|
+
import type { CloudDeps } from "../cloud.js";
|
|
2
3
|
import type { ReviewArgs } from "./args.js";
|
|
3
4
|
import { type Output } from "./progress.js";
|
|
5
|
+
import type { RuntimeLoaders } from "./runtimes.js";
|
|
4
6
|
export declare const SESSIONS_DIR = ".ocra/sessions";
|
|
5
7
|
export declare const EXIT: {
|
|
6
8
|
readonly ok: 0;
|
|
@@ -14,10 +16,12 @@ export interface ReviewDeps {
|
|
|
14
16
|
cwd: string;
|
|
15
17
|
env: Readonly<Record<string, string | undefined>>;
|
|
16
18
|
builtinPlugins: readonly OcraPlugin[];
|
|
19
|
+
runtimes: RuntimeLoaders;
|
|
17
20
|
writeFile(path: string, content: string): Promise<void>;
|
|
18
21
|
now(): number;
|
|
19
22
|
heartbeatMs: number;
|
|
20
23
|
fetch?: typeof fetch;
|
|
24
|
+
cloud?: CloudDeps;
|
|
21
25
|
onInterrupt?(handler: () => void): () => void;
|
|
22
26
|
}
|
|
23
27
|
export declare function reviewCommand(args: ReviewArgs, io: {
|
package/dist/review/command.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { join, relative, resolve } from "node:path";
|
|
2
|
-
import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps,
|
|
3
|
-
import {
|
|
2
|
+
import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, docsReviewerPlugin, performanceReviewerPlugin, review, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, } from "@open-cr-agent/core";
|
|
3
|
+
import { defaultSelectionPolicy, newRunId, previewReview, toPlanOutput, } from "@open-cr-agent/core/internal";
|
|
4
4
|
import { githubPlugin } from "@open-cr-agent/vcs-github";
|
|
5
5
|
import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
|
|
6
|
-
import {
|
|
6
|
+
import { localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
7
|
+
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
7
8
|
import { VERSION } from "../version.js";
|
|
9
|
+
import { withCloudProviders } from "./cloud-providers.js";
|
|
10
|
+
import { cloudEnabled, defaultModels, repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
|
|
8
11
|
import { ConfigError } from "./config.js";
|
|
9
12
|
import { renderPlan } from "./plan-render.js";
|
|
10
13
|
import { ProgressPrinter } from "./progress.js";
|
|
14
|
+
import { configHash, requestedSampling } from "./provenance.js";
|
|
11
15
|
import { renderJson, renderText, safeJson } from "./render.js";
|
|
12
16
|
import { renderSarif } from "./sarif.js";
|
|
17
|
+
import { loadSarifLogs } from "./sarif-input.js";
|
|
13
18
|
import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
|
|
14
19
|
import { forTerminal } from "./terminal.js";
|
|
15
20
|
export const SESSIONS_DIR = ".ocra/sessions";
|
|
@@ -18,7 +23,6 @@ export const BUILTIN_PLUGINS = [
|
|
|
18
23
|
localGitPlugin,
|
|
19
24
|
githubPlugin,
|
|
20
25
|
gitlabPlugin,
|
|
21
|
-
opencodeRuntimePlugin,
|
|
22
26
|
correctnessReviewerPlugin,
|
|
23
27
|
securityReviewerPlugin,
|
|
24
28
|
performanceReviewerPlugin,
|
|
@@ -30,17 +34,20 @@ export async function reviewCommand(args, io, deps) {
|
|
|
30
34
|
const root = await findRepositoryRoot(deps.cwd);
|
|
31
35
|
const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
|
|
32
36
|
const ignoreRepoConfig = args.ignoreRepoConfig === true;
|
|
37
|
+
const configFile = args.configFile ? resolve(deps.cwd, args.configFile) : undefined;
|
|
38
|
+
const localArgs = configFile ? { ...args, configFile } : args;
|
|
33
39
|
const target = args.pullRequest
|
|
34
|
-
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
|
|
40
|
+
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
35
41
|
: args.mergeRequest
|
|
36
|
-
? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
|
|
37
|
-
: await localTarget(
|
|
42
|
+
? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
43
|
+
: await localTarget(localArgs, deps.cwd, root, deps.env, warn, deps.fetch);
|
|
38
44
|
const { config } = target;
|
|
39
|
-
const session = { dir: join(root, SESSIONS_DIR), id:
|
|
40
|
-
// A plan calls no model
|
|
45
|
+
const session = { dir: join(root, SESSIONS_DIR), id: newRunId() };
|
|
46
|
+
// A plan calls no model, writes no session log and imports no runtime: it
|
|
47
|
+
// works without the optional runtime-opencode.
|
|
41
48
|
const builtins = args.plan
|
|
42
49
|
? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
|
|
43
|
-
: deps.builtinPlugins;
|
|
50
|
+
: [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
|
|
44
51
|
const registry = await startPlugins([...builtins, ...target.plugins], {
|
|
45
52
|
settings: args.plan
|
|
46
53
|
? config.pluginSettings
|
|
@@ -55,6 +62,8 @@ export async function reviewCommand(args, io, deps) {
|
|
|
55
62
|
vcs,
|
|
56
63
|
reviewers: registry.reviewers,
|
|
57
64
|
reviewerOverrides: overrides,
|
|
65
|
+
effort: config.effort,
|
|
66
|
+
roles: config.roles,
|
|
58
67
|
rules: [...registry.rules, ...config.rules],
|
|
59
68
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
60
69
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
@@ -68,10 +77,23 @@ export async function reviewCommand(args, io, deps) {
|
|
|
68
77
|
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
69
78
|
return EXIT.ok;
|
|
70
79
|
}
|
|
80
|
+
const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
|
|
81
|
+
const sampling = requestedSampling(config, args);
|
|
82
|
+
const cloudDeps = deps.cloud;
|
|
83
|
+
const signedIn = cloudDeps !== undefined && (await cloudEnabled(cloudDeps));
|
|
84
|
+
let models = config.models;
|
|
85
|
+
if (cloudDeps && signedIn && Object.values(models).every((chain) => !chain?.length)) {
|
|
86
|
+
models = await defaultModels(cloudDeps);
|
|
87
|
+
if (Object.keys(models).length > 0) {
|
|
88
|
+
io.err.write("[ocra] No models configured: using your default models from ocra Cloud\n");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const cloud = await withCloudProviders(models, config.providers, deps.env, cloudDeps, warn);
|
|
71
92
|
const runtime = registry.createRuntime(config.runtime, {
|
|
72
|
-
models
|
|
73
|
-
env:
|
|
74
|
-
providers:
|
|
93
|
+
models,
|
|
94
|
+
env: cloud.env,
|
|
95
|
+
providers: cloud.providers,
|
|
96
|
+
...(Object.keys(sampling).length > 0 ? { sampling } : {}),
|
|
75
97
|
});
|
|
76
98
|
const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
|
|
77
99
|
const interrupt = new AbortController();
|
|
@@ -80,14 +102,18 @@ export async function reviewCommand(args, io, deps) {
|
|
|
80
102
|
interrupt.abort();
|
|
81
103
|
});
|
|
82
104
|
let report;
|
|
105
|
+
const started = deps.now();
|
|
83
106
|
try {
|
|
84
|
-
report = await
|
|
107
|
+
report = await review({
|
|
108
|
+
runId: session.id,
|
|
85
109
|
signal: interrupt.signal,
|
|
86
110
|
...runOptions(config),
|
|
87
111
|
reviewerOverrides: overrides,
|
|
112
|
+
provenance: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
|
|
88
113
|
...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
89
114
|
...(args.ultra ? { ultra: true } : {}),
|
|
90
115
|
...(args.full ? { fullReview: true } : {}),
|
|
116
|
+
...(sarif.length > 0 ? { sarif } : {}),
|
|
91
117
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
92
118
|
vcs,
|
|
93
119
|
runtime,
|
|
@@ -135,11 +161,25 @@ export async function reviewCommand(args, io, deps) {
|
|
|
135
161
|
}
|
|
136
162
|
io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
|
|
137
163
|
}
|
|
164
|
+
if (signedIn && cloudDeps && !args.noUpload) {
|
|
165
|
+
const source = args.pullRequest ? "github" : args.mergeRequest ? "gitlab" : "local";
|
|
166
|
+
const upload = uploadOf(report, source, await repoHash(root, cloudDeps.credentialsPath), deps.now() - started);
|
|
167
|
+
if (await uploadReview(upload, cloudDeps, warn)) {
|
|
168
|
+
io.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
138
171
|
return exitCode(report, io.err);
|
|
139
172
|
}
|
|
173
|
+
// A runtime not built in comes from a plugin of the configuration.
|
|
174
|
+
async function builtinRuntime(runtimes, name) {
|
|
175
|
+
const load = Object.hasOwn(runtimes, name) ? runtimes[name] : undefined;
|
|
176
|
+
return load ? [await load()] : [];
|
|
177
|
+
}
|
|
140
178
|
function runOptions(config) {
|
|
141
179
|
const options = {
|
|
142
180
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
181
|
+
effort: config.effort,
|
|
182
|
+
roles: config.roles,
|
|
143
183
|
};
|
|
144
184
|
if (config.concurrency !== undefined)
|
|
145
185
|
options.concurrency = config.concurrency;
|
package/dist/review/config.d.ts
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
|
-
import { type CustomProvider, type ModelChains, type RepoRule } from "@open-cr-agent/core";
|
|
1
|
+
import { type CustomProvider, type ModelChains, OcraError, type RepoRule, type TierEfforts } from "@open-cr-agent/core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export declare const CONFIG_PATH = ".ocra/config.json";
|
|
4
4
|
declare const configSchema: z.ZodObject<{
|
|
5
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
5
6
|
models: z.ZodDefault<z.ZodObject<{
|
|
6
7
|
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
7
8
|
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
8
9
|
light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
9
10
|
}, z.core.$strict>>;
|
|
11
|
+
effort: z.ZodDefault<z.ZodObject<{
|
|
12
|
+
top: z.ZodOptional<z.ZodEnum<{
|
|
13
|
+
high: "high";
|
|
14
|
+
low: "low";
|
|
15
|
+
medium: "medium";
|
|
16
|
+
minimal: "minimal";
|
|
17
|
+
none: "none";
|
|
18
|
+
}>>;
|
|
19
|
+
standard: z.ZodOptional<z.ZodEnum<{
|
|
20
|
+
high: "high";
|
|
21
|
+
low: "low";
|
|
22
|
+
medium: "medium";
|
|
23
|
+
minimal: "minimal";
|
|
24
|
+
none: "none";
|
|
25
|
+
}>>;
|
|
26
|
+
light: z.ZodOptional<z.ZodEnum<{
|
|
27
|
+
high: "high";
|
|
28
|
+
low: "low";
|
|
29
|
+
medium: "medium";
|
|
30
|
+
minimal: "minimal";
|
|
31
|
+
none: "none";
|
|
32
|
+
}>>;
|
|
33
|
+
}, z.core.$strict>>;
|
|
10
34
|
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
11
35
|
taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
12
36
|
runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
@@ -14,13 +38,20 @@ declare const configSchema: z.ZodObject<{
|
|
|
14
38
|
judge: z.ZodOptional<z.ZodBoolean>;
|
|
15
39
|
maxCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
16
40
|
maxTasks: z.ZodOptional<z.ZodNumber>;
|
|
41
|
+
sampling: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
43
|
+
seed: z.ZodOptional<z.ZodNumber>;
|
|
44
|
+
}, z.core.$strict>>;
|
|
17
45
|
github: z.ZodDefault<z.ZodObject<{
|
|
18
46
|
requestChanges: z.ZodOptional<z.ZodBoolean>;
|
|
19
47
|
botLogin: z.ZodOptional<z.ZodString>;
|
|
20
48
|
}, z.core.$strict>>;
|
|
21
49
|
include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
22
50
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
23
|
-
runtime: z.ZodDefault<z.
|
|
51
|
+
runtime: z.ZodDefault<z.ZodUnion<[z.ZodEnum<{
|
|
52
|
+
direct: "direct";
|
|
53
|
+
opencode: "opencode";
|
|
54
|
+
}>, z.ZodString]>>;
|
|
24
55
|
plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
25
56
|
reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
26
57
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -29,6 +60,26 @@ declare const configSchema: z.ZodObject<{
|
|
|
29
60
|
lite: "lite";
|
|
30
61
|
trivial: "trivial";
|
|
31
62
|
}>>;
|
|
63
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
64
|
+
high: "high";
|
|
65
|
+
low: "low";
|
|
66
|
+
medium: "medium";
|
|
67
|
+
minimal: "minimal";
|
|
68
|
+
none: "none";
|
|
69
|
+
}>>;
|
|
70
|
+
}, z.core.$strict>>>;
|
|
71
|
+
roles: z.ZodDefault<z.ZodRecord<z.ZodEnum<{
|
|
72
|
+
helper: "helper";
|
|
73
|
+
judge: "judge";
|
|
74
|
+
verifier: "verifier";
|
|
75
|
+
}> & z.core.$partial, z.ZodObject<{
|
|
76
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
77
|
+
high: "high";
|
|
78
|
+
low: "low";
|
|
79
|
+
medium: "medium";
|
|
80
|
+
minimal: "minimal";
|
|
81
|
+
none: "none";
|
|
82
|
+
}>>;
|
|
32
83
|
}, z.core.$strict>>>;
|
|
33
84
|
pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
34
85
|
providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -40,21 +91,32 @@ declare const configSchema: z.ZodObject<{
|
|
|
40
91
|
output: z.ZodNumber;
|
|
41
92
|
cachedInput: z.ZodOptional<z.ZodNumber>;
|
|
42
93
|
}, z.core.$strict>>;
|
|
94
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
95
|
+
openai: "openai";
|
|
96
|
+
openrouter: "openrouter";
|
|
97
|
+
}>>;
|
|
43
98
|
}, z.core.$strict>>>;
|
|
44
99
|
extends: z.ZodOptional<z.ZodString>;
|
|
45
100
|
}, z.core.$strict>;
|
|
46
|
-
export
|
|
101
|
+
export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
102
|
+
export declare function configJsonSchema(): Record<string, unknown>;
|
|
103
|
+
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers"> & {
|
|
47
104
|
models: ModelChains;
|
|
105
|
+
effort: TierEfforts;
|
|
48
106
|
providers: Record<string, CustomProvider>;
|
|
49
107
|
rules: RepoRule[];
|
|
50
108
|
};
|
|
51
109
|
export interface LoadOptions {
|
|
52
110
|
repository: boolean;
|
|
53
111
|
read?: (path: string) => Promise<string | undefined>;
|
|
112
|
+
file?: string;
|
|
54
113
|
fetch?: typeof fetch;
|
|
55
114
|
warn?: (message: string) => void;
|
|
56
115
|
}
|
|
57
|
-
export declare class ConfigError extends
|
|
116
|
+
export declare class ConfigError extends OcraError {
|
|
117
|
+
constructor(message: string, options?: {
|
|
118
|
+
cause?: unknown;
|
|
119
|
+
});
|
|
58
120
|
}
|
|
59
121
|
export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
|
|
60
122
|
export {};
|