@open-cr-agent/cli 0.3.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/review/args.d.ts +2 -1
- package/dist/review/args.js +5 -0
- 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 +2 -0
- package/dist/review/command.js +27 -3
- package/dist/review/config.d.ts +50 -2
- package/dist/review/config.js +42 -2
- package/dist/review/plan-render.js +1 -1
- package/dist/review/provenance.js +5 -2
- package/dist/review/remote-config.d.ts +6 -0
- package/dist/review/remote-config.js +3 -1
- package/dist/review/sarif.js +1 -1
- package/dist/review/target.js +1 -1
- package/dist/run.js +17 -0
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`ocra` is the command line of [Open-CR-Agent](https://github.com/jma49/Open-CR-Agent), an open-source multi-agent code reviewer for local changes and GitHub pull requests. Deterministic code selects and bundles the files, matches rules and anchors comments; LLM reviewers make the judgment calls, and a verification pass checks what they report.
|
|
4
4
|
|
|
5
|
-
This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://
|
|
5
|
+
This is an early 0.x release: options and output may change between minor versions. What the reviews find and miss is measured on the [quality page](https://ocracloud.com/en/docs/quality).
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -34,8 +34,8 @@ Pull requests can also be reviewed by the GitHub Action: inline comments, one su
|
|
|
34
34
|
|
|
35
35
|
## Documentation
|
|
36
36
|
|
|
37
|
-
- [Quickstart](https://
|
|
38
|
-
- [GitHub pull requests](https://
|
|
37
|
+
- [Quickstart](https://ocracloud.com/en/docs/quickstart) and the manual, in [English](https://ocracloud.com/en/docs) and [中文](https://ocracloud.com/zh/docs)
|
|
38
|
+
- [GitHub pull requests](https://ocracloud.com/en/docs/github)
|
|
39
39
|
- [Changelog](https://github.com/jma49/Open-CR-Agent/blob/main/CHANGELOG.md) and [issues](https://github.com/jma49/Open-CR-Agent/issues)
|
|
40
40
|
|
|
41
41
|
## License
|
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Output } from "./review/progress.js";
|
|
2
|
+
/** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
|
|
3
|
+
export declare const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
|
|
4
|
+
export declare const LOGIN_USAGE: string;
|
|
5
|
+
export type Credentials = {
|
|
6
|
+
server: string;
|
|
7
|
+
login: string;
|
|
8
|
+
access_token: string;
|
|
9
|
+
refresh_token: string;
|
|
10
|
+
/** Epoch milliseconds when the access token expires. */
|
|
11
|
+
expires_at: number;
|
|
12
|
+
};
|
|
13
|
+
export type CloudDeps = {
|
|
14
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
15
|
+
fetch: typeof fetch;
|
|
16
|
+
now: () => number;
|
|
17
|
+
sleep: (ms: number) => Promise<void>;
|
|
18
|
+
openBrowser: (url: string) => void;
|
|
19
|
+
credentialsPath: string;
|
|
20
|
+
clientName: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function defaultCloudDeps(env?: CloudDeps["env"]): CloudDeps;
|
|
23
|
+
export declare function credentialsPath(env: CloudDeps["env"]): string;
|
|
24
|
+
export declare function readCredentials(path: string): Promise<Credentials | undefined>;
|
|
25
|
+
/**
|
|
26
|
+
* The saved session with a live access token, refreshed (and saved) when
|
|
27
|
+
* it has expired or is about to; undefined when not signed in or the
|
|
28
|
+
* session was revoked.
|
|
29
|
+
*/
|
|
30
|
+
export declare function cloudSession(deps: CloudDeps, minValidityMs?: number): Promise<Credentials | undefined>;
|
|
31
|
+
export declare function cloudCommand(command: "login" | "logout" | "whoami", argv: string[], out: Output, err: Output, deps: CloudDeps): Promise<number>;
|
|
32
|
+
//# sourceMappingURL=cloud.d.ts.map
|
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { errorMessage } from "@open-cr-agent/core/internal";
|
|
7
|
+
import { UsageError } from "./review/args.js";
|
|
8
|
+
import { forTerminal } from "./review/terminal.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
// ocra Cloud sign-in (ADR-0024): `ocra login` runs the OAuth device flow
|
|
11
|
+
// (RFC 8628) against ocra Cloud, `ocra whoami` shows the account, and
|
|
12
|
+
// `ocra logout` ends the session. Nothing here runs unless one of these
|
|
13
|
+
// commands is used: a self-hosted user never sees a login prompt.
|
|
14
|
+
/** ocra Cloud's address; OCRA_CLOUD_URL names another server. */
|
|
15
|
+
export const DEFAULT_CLOUD_URL = "https://app.ocracloud.com";
|
|
16
|
+
export const LOGIN_USAGE = `Usage: ocra login [--no-browser]
|
|
17
|
+
ocra logout
|
|
18
|
+
ocra whoami
|
|
19
|
+
|
|
20
|
+
Sign in to ocra Cloud with GitHub, so reviews can use the model keys stored
|
|
21
|
+
there. ocra login shows a code, opens the browser to confirm it, and saves the
|
|
22
|
+
session to ${credentialsHint()} (readable only by you).
|
|
23
|
+
|
|
24
|
+
Environment:
|
|
25
|
+
OCRA_CLOUD=off Disable ocra Cloud: these commands refuse to run
|
|
26
|
+
OCRA_CLOUD_URL Another ocra Cloud server (default ${DEFAULT_CLOUD_URL})
|
|
27
|
+
`;
|
|
28
|
+
export function defaultCloudDeps(env = process.env) {
|
|
29
|
+
return {
|
|
30
|
+
env,
|
|
31
|
+
fetch: globalThis.fetch,
|
|
32
|
+
now: Date.now,
|
|
33
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
34
|
+
openBrowser,
|
|
35
|
+
credentialsPath: credentialsPath(env),
|
|
36
|
+
clientName: `${hostname()} (ocra ${VERSION})`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function credentialsHint() {
|
|
40
|
+
return process.platform === "win32"
|
|
41
|
+
? "%APPDATA%\\ocra\\credentials.json"
|
|
42
|
+
: "~/.config/ocra/credentials.json";
|
|
43
|
+
}
|
|
44
|
+
export function credentialsPath(env) {
|
|
45
|
+
const base = process.platform === "win32"
|
|
46
|
+
? (env.APPDATA ?? join(homedir(), "AppData", "Roaming"))
|
|
47
|
+
: (env.XDG_CONFIG_HOME ?? join(homedir(), ".config"));
|
|
48
|
+
return join(base, "ocra", "credentials.json");
|
|
49
|
+
}
|
|
50
|
+
function cloudUrl(env) {
|
|
51
|
+
const named = env.OCRA_CLOUD_URL || DEFAULT_CLOUD_URL;
|
|
52
|
+
let url;
|
|
53
|
+
try {
|
|
54
|
+
url = new URL(named);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new UsageError(`OCRA_CLOUD_URL is not a URL: ${named}`);
|
|
58
|
+
}
|
|
59
|
+
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
60
|
+
throw new UsageError(`OCRA_CLOUD_URL must use https: ${url}`);
|
|
61
|
+
}
|
|
62
|
+
return url.origin;
|
|
63
|
+
}
|
|
64
|
+
function openBrowser(url) {
|
|
65
|
+
const [cmd, args] = process.platform === "darwin"
|
|
66
|
+
? ["open", [url]]
|
|
67
|
+
: process.platform === "win32"
|
|
68
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
69
|
+
: ["xdg-open", [url]];
|
|
70
|
+
try {
|
|
71
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
72
|
+
child.on("error", () => { });
|
|
73
|
+
child.unref();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// The URL is printed as well; a missing opener is not an error.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function readCredentials(path) {
|
|
80
|
+
let text;
|
|
81
|
+
try {
|
|
82
|
+
text = await readFile(path, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const c = JSON.parse(text);
|
|
88
|
+
if (!c.server || !c.access_token || !c.refresh_token || typeof c.expires_at !== "number") {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return c;
|
|
92
|
+
}
|
|
93
|
+
async function writeCredentials(path, c) {
|
|
94
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
95
|
+
await writeFile(path, `${JSON.stringify(c, null, 2)}\n`, { mode: 0o600 });
|
|
96
|
+
// writeFile keeps the mode of a file that already exists.
|
|
97
|
+
await chmod(path, 0o600);
|
|
98
|
+
}
|
|
99
|
+
async function postJson(deps, url, body, token) {
|
|
100
|
+
const res = await deps.fetch(url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
"content-type": "application/json",
|
|
104
|
+
"user-agent": `ocra/${VERSION}`,
|
|
105
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
signal: AbortSignal.timeout(30_000),
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
status: res.status,
|
|
112
|
+
body: (await res.json().catch(() => ({}))),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function fetchMe(deps, server, token) {
|
|
116
|
+
const res = await deps.fetch(`${server}/api/me`, {
|
|
117
|
+
headers: { authorization: `Bearer ${token}`, "user-agent": `ocra/${VERSION}` },
|
|
118
|
+
signal: AbortSignal.timeout(30_000),
|
|
119
|
+
});
|
|
120
|
+
if (res.status === 401)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (!res.ok)
|
|
123
|
+
throw new Error(`ocra Cloud answered ${res.status}`);
|
|
124
|
+
return (await res.json());
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The saved session with a live access token, refreshed (and saved) when
|
|
128
|
+
* it has expired or is about to; undefined when not signed in or the
|
|
129
|
+
* session was revoked.
|
|
130
|
+
*/
|
|
131
|
+
export async function cloudSession(deps, minValidityMs = 60_000) {
|
|
132
|
+
const saved = await readCredentials(deps.credentialsPath);
|
|
133
|
+
if (!saved)
|
|
134
|
+
return undefined;
|
|
135
|
+
if (saved.expires_at - deps.now() > minValidityMs)
|
|
136
|
+
return saved;
|
|
137
|
+
const { status, body } = await postJson(deps, `${saved.server}/api/device/refresh`, {
|
|
138
|
+
refresh_token: saved.refresh_token,
|
|
139
|
+
});
|
|
140
|
+
const t = body;
|
|
141
|
+
if (status !== 200 || !t.access_token || !t.refresh_token || !t.expires_in)
|
|
142
|
+
return undefined;
|
|
143
|
+
const next = {
|
|
144
|
+
...saved,
|
|
145
|
+
access_token: t.access_token,
|
|
146
|
+
refresh_token: t.refresh_token,
|
|
147
|
+
expires_at: deps.now() + t.expires_in * 1000,
|
|
148
|
+
};
|
|
149
|
+
await writeCredentials(deps.credentialsPath, next);
|
|
150
|
+
return next;
|
|
151
|
+
}
|
|
152
|
+
export async function cloudCommand(command, argv, out, err, deps) {
|
|
153
|
+
const { values } = parseArgs({
|
|
154
|
+
args: argv,
|
|
155
|
+
strict: true,
|
|
156
|
+
options: { "no-browser": { type: "boolean" }, help: { type: "boolean", short: "h" } },
|
|
157
|
+
});
|
|
158
|
+
if (values.help) {
|
|
159
|
+
out.write(LOGIN_USAGE);
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
if (deps.env.OCRA_CLOUD === "off") {
|
|
163
|
+
err.write("ocra Cloud is off (OCRA_CLOUD=off).\n");
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
if (command === "whoami")
|
|
167
|
+
return whoami(out, err, deps);
|
|
168
|
+
if (command === "logout")
|
|
169
|
+
return logout(out, deps);
|
|
170
|
+
return login(out, err, deps, !values["no-browser"]);
|
|
171
|
+
}
|
|
172
|
+
async function whoami(out, err, deps) {
|
|
173
|
+
const session = await cloudSession(deps);
|
|
174
|
+
if (!session) {
|
|
175
|
+
err.write("Not signed in. Run ocra login.\n");
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
const me = await fetchMe(deps, session.server, session.access_token);
|
|
179
|
+
if (!me) {
|
|
180
|
+
err.write("The saved session was revoked. Run ocra login.\n");
|
|
181
|
+
return 1;
|
|
182
|
+
}
|
|
183
|
+
out.write(forTerminal(`${me.login} on ${session.server}\n`));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
async function logout(out, deps) {
|
|
187
|
+
const saved = await readCredentials(deps.credentialsPath);
|
|
188
|
+
if (saved) {
|
|
189
|
+
// Ends the session on the server too; the local file goes either way.
|
|
190
|
+
const session = await cloudSession(deps).catch(() => undefined);
|
|
191
|
+
if (session) {
|
|
192
|
+
await postJson(deps, `${session.server}/api/auth/logout`, {}, session.access_token).catch(() => { });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await rm(deps.credentialsPath, { force: true });
|
|
196
|
+
out.write(saved ? "Signed out.\n" : "Not signed in.\n");
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
async function login(out, err, deps, browser) {
|
|
200
|
+
const server = cloudUrl(deps.env);
|
|
201
|
+
const start = await postJson(deps, `${server}/api/device/code`, { client_name: deps.clientName });
|
|
202
|
+
const code = start.body;
|
|
203
|
+
if (start.status !== 200 ||
|
|
204
|
+
!code.device_code ||
|
|
205
|
+
!code.user_code ||
|
|
206
|
+
!code.verification_uri_complete) {
|
|
207
|
+
err.write(`ocra Cloud did not start a login (HTTP ${start.status}). Try again in a minute.\n`);
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
out.write(forTerminal(`Confirm this code in your browser: ${code.user_code}\n` +
|
|
211
|
+
` ${code.verification_uri_complete}\n` +
|
|
212
|
+
`(or open ${code.verification_uri} and type it)\n\nWaiting for approval...\n`));
|
|
213
|
+
if (browser)
|
|
214
|
+
deps.openBrowser(code.verification_uri_complete);
|
|
215
|
+
let interval = (code.interval ?? 5) * 1000;
|
|
216
|
+
const deadline = deps.now() + (code.expires_in ?? 600) * 1000;
|
|
217
|
+
while (deps.now() < deadline) {
|
|
218
|
+
await deps.sleep(interval);
|
|
219
|
+
let answer;
|
|
220
|
+
try {
|
|
221
|
+
answer = await postJson(deps, `${server}/api/device/token`, {
|
|
222
|
+
device_code: code.device_code,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
// A dropped connection is retried on the next tick.
|
|
227
|
+
err.write(forTerminal(`(${errorMessage(error)}; retrying)\n`));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const t = answer.body;
|
|
231
|
+
if (answer.status === 200 && t.access_token && t.refresh_token && t.expires_in) {
|
|
232
|
+
const me = await fetchMe(deps, server, t.access_token);
|
|
233
|
+
const credentials = {
|
|
234
|
+
server,
|
|
235
|
+
login: me?.login ?? "",
|
|
236
|
+
access_token: t.access_token,
|
|
237
|
+
refresh_token: t.refresh_token,
|
|
238
|
+
expires_at: deps.now() + t.expires_in * 1000,
|
|
239
|
+
};
|
|
240
|
+
await writeCredentials(deps.credentialsPath, credentials);
|
|
241
|
+
out.write(forTerminal(`Signed in as ${credentials.login}.\n`));
|
|
242
|
+
return 0;
|
|
243
|
+
}
|
|
244
|
+
if (t.error === "authorization_pending")
|
|
245
|
+
continue;
|
|
246
|
+
if (t.error === "slow_down") {
|
|
247
|
+
interval = typeof t.interval === "number" ? t.interval * 1000 : interval + 5000;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (t.error === "access_denied") {
|
|
251
|
+
err.write("Login denied in the browser.\n");
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
if (t.error === "expired_token")
|
|
255
|
+
break;
|
|
256
|
+
err.write(forTerminal(`Login failed: ${t.error ?? `HTTP ${answer.status}`}\n`));
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
err.write("The code expired before it was confirmed. Run ocra login again.\n");
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=cloud.js.map
|
package/dist/review/args.d.ts
CHANGED
|
@@ -25,12 +25,13 @@ export interface ReviewArgs {
|
|
|
25
25
|
plan?: true;
|
|
26
26
|
importSarif?: string[];
|
|
27
27
|
configFile?: string;
|
|
28
|
+
noUpload?: true;
|
|
28
29
|
temperature?: number;
|
|
29
30
|
seed?: number;
|
|
30
31
|
}
|
|
31
32
|
export declare class UsageError extends OcraError {
|
|
32
33
|
constructor(message: string);
|
|
33
34
|
}
|
|
34
|
-
export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n -h, --help Show help\n";
|
|
35
|
+
export declare const REVIEW_USAGE = "Usage: ocra review [options]\n\nReviews uncommitted changes by default.\n\nOptions:\n --from <ref> Review changes on --to since it diverged from <ref>\n --to <ref> End of the range (default: HEAD)\n --commit <sha> Review a single commit\n --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)\n --repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)\n --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)\n --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)\n --publish With --pr or --mr: post the review to it\n --full With --pr or --mr: review every file, not only what\n changed since the previous review\n --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)\n --output <file> Write the result to a file instead of stdout\n --reviewers <ids> Run only these reviewers (comma-separated)\n --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest\n --import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, \u2026)\n that fall on the change; repeatable; not with --plan\n --plan Show files, bundles, review tasks and prompt sizes; call no model\n --ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)\n --temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)\n --seed <n> Sampling seed, where the runtime and provider support one\n --config <file> Read this configuration file instead of the repository's\n .ocra/config.json; it applies with --no-repo-config too\n --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables or --config\n --no-upload When signed in to ocra Cloud, do not send this review's\n counts (verdict, numbers of findings, files, tokens)\n -h, --help Show help\n";
|
|
35
36
|
export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
|
|
36
37
|
//# sourceMappingURL=args.d.ts.map
|
package/dist/review/args.js
CHANGED
|
@@ -35,6 +35,8 @@ Options:
|
|
|
35
35
|
.ocra/config.json; it applies with --no-repo-config too
|
|
36
36
|
--no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
|
|
37
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)
|
|
38
40
|
-h, --help Show help
|
|
39
41
|
`;
|
|
40
42
|
export function parseReviewArgs(argv) {
|
|
@@ -70,6 +72,8 @@ export function parseReviewArgs(argv) {
|
|
|
70
72
|
args.output = values.output;
|
|
71
73
|
if (values["no-repo-config"])
|
|
72
74
|
args.ignoreRepoConfig = true;
|
|
75
|
+
if (values["no-upload"])
|
|
76
|
+
args.noUpload = true;
|
|
73
77
|
if (values.ultra)
|
|
74
78
|
args.ultra = true;
|
|
75
79
|
if (values.full) {
|
|
@@ -137,6 +141,7 @@ function parse(argv) {
|
|
|
137
141
|
format: { type: "string" },
|
|
138
142
|
output: { type: "string" },
|
|
139
143
|
"no-repo-config": { type: "boolean" },
|
|
144
|
+
"no-upload": { type: "boolean" },
|
|
140
145
|
config: { type: "string" },
|
|
141
146
|
reviewers: { type: "string" },
|
|
142
147
|
"max-cost-usd": { 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,4 +1,5 @@
|
|
|
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";
|
|
4
5
|
import type { RuntimeLoaders } from "./runtimes.js";
|
|
@@ -20,6 +21,7 @@ export interface ReviewDeps {
|
|
|
20
21
|
now(): number;
|
|
21
22
|
heartbeatMs: number;
|
|
22
23
|
fetch?: typeof fetch;
|
|
24
|
+
cloud?: CloudDeps;
|
|
23
25
|
onInterrupt?(handler: () => void): () => void;
|
|
24
26
|
}
|
|
25
27
|
export declare function reviewCommand(args: ReviewArgs, io: {
|
package/dist/review/command.js
CHANGED
|
@@ -6,6 +6,8 @@ import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
|
|
|
6
6
|
import { localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
7
7
|
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
8
8
|
import { VERSION } from "../version.js";
|
|
9
|
+
import { withCloudProviders } from "./cloud-providers.js";
|
|
10
|
+
import { cloudEnabled, defaultModels, repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
|
|
9
11
|
import { ConfigError } from "./config.js";
|
|
10
12
|
import { renderPlan } from "./plan-render.js";
|
|
11
13
|
import { ProgressPrinter } from "./progress.js";
|
|
@@ -60,6 +62,8 @@ export async function reviewCommand(args, io, deps) {
|
|
|
60
62
|
vcs,
|
|
61
63
|
reviewers: registry.reviewers,
|
|
62
64
|
reviewerOverrides: overrides,
|
|
65
|
+
effort: config.effort,
|
|
66
|
+
roles: config.roles,
|
|
63
67
|
rules: [...registry.rules, ...config.rules],
|
|
64
68
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
65
69
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
@@ -75,10 +79,20 @@ export async function reviewCommand(args, io, deps) {
|
|
|
75
79
|
}
|
|
76
80
|
const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
|
|
77
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);
|
|
78
92
|
const runtime = registry.createRuntime(config.runtime, {
|
|
79
|
-
models
|
|
80
|
-
env:
|
|
81
|
-
providers:
|
|
93
|
+
models,
|
|
94
|
+
env: cloud.env,
|
|
95
|
+
providers: cloud.providers,
|
|
82
96
|
...(Object.keys(sampling).length > 0 ? { sampling } : {}),
|
|
83
97
|
});
|
|
84
98
|
const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
|
|
@@ -88,6 +102,7 @@ export async function reviewCommand(args, io, deps) {
|
|
|
88
102
|
interrupt.abort();
|
|
89
103
|
});
|
|
90
104
|
let report;
|
|
105
|
+
const started = deps.now();
|
|
91
106
|
try {
|
|
92
107
|
report = await review({
|
|
93
108
|
runId: session.id,
|
|
@@ -146,6 +161,13 @@ export async function reviewCommand(args, io, deps) {
|
|
|
146
161
|
}
|
|
147
162
|
io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
|
|
148
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
|
+
}
|
|
149
171
|
return exitCode(report, io.err);
|
|
150
172
|
}
|
|
151
173
|
// A runtime not built in comes from a plugin of the configuration.
|
|
@@ -156,6 +178,8 @@ async function builtinRuntime(runtimes, name) {
|
|
|
156
178
|
function runOptions(config) {
|
|
157
179
|
const options = {
|
|
158
180
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
181
|
+
effort: config.effort,
|
|
182
|
+
roles: config.roles,
|
|
159
183
|
};
|
|
160
184
|
if (config.concurrency !== undefined)
|
|
161
185
|
options.concurrency = config.concurrency;
|
package/dist/review/config.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type CustomProvider, type ModelChains, OcraError, 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<{
|
|
@@ -8,6 +8,29 @@ declare const configSchema: z.ZodObject<{
|
|
|
8
8
|
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
9
9
|
light: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
10
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>>;
|
|
11
34
|
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
12
35
|
taskTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
13
36
|
runTimeoutMinutes: z.ZodOptional<z.ZodNumber>;
|
|
@@ -37,6 +60,26 @@ declare const configSchema: z.ZodObject<{
|
|
|
37
60
|
lite: "lite";
|
|
38
61
|
trivial: "trivial";
|
|
39
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
|
+
}>>;
|
|
40
83
|
}, z.core.$strict>>>;
|
|
41
84
|
pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
42
85
|
providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
@@ -48,13 +91,18 @@ declare const configSchema: z.ZodObject<{
|
|
|
48
91
|
output: z.ZodNumber;
|
|
49
92
|
cachedInput: z.ZodOptional<z.ZodNumber>;
|
|
50
93
|
}, z.core.$strict>>;
|
|
94
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
95
|
+
openai: "openai";
|
|
96
|
+
openrouter: "openrouter";
|
|
97
|
+
}>>;
|
|
51
98
|
}, z.core.$strict>>>;
|
|
52
99
|
extends: z.ZodOptional<z.ZodString>;
|
|
53
100
|
}, z.core.$strict>;
|
|
54
101
|
export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
55
102
|
export declare function configJsonSchema(): Record<string, unknown>;
|
|
56
|
-
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
|
|
103
|
+
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "effort" | "providers"> & {
|
|
57
104
|
models: ModelChains;
|
|
105
|
+
effort: TierEfforts;
|
|
58
106
|
providers: Record<string, CustomProvider>;
|
|
59
107
|
rules: RepoRule[];
|
|
60
108
|
};
|
package/dist/review/config.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { OcraError, } from "@open-cr-agent/core";
|
|
4
|
-
import { RISK_TIERS } from "@open-cr-agent/core/internal";
|
|
4
|
+
import { AGENT_ROLES, EFFORT_LEVELS, RISK_TIERS } from "@open-cr-agent/core/internal";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
|
|
7
7
|
export const CONFIG_PATH = ".ocra/config.json";
|
|
8
|
+
// How much a model reasons before answering (ADR-0025); unset leaves the
|
|
9
|
+
// provider's default.
|
|
10
|
+
const effort = z.enum(EFFORT_LEVELS);
|
|
8
11
|
const modelChain = z
|
|
9
12
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
10
13
|
.transform((value) => (typeof value === "string" ? [value] : value));
|
|
@@ -40,6 +43,9 @@ const providerSchema = z
|
|
|
40
43
|
models: z
|
|
41
44
|
.record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
|
|
42
45
|
.refine((models) => Object.keys(models).length > 0, "must list at least one model"),
|
|
46
|
+
// How the endpoint takes a reasoning effort: "openai" sends
|
|
47
|
+
// reasoning_effort (the default), "openrouter" sends reasoning.effort.
|
|
48
|
+
effort: z.enum(["openai", "openrouter"]).optional(),
|
|
43
49
|
})
|
|
44
50
|
.strict();
|
|
45
51
|
// The runtimes ocra ships; a plugin may register another name, so the
|
|
@@ -53,6 +59,11 @@ const configSchema = z
|
|
|
53
59
|
.partial()
|
|
54
60
|
.strict()
|
|
55
61
|
.default({}),
|
|
62
|
+
effort: z
|
|
63
|
+
.object({ top: effort, standard: effort, light: effort })
|
|
64
|
+
.partial()
|
|
65
|
+
.strict()
|
|
66
|
+
.default({}),
|
|
56
67
|
concurrency: z.number().int().min(1).max(32).optional(),
|
|
57
68
|
taskTimeoutMinutes: z.number().positive().optional(),
|
|
58
69
|
runTimeoutMinutes: z.number().positive().optional(),
|
|
@@ -88,10 +99,14 @@ const configSchema = z
|
|
|
88
99
|
.object({
|
|
89
100
|
enabled: z.boolean(),
|
|
90
101
|
minTier: z.enum(RISK_TIERS),
|
|
102
|
+
effort,
|
|
91
103
|
})
|
|
92
104
|
.partial()
|
|
93
105
|
.strict())
|
|
94
106
|
.default({}),
|
|
107
|
+
roles: z
|
|
108
|
+
.partialRecord(z.enum(AGENT_ROLES), z.object({ effort }).partial().strict())
|
|
109
|
+
.default({}),
|
|
95
110
|
pluginSettings: z.record(z.string(), z.unknown()).default({}),
|
|
96
111
|
providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
|
|
97
112
|
extends: z.string().min(1).optional(),
|
|
@@ -116,6 +131,11 @@ const MODEL_ENV = {
|
|
|
116
131
|
standard: "OCRA_MODEL_STANDARD",
|
|
117
132
|
light: "OCRA_MODEL_LIGHT",
|
|
118
133
|
};
|
|
134
|
+
const EFFORT_ENV = {
|
|
135
|
+
top: "OCRA_EFFORT_TOP",
|
|
136
|
+
standard: "OCRA_EFFORT_STANDARD",
|
|
137
|
+
light: "OCRA_EFFORT_LIGHT",
|
|
138
|
+
};
|
|
119
139
|
export class ConfigError extends OcraError {
|
|
120
140
|
constructor(message, options) {
|
|
121
141
|
super("CONFIG_INVALID", message, options);
|
|
@@ -144,10 +164,29 @@ export async function loadConfig(root, env, options = { repository: true }) {
|
|
|
144
164
|
if (chain.length > 0)
|
|
145
165
|
models[tier] = chain;
|
|
146
166
|
}
|
|
167
|
+
const efforts = {
|
|
168
|
+
...parsed.effort,
|
|
169
|
+
};
|
|
170
|
+
for (const [tier, name] of Object.entries(EFFORT_ENV)) {
|
|
171
|
+
const value = env[name]?.trim();
|
|
172
|
+
if (!value)
|
|
173
|
+
continue;
|
|
174
|
+
const level = effort.safeParse(value);
|
|
175
|
+
if (!level.success) {
|
|
176
|
+
throw new ConfigError(`${name} must be one of ${EFFORT_LEVELS.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
efforts[tier] = level.data;
|
|
179
|
+
}
|
|
147
180
|
for (const model of unpriced(parsed.providers, models)) {
|
|
148
181
|
options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
|
|
149
182
|
}
|
|
150
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
...parsed,
|
|
185
|
+
models,
|
|
186
|
+
effort: efforts,
|
|
187
|
+
providers: toProviders(parsed.providers),
|
|
188
|
+
rules,
|
|
189
|
+
};
|
|
151
190
|
}
|
|
152
191
|
function toProviders(declared) {
|
|
153
192
|
return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
|
|
@@ -155,6 +194,7 @@ function toProviders(declared) {
|
|
|
155
194
|
{
|
|
156
195
|
baseUrl: p.baseUrl,
|
|
157
196
|
...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
|
|
197
|
+
...(p.effort ? { effort: p.effort } : {}),
|
|
158
198
|
models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
|
|
159
199
|
model,
|
|
160
200
|
{
|
|
@@ -20,7 +20,7 @@ export function renderPlan(preview) {
|
|
|
20
20
|
lines.push("", `Review tasks: ${preview.tasks.length}`);
|
|
21
21
|
const width = Math.max(0, ...preview.tasks.map((t) => t.taskId.length));
|
|
22
22
|
for (const t of preview.tasks) {
|
|
23
|
-
lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`} ${t.bundle}`);
|
|
23
|
+
lines.push(` ${t.taskId.padEnd(width)} ~${number.format(t.promptTokens)} prompt tokens${t.planPromptTokens === undefined ? "" : ` + plan ~${number.format(t.planPromptTokens)}`}${t.effort === undefined ? "" : ` effort ${t.effort}`} ${t.bundle}`);
|
|
24
24
|
}
|
|
25
25
|
if (preview.skipped.length > 0) {
|
|
26
26
|
lines.push("", "Skipped by the review matrix:");
|
|
@@ -12,11 +12,14 @@ export function requestedSampling(config, args) {
|
|
|
12
12
|
// The effective configuration and the flags that change what is reviewed
|
|
13
13
|
// and how, so two runs can tell they were set up alike. Keys are names of
|
|
14
14
|
// environment variables, never their values; a password written into a
|
|
15
|
-
// URL is dropped. Sampling is recorded on its own.
|
|
15
|
+
// URL is dropped. Sampling is recorded on its own. Effort is covered; left
|
|
16
|
+
// unset it adds nothing, so configurations that set none keep their hash.
|
|
16
17
|
export function configHash(config, args) {
|
|
17
|
-
const { sampling: _, providers, extends: shared, ...rest } = config;
|
|
18
|
+
const { sampling: _, providers, extends: shared, effort, roles, ...rest } = config;
|
|
18
19
|
return stableHash({
|
|
19
20
|
...rest,
|
|
21
|
+
...(Object.keys(effort).length > 0 ? { effort } : {}),
|
|
22
|
+
...(Object.keys(roles).length > 0 ? { roles } : {}),
|
|
20
23
|
...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
|
|
21
24
|
providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
|
|
22
25
|
id,
|
|
@@ -6,6 +6,11 @@ export declare const remoteConfigSchema: z.ZodObject<{
|
|
|
6
6
|
standard: "standard";
|
|
7
7
|
top: "top";
|
|
8
8
|
}>, z.ZodUnknown>>;
|
|
9
|
+
effort: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
|
|
10
|
+
light: "light";
|
|
11
|
+
standard: "standard";
|
|
12
|
+
top: "top";
|
|
13
|
+
}> & z.core.$partial, z.ZodUnknown>>;
|
|
9
14
|
concurrency: z.ZodOptional<z.ZodUnknown>;
|
|
10
15
|
taskTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
11
16
|
runTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
@@ -16,6 +21,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
|
|
|
16
21
|
include: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
22
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
18
23
|
reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
24
|
+
roles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
19
25
|
github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
20
26
|
providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
21
27
|
rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
@@ -11,6 +11,7 @@ export const remoteConfigSchema = z
|
|
|
11
11
|
.object({
|
|
12
12
|
$schema: z.string().optional(),
|
|
13
13
|
models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
14
|
+
effort: z.partialRecord(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
14
15
|
concurrency: z.unknown().optional(),
|
|
15
16
|
taskTimeoutMinutes: z.unknown().optional(),
|
|
16
17
|
runTimeoutMinutes: z.unknown().optional(),
|
|
@@ -21,6 +22,7 @@ export const remoteConfigSchema = z
|
|
|
21
22
|
include: z.array(z.string()).optional(),
|
|
22
23
|
exclude: z.array(z.string()).optional(),
|
|
23
24
|
reviewers: z.record(z.string(), z.unknown()).optional(),
|
|
25
|
+
roles: z.record(z.string(), z.unknown()).optional(),
|
|
24
26
|
github: z.record(z.string(), z.unknown()).optional(),
|
|
25
27
|
providers: z.record(z.string(), z.unknown()).optional(),
|
|
26
28
|
rules: z.array(repoRuleSchema).max(500).optional(),
|
|
@@ -83,7 +85,7 @@ async function readLimited(response, limit, name) {
|
|
|
83
85
|
export function mergeConfig(remote, local) {
|
|
84
86
|
const { rules: _rules, ...settings } = remote;
|
|
85
87
|
const merged = { ...settings, ...local };
|
|
86
|
-
for (const key of ["models", "reviewers", "github", "providers"]) {
|
|
88
|
+
for (const key of ["models", "effort", "reviewers", "roles", "github", "providers"]) {
|
|
87
89
|
const a = settings[key];
|
|
88
90
|
const b = local[key];
|
|
89
91
|
if (a !== undefined || b !== undefined)
|
package/dist/review/sarif.js
CHANGED
package/dist/review/target.js
CHANGED
|
@@ -90,7 +90,7 @@ const GITLAB_API = "https://gitlab.com/api/v4";
|
|
|
90
90
|
export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
|
|
91
91
|
const token = env.GITLAB_TOKEN;
|
|
92
92
|
if (!token) {
|
|
93
|
-
throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN: a project access token
|
|
93
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--mr needs a GitLab token in GITLAB_TOKEN with the api scope and the Developer role: a project access token, or on GitLab.com Free a personal access token of a dedicated account (CI_JOB_TOKEN cannot post comments)");
|
|
94
94
|
}
|
|
95
95
|
const origin = await originUrl(root);
|
|
96
96
|
const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
|
package/dist/run.js
CHANGED
|
@@ -2,6 +2,7 @@ import { writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { isOcraError } from "@open-cr-agent/core";
|
|
4
4
|
import { errorMessage } from "@open-cr-agent/core/internal";
|
|
5
|
+
import { cloudCommand, defaultCloudDeps, LOGIN_USAGE } from "./cloud.js";
|
|
5
6
|
import { memoryCommand } from "./memory.js";
|
|
6
7
|
import { metricsCommand } from "./metrics.js";
|
|
7
8
|
import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
|
|
@@ -15,6 +16,9 @@ Commands:
|
|
|
15
16
|
review Review code changes (run "ocra review --help" for options)
|
|
16
17
|
memory Remember findings the team accepts (run "ocra memory --help")
|
|
17
18
|
metrics Counts over past reviews: runs, cost, findings, per reviewer (run "ocra metrics --help")
|
|
19
|
+
login Sign in to ocra Cloud (in development; run "ocra login --help")
|
|
20
|
+
logout Sign out of ocra Cloud
|
|
21
|
+
whoami Show the ocra Cloud account
|
|
18
22
|
|
|
19
23
|
Options:
|
|
20
24
|
-h, --help Show help
|
|
@@ -27,6 +31,7 @@ export function defaultDeps() {
|
|
|
27
31
|
builtinPlugins: BUILTIN_PLUGINS,
|
|
28
32
|
runtimes: BUILTIN_RUNTIMES,
|
|
29
33
|
writeFile: (path, content) => writeFile(path, content, "utf8"),
|
|
34
|
+
cloud: defaultCloudDeps(process.env),
|
|
30
35
|
now: Date.now,
|
|
31
36
|
heartbeatMs: 30_000,
|
|
32
37
|
onInterrupt(handler) {
|
|
@@ -61,6 +66,18 @@ export async function run(argv, out, err, deps = defaultDeps()) {
|
|
|
61
66
|
return EXIT.error;
|
|
62
67
|
}
|
|
63
68
|
}
|
|
69
|
+
if (command === "login" || command === "logout" || command === "whoami") {
|
|
70
|
+
try {
|
|
71
|
+
return await cloudCommand(command, rest, out, err, defaultCloudDeps(deps.env));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof UsageError)
|
|
75
|
+
err.write(`${error.message}\n\n${LOGIN_USAGE}`);
|
|
76
|
+
else
|
|
77
|
+
err.write(failure(error));
|
|
78
|
+
return EXIT.error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
64
81
|
if (command !== undefined && !command.startsWith("-")) {
|
|
65
82
|
err.write(`Unknown command: ${command}\n\n${USAGE}`);
|
|
66
83
|
return EXIT.error;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-cr-agent/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "ocra: open-source multi-agent code review for local changes and GitHub pull requests",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"code-review",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"github-action"
|
|
11
11
|
],
|
|
12
12
|
"license": "Apache-2.0",
|
|
13
|
-
"homepage": "https://
|
|
13
|
+
"homepage": "https://ocracloud.com",
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
16
16
|
"url": "git+https://github.com/jma49/Open-CR-Agent.git",
|
|
@@ -37,15 +37,15 @@
|
|
|
37
37
|
"!dist/**/*.map"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@open-cr-agent/core": "0.
|
|
41
|
-
"@open-cr-agent/runtime-direct": "0.
|
|
42
|
-
"@open-cr-agent/vcs-github": "0.
|
|
43
|
-
"@open-cr-agent/vcs-gitlab": "0.
|
|
44
|
-
"@open-cr-agent/vcs-local": "0.
|
|
40
|
+
"@open-cr-agent/core": "0.4.0",
|
|
41
|
+
"@open-cr-agent/runtime-direct": "0.4.0",
|
|
42
|
+
"@open-cr-agent/vcs-github": "0.4.0",
|
|
43
|
+
"@open-cr-agent/vcs-gitlab": "0.4.0",
|
|
44
|
+
"@open-cr-agent/vcs-local": "0.4.0",
|
|
45
45
|
"zod": "^4.6.5"
|
|
46
46
|
},
|
|
47
47
|
"optionalDependencies": {
|
|
48
|
-
"@open-cr-agent/runtime-opencode": "0.
|
|
48
|
+
"@open-cr-agent/runtime-opencode": "0.4.0"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|