@open-cr-agent/cli 0.3.0 → 0.5.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/account-salt.d.ts +7 -0
- package/dist/account-salt.js +43 -0
- package/dist/cloud.d.ts +34 -0
- package/dist/cloud.js +272 -0
- package/dist/plugin-store.d.ts +20 -0
- package/dist/plugin-store.js +84 -0
- package/dist/plugins-command.d.ts +15 -0
- package/dist/plugins-command.js +183 -0
- package/dist/review/account-plugins.d.ts +23 -0
- package/dist/review/account-plugins.js +54 -0
- package/dist/review/account-settings.d.ts +100 -0
- package/dist/review/account-settings.js +106 -0
- package/dist/review/args.d.ts +2 -1
- package/dist/review/args.js +5 -0
- package/dist/review/cloud-findings.d.ts +31 -0
- package/dist/review/cloud-findings.js +80 -0
- package/dist/review/cloud-memory.d.ts +6 -0
- package/dist/review/cloud-memory.js +58 -0
- package/dist/review/cloud-providers.d.ts +12 -0
- package/dist/review/cloud-providers.js +60 -0
- package/dist/review/cloud-review.d.ts +19 -0
- package/dist/review/cloud-review.js +51 -0
- package/dist/review/cloud-settings.d.ts +16 -0
- package/dist/review/cloud-settings.js +118 -0
- package/dist/review/cloud-upload.d.ts +60 -0
- package/dist/review/cloud-upload.js +180 -0
- package/dist/review/command.d.ts +4 -0
- package/dist/review/command.js +91 -14
- package/dist/review/config.d.ts +65 -6
- package/dist/review/config.js +71 -7
- package/dist/review/plan-render.js +1 -1
- package/dist/review/plugins.d.ts +10 -0
- package/dist/review/plugins.js +46 -1
- package/dist/review/provenance.js +15 -2
- package/dist/review/remote-config.d.ts +6 -0
- package/dist/review/remote-config.js +3 -1
- package/dist/review/render.js +7 -2
- package/dist/review/sarif.js +1 -1
- package/dist/review/settings-sources.d.ts +11 -0
- package/dist/review/settings-sources.js +58 -0
- package/dist/review/target.d.ts +1 -0
- package/dist/review/target.js +4 -1
- package/dist/run.js +36 -0
- package/package.json +8 -8
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { memoryEntrySchema } from "@open-cr-agent/core/internal";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
// The findings the account remembers for one repository (ADR-0028, 4), set
|
|
4
|
+
// from the web. A review applies them with .ocra/memory.json's.
|
|
5
|
+
// The server keeps at most 500 per repository; more is not an answer to trust.
|
|
6
|
+
const MAX_ENTRIES = 500;
|
|
7
|
+
/** The account's entries for the repository; [] and a warning when they cannot be read. */
|
|
8
|
+
export async function fetchAccountMemory(deps, session, repoHash, warn) {
|
|
9
|
+
let body;
|
|
10
|
+
try {
|
|
11
|
+
const res = await deps.fetch(`${session.server}/api/memory?repo=${encodeURIComponent(repoHash)}`, {
|
|
12
|
+
headers: {
|
|
13
|
+
authorization: `Bearer ${session.access_token}`,
|
|
14
|
+
"user-agent": `ocra/${VERSION}`,
|
|
15
|
+
},
|
|
16
|
+
signal: AbortSignal.timeout(15_000),
|
|
17
|
+
});
|
|
18
|
+
// A server without account memory has none to apply.
|
|
19
|
+
if (res.status === 404)
|
|
20
|
+
return [];
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
throw new Error(`HTTP ${res.status}`);
|
|
23
|
+
body = await res.json();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
warn(`could not read your ocra Cloud memory (${error instanceof Error ? error.message : "error"}); the review applies the repository's alone`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return parseAccountMemory(body, warn);
|
|
30
|
+
}
|
|
31
|
+
export function parseAccountMemory(body, warn) {
|
|
32
|
+
const list = body?.entries;
|
|
33
|
+
if (!Array.isArray(list)) {
|
|
34
|
+
warn("ignoring your ocra Cloud memory: the server's answer has no entries");
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const entries = [];
|
|
38
|
+
let refused = 0;
|
|
39
|
+
for (const raw of list.slice(0, MAX_ENTRIES)) {
|
|
40
|
+
const r = (typeof raw === "object" && raw !== null ? raw : {});
|
|
41
|
+
const parsed = memoryEntrySchema.safeParse({
|
|
42
|
+
fingerprint: r.fingerprint,
|
|
43
|
+
file: r.file,
|
|
44
|
+
title: r.title,
|
|
45
|
+
reason: r.reason,
|
|
46
|
+
...(typeof r.createdAt === "string" ? { added: r.createdAt.slice(0, 10) } : {}),
|
|
47
|
+
});
|
|
48
|
+
if (parsed.success)
|
|
49
|
+
entries.push(parsed.data);
|
|
50
|
+
else
|
|
51
|
+
refused += 1;
|
|
52
|
+
}
|
|
53
|
+
if (refused > 0) {
|
|
54
|
+
warn(`ignoring ${refused} ocra Cloud memory entr${refused > 1 ? "ies" : "y"} this version of ocra cannot read`);
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=cloud-memory.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CustomProvider } 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(chains: readonly (readonly string[] | undefined)[], 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,60 @@
|
|
|
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
|
+
// chains: every chain of the run, the tiers' and the agents' own.
|
|
14
|
+
export async function withCloudProviders(chains, providers, env, deps, warn) {
|
|
15
|
+
const wanted = new Map();
|
|
16
|
+
for (const model of chains.flatMap((chain) => chain ?? [])) {
|
|
17
|
+
const slash = model.indexOf("/");
|
|
18
|
+
const id = model.slice(0, slash);
|
|
19
|
+
if (slash < 0 || !id.startsWith(CLOUD_PREFIX) || providers[id])
|
|
20
|
+
continue;
|
|
21
|
+
const set = wanted.get(id) ?? new Set();
|
|
22
|
+
set.add(model.slice(slash + 1));
|
|
23
|
+
wanted.set(id, set);
|
|
24
|
+
}
|
|
25
|
+
if (wanted.size === 0)
|
|
26
|
+
return { providers: { ...providers }, env };
|
|
27
|
+
const names = [...wanted.keys()].join(", ");
|
|
28
|
+
if (env.OCRA_CLOUD === "off") {
|
|
29
|
+
throw new OcraError("CONFIG_INVALID", `Models name ocra Cloud (${names}) but OCRA_CLOUD=off`);
|
|
30
|
+
}
|
|
31
|
+
const session = deps && (await cloudSession(deps, MIN_TOKEN_MS));
|
|
32
|
+
if (!session) {
|
|
33
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", `Models name ocra Cloud (${names}): sign in with ocra login`);
|
|
34
|
+
}
|
|
35
|
+
const res = await deps.fetch(`${session.server}/api/providers`, {
|
|
36
|
+
signal: AbortSignal.timeout(30_000),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new OcraError("RUNTIME_START_FAILED", `ocra Cloud answered ${res.status} for its providers`);
|
|
40
|
+
}
|
|
41
|
+
const listed = (await res.json()).providers ?? [];
|
|
42
|
+
const declared = { ...providers };
|
|
43
|
+
for (const [id, modelIds] of wanted) {
|
|
44
|
+
const name = id.slice(CLOUD_PREFIX.length);
|
|
45
|
+
const chat = listed
|
|
46
|
+
.find((p) => p.name === name)
|
|
47
|
+
?.paths.find((path) => path.endsWith("/chat/completions"));
|
|
48
|
+
if (!chat) {
|
|
49
|
+
throw new OcraError("CONFIG_INVALID", `ocra Cloud has no OpenAI-compatible chat endpoint for "${name}" (named by ${id})`);
|
|
50
|
+
}
|
|
51
|
+
declared[id] = {
|
|
52
|
+
baseUrl: `${session.server}/api/gateway/${name}${chat.slice(0, -"/chat/completions".length)}`,
|
|
53
|
+
apiKeyEnv: CLOUD_TOKEN_ENV,
|
|
54
|
+
models: Object.fromEntries([...modelIds].map((m) => [m, { input: 0, output: 0 }])),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
warn(`${names}: models through ocra Cloud are not priced, so reported cost and --max-cost-usd do not count them`);
|
|
58
|
+
return { providers: declared, env: { ...env, [CLOUD_TOKEN_ENV]: session.access_token } };
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=cloud-providers.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { MemoryEntry, ReviewReport } from "@open-cr-agent/core";
|
|
2
|
+
import { type CloudDeps } from "../cloud.js";
|
|
3
|
+
import { type ReviewSource } from "./cloud-upload.js";
|
|
4
|
+
import type { Output } from "./progress.js";
|
|
5
|
+
export type CloudReview = {
|
|
6
|
+
repoHash: string;
|
|
7
|
+
shareFindings: boolean;
|
|
8
|
+
memory: MemoryEntry[];
|
|
9
|
+
};
|
|
10
|
+
/** Undefined when the saved session is gone; a failure to reach ocra Cloud is one warning. */
|
|
11
|
+
export declare function prepareCloudReview(root: string, deps: CloudDeps, warn: (message: string) => void): Promise<CloudReview | undefined>;
|
|
12
|
+
/** Sends the counts, and the findings when the account shares them. */
|
|
13
|
+
export declare function sendToCloud(report: ReviewReport, cloud: CloudReview, deps: CloudDeps, run: {
|
|
14
|
+
source: ReviewSource;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
err: Output;
|
|
17
|
+
warn: (message: string) => void;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
//# sourceMappingURL=cloud-review.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { fetchAccountSalt, saveAccountSalt } from "../account-salt.js";
|
|
2
|
+
import { cloudSession } from "../cloud.js";
|
|
3
|
+
import { sharedFindings } from "./cloud-findings.js";
|
|
4
|
+
import { fetchAccountMemory } from "./cloud-memory.js";
|
|
5
|
+
import { repoHash, uploadOf, uploadReview } from "./cloud-upload.js";
|
|
6
|
+
/** Undefined when the saved session is gone; a failure to reach ocra Cloud is one warning. */
|
|
7
|
+
export async function prepareCloudReview(root, deps, warn) {
|
|
8
|
+
let session;
|
|
9
|
+
let salt;
|
|
10
|
+
try {
|
|
11
|
+
session = await cloudSession(deps);
|
|
12
|
+
if (!session)
|
|
13
|
+
return undefined;
|
|
14
|
+
salt = await fetchAccountSalt(deps, session.server, session.access_token);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
// This machine's salt still groups the counts. Findings and the
|
|
18
|
+
// account's memory are keyed by the account's hash when it shares
|
|
19
|
+
// findings, so without its answer neither can be matched: none is sent
|
|
20
|
+
// and none applied.
|
|
21
|
+
warn(`could not read your ocra Cloud account (${error instanceof Error ? error.message : "error"}); this review sends no findings and applies no account memory`);
|
|
22
|
+
return {
|
|
23
|
+
repoHash: await repoHash(root, deps.credentialsPath),
|
|
24
|
+
shareFindings: false,
|
|
25
|
+
memory: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
await saveAccountSalt(deps.credentialsPath, salt);
|
|
29
|
+
const hash = await repoHash(root, deps.credentialsPath, salt ?? undefined);
|
|
30
|
+
return {
|
|
31
|
+
repoHash: hash,
|
|
32
|
+
shareFindings: salt !== null,
|
|
33
|
+
memory: await fetchAccountMemory(deps, session, hash, warn),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Sends the counts, and the findings when the account shares them. */
|
|
37
|
+
export async function sendToCloud(report, cloud, deps, run) {
|
|
38
|
+
const upload = uploadOf(report, run.source, cloud.repoHash, run.durationMs);
|
|
39
|
+
const shared = cloud.shareFindings ? sharedFindings(report) : undefined;
|
|
40
|
+
const sent = await uploadReview(upload, deps, run.warn, shared?.findings);
|
|
41
|
+
if (!sent)
|
|
42
|
+
return;
|
|
43
|
+
if (sent.findings > 0) {
|
|
44
|
+
const left = shared?.left ? ` (${shared.left} left out by the size limit)` : "";
|
|
45
|
+
run.err.write(`[ocra] Sent this review's counts and ${sent.findings} finding(s) to ocra Cloud${left} (--no-upload to skip)\n`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
run.err.write("[ocra] Sent this review's counts to ocra Cloud (--no-upload to skip)\n");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=cloud-review.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type CloudDeps } from "../cloud.js";
|
|
2
|
+
import { type AccountPlugins } from "./account-plugins.js";
|
|
3
|
+
import { type AccountSettings } from "./account-settings.js";
|
|
4
|
+
import type { CliConfig } from "./config.js";
|
|
5
|
+
export declare const ACCOUNT_SCALARS: readonly ["concurrency", "taskTimeoutMinutes", "runTimeoutMinutes", "maxCostUsd", "maxTasks", "verify", "judge", "sampling"];
|
|
6
|
+
/** The account's settings; undefined when signed out or unreachable (a warning says which). */
|
|
7
|
+
export declare function fetchAccountSettings(deps: CloudDeps, warn: (message: string) => void): Promise<{
|
|
8
|
+
settings?: AccountSettings;
|
|
9
|
+
plugins: AccountPlugins;
|
|
10
|
+
} | undefined>;
|
|
11
|
+
/** The configuration with the account's settings under it, and what they filled in. */
|
|
12
|
+
export declare function layerAccountSettings(config: CliConfig, account: AccountSettings): {
|
|
13
|
+
config: CliConfig;
|
|
14
|
+
filled: string[];
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=cloud-settings.d.ts.map
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { cloudSession } from "../cloud.js";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
import { parseAccountPlugins } from "./account-plugins.js";
|
|
4
|
+
import { parseAccountSettings } from "./account-settings.js";
|
|
5
|
+
// ocra Cloud's account settings fill what the repository's configuration
|
|
6
|
+
// leaves out (ADR-0025, ADR-0027): a tier's models and effort, a reviewer's
|
|
7
|
+
// or role's whole entry, the runtime and each limit. What the configuration
|
|
8
|
+
// sets always wins; include, exclude and rules combine, the repository's
|
|
9
|
+
// first.
|
|
10
|
+
const TIERS = ["top", "standard", "light"];
|
|
11
|
+
// Each set by the configuration or else by the account, whole.
|
|
12
|
+
export const ACCOUNT_SCALARS = [
|
|
13
|
+
"concurrency",
|
|
14
|
+
"taskTimeoutMinutes",
|
|
15
|
+
"runTimeoutMinutes",
|
|
16
|
+
"maxCostUsd",
|
|
17
|
+
"maxTasks",
|
|
18
|
+
"verify",
|
|
19
|
+
"judge",
|
|
20
|
+
"sampling",
|
|
21
|
+
];
|
|
22
|
+
/** The account's settings; undefined when signed out or unreachable (a warning says which). */
|
|
23
|
+
export async function fetchAccountSettings(deps, warn) {
|
|
24
|
+
let body;
|
|
25
|
+
try {
|
|
26
|
+
const session = await cloudSession(deps);
|
|
27
|
+
if (!session)
|
|
28
|
+
return undefined;
|
|
29
|
+
const res = await deps.fetch(`${session.server}/api/preferences`, {
|
|
30
|
+
headers: { authorization: `Bearer ${session.access_token}`, "user-agent": `ocra/${VERSION}` },
|
|
31
|
+
signal: AbortSignal.timeout(15_000),
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
warn(`could not read your ocra Cloud settings (HTTP ${res.status}); the review uses the repository's`);
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
body = await res.json();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
warn(`could not read your ocra Cloud settings (${error.message}); the review uses the repository's`);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (typeof body !== "object" || body === null) {
|
|
44
|
+
warn("ignoring your ocra Cloud settings: the server's answer is not an object");
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const { settings, warnings } = parseAccountSettings(body);
|
|
48
|
+
for (const warning of warnings)
|
|
49
|
+
warn(warning);
|
|
50
|
+
return { ...(settings ? { settings } : {}), plugins: parseAccountPlugins(body, warn) };
|
|
51
|
+
}
|
|
52
|
+
/** The configuration with the account's settings under it, and what they filled in. */
|
|
53
|
+
export function layerAccountSettings(config, account) {
|
|
54
|
+
const filled = [];
|
|
55
|
+
const models = { ...config.models };
|
|
56
|
+
const effort = { ...config.effort };
|
|
57
|
+
for (const tier of TIERS) {
|
|
58
|
+
const chain = account.models?.[tier];
|
|
59
|
+
if (!config.models[tier]?.length && chain) {
|
|
60
|
+
models[tier] = chain;
|
|
61
|
+
filled.push(`models.${tier}`);
|
|
62
|
+
}
|
|
63
|
+
const level = account.effort?.[tier];
|
|
64
|
+
if (config.effort[tier] === undefined && level !== undefined) {
|
|
65
|
+
effort[tier] = level;
|
|
66
|
+
filled.push(`effort.${tier}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const reviewers = { ...config.reviewers };
|
|
70
|
+
for (const [id, entry] of Object.entries(account.reviewers ?? {})) {
|
|
71
|
+
if (reviewers[id] === undefined) {
|
|
72
|
+
reviewers[id] = entry;
|
|
73
|
+
filled.push(`reviewers.${id}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const roles = { ...config.roles };
|
|
77
|
+
for (const [role, entry] of Object.entries(account.roles ?? {})) {
|
|
78
|
+
if (roles[role] === undefined && entry) {
|
|
79
|
+
roles[role] = entry;
|
|
80
|
+
filled.push(`roles.${role}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const out = {
|
|
84
|
+
...config,
|
|
85
|
+
models,
|
|
86
|
+
effort,
|
|
87
|
+
reviewers,
|
|
88
|
+
roles,
|
|
89
|
+
accountSettings: { version: account.version },
|
|
90
|
+
};
|
|
91
|
+
if (!config.runtimeSet && account.runtime) {
|
|
92
|
+
out.runtime = account.runtime;
|
|
93
|
+
out.runtimeSet = true;
|
|
94
|
+
filled.push("runtime");
|
|
95
|
+
}
|
|
96
|
+
for (const key of ACCOUNT_SCALARS) {
|
|
97
|
+
if (config[key] === undefined && account[key] !== undefined) {
|
|
98
|
+
Object.assign(out, { [key]: account[key] });
|
|
99
|
+
filled.push(key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const key of ["include", "exclude"]) {
|
|
103
|
+
const added = (account[key] ?? []).filter((glob) => !config[key].includes(glob));
|
|
104
|
+
if (added.length > 0) {
|
|
105
|
+
out[key] = [...config[key], ...added];
|
|
106
|
+
filled.push(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (account.rules?.length) {
|
|
110
|
+
out.rules = [
|
|
111
|
+
...config.rules,
|
|
112
|
+
...account.rules.map((rule) => ({ ...rule, source: "account" })),
|
|
113
|
+
];
|
|
114
|
+
filled.push("rules");
|
|
115
|
+
}
|
|
116
|
+
return { config: out, filled };
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=cloud-settings.js.map
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type ReviewReport, type Severity, type Verification } from "@open-cr-agent/core";
|
|
2
|
+
import { type CloudDeps } from "../cloud.js";
|
|
3
|
+
import type { SharedFinding } from "./cloud-findings.js";
|
|
4
|
+
export type ReviewSource = "local" | "github" | "gitlab";
|
|
5
|
+
export type ReviewerCounts = {
|
|
6
|
+
tasks: number;
|
|
7
|
+
failedTasks: number;
|
|
8
|
+
findings: Record<Severity, number>;
|
|
9
|
+
costUsd: number;
|
|
10
|
+
fixed: number;
|
|
11
|
+
dismissed: number;
|
|
12
|
+
};
|
|
13
|
+
export type ReviewUpload = {
|
|
14
|
+
runId: string;
|
|
15
|
+
repoHash: string;
|
|
16
|
+
source: ReviewSource;
|
|
17
|
+
tier: string;
|
|
18
|
+
verdict: string;
|
|
19
|
+
complete: boolean;
|
|
20
|
+
findings: {
|
|
21
|
+
critical: number;
|
|
22
|
+
warning: number;
|
|
23
|
+
suggestion: number;
|
|
24
|
+
};
|
|
25
|
+
files: {
|
|
26
|
+
reviewed: number;
|
|
27
|
+
notReviewed: number;
|
|
28
|
+
};
|
|
29
|
+
tasks: {
|
|
30
|
+
completed: number;
|
|
31
|
+
failed: number;
|
|
32
|
+
};
|
|
33
|
+
usage: {
|
|
34
|
+
inputTokens: number;
|
|
35
|
+
outputTokens: number;
|
|
36
|
+
costUsd: number;
|
|
37
|
+
};
|
|
38
|
+
durationMs: number;
|
|
39
|
+
ocraVersion: string;
|
|
40
|
+
reviewers?: Record<string, ReviewerCounts>;
|
|
41
|
+
verification?: Record<Verification, number>;
|
|
42
|
+
outcomes?: {
|
|
43
|
+
fixed: number;
|
|
44
|
+
dismissed: number;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
export declare function uploadOf(report: ReviewReport, source: ReviewSource, repoHash: string, durationMs: number): ReviewUpload;
|
|
48
|
+
/** The repository's hash, salted with the account's salt when given, else this machine's. */
|
|
49
|
+
export declare function repoHash(root: string, credentialsPath: string, accountSalt?: string): Promise<string>;
|
|
50
|
+
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
51
|
+
export declare function cloudEnabled(deps: CloudDeps): Promise<boolean>;
|
|
52
|
+
/**
|
|
53
|
+
* Sends the counts, and the findings when given; answers how many findings
|
|
54
|
+
* the server kept, or undefined when it took nothing. A failure is a
|
|
55
|
+
* warning, never a failed review.
|
|
56
|
+
*/
|
|
57
|
+
export declare function uploadReview(upload: ReviewUpload, deps: CloudDeps, warn: (message: string) => void, findings?: readonly SharedFinding[]): Promise<{
|
|
58
|
+
findings: number;
|
|
59
|
+
} | undefined>;
|
|
60
|
+
//# sourceMappingURL=cloud-upload.d.ts.map
|
|
@@ -0,0 +1,180 @@
|
|
|
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 { verificationSchema } from "@open-cr-agent/core/internal";
|
|
8
|
+
import { cloudSession, readCredentials } from "../cloud.js";
|
|
9
|
+
import { VERSION } from "../version.js";
|
|
10
|
+
// After a review, a signed-in CLI sends ocra Cloud its counts (ADR-0024):
|
|
11
|
+
// the verdict, how many findings of each severity, files and tasks, tokens
|
|
12
|
+
// and time. A path, a title, a finding's text or code go only when the
|
|
13
|
+
// account shares findings (ADR-0028, cloud-findings.ts). The repository is
|
|
14
|
+
// a salted hash, so the server can group reviews of one repository without
|
|
15
|
+
// learning which it is: the salt stays on this machine, or is the account's
|
|
16
|
+
// while it shares findings, so the hash matches on all its machines.
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
export function uploadOf(report, source, repoHash, durationMs) {
|
|
19
|
+
const severity = (s) => report.findings.filter((f) => f.severity === s).length;
|
|
20
|
+
const gaps = coverageGaps(report);
|
|
21
|
+
return {
|
|
22
|
+
runId: report.runId,
|
|
23
|
+
repoHash,
|
|
24
|
+
source,
|
|
25
|
+
tier: report.tier,
|
|
26
|
+
verdict: report.verdict,
|
|
27
|
+
complete: gaps.notReviewed === 0,
|
|
28
|
+
findings: {
|
|
29
|
+
critical: severity("critical"),
|
|
30
|
+
warning: severity("warning"),
|
|
31
|
+
suggestion: severity("suggestion"),
|
|
32
|
+
},
|
|
33
|
+
files: {
|
|
34
|
+
reviewed: report.coverage.filter((c) => c.status === "reviewed").length,
|
|
35
|
+
notReviewed: gaps.notReviewed,
|
|
36
|
+
},
|
|
37
|
+
tasks: {
|
|
38
|
+
completed: report.tasks.filter((t) => t.status === "completed").length,
|
|
39
|
+
failed: report.tasks.filter((t) => t.status === "failed").length,
|
|
40
|
+
},
|
|
41
|
+
usage: {
|
|
42
|
+
inputTokens: report.usage.inputTokens,
|
|
43
|
+
outputTokens: report.usage.outputTokens,
|
|
44
|
+
costUsd: report.usage.costUsd ?? 0,
|
|
45
|
+
},
|
|
46
|
+
durationMs: Math.round(durationMs),
|
|
47
|
+
ocraVersion: VERSION,
|
|
48
|
+
...perReviewer(report),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// Counted as `ocra metrics` counts one report: a failed or timed-out task is
|
|
52
|
+
// failed, a finding without a verification is unchecked, a dismissal outranks
|
|
53
|
+
// a fix, and a fixed or dismissed finding is attributed to the reviewer of a
|
|
54
|
+
// finding with its fingerprint in this report, or to none.
|
|
55
|
+
function perReviewer(report) {
|
|
56
|
+
const reviewers = new Map();
|
|
57
|
+
const of = (id) => {
|
|
58
|
+
let r = reviewers.get(id);
|
|
59
|
+
if (!r) {
|
|
60
|
+
r = {
|
|
61
|
+
tasks: 0,
|
|
62
|
+
failedTasks: 0,
|
|
63
|
+
findings: { critical: 0, warning: 0, suggestion: 0 },
|
|
64
|
+
costUsd: 0,
|
|
65
|
+
fixed: 0,
|
|
66
|
+
dismissed: 0,
|
|
67
|
+
};
|
|
68
|
+
reviewers.set(id, r);
|
|
69
|
+
}
|
|
70
|
+
return r;
|
|
71
|
+
};
|
|
72
|
+
for (const task of report.tasks) {
|
|
73
|
+
const r = of(task.reviewer);
|
|
74
|
+
r.tasks += 1;
|
|
75
|
+
if (task.status === "failed" || task.status === "timed_out")
|
|
76
|
+
r.failedTasks += 1;
|
|
77
|
+
r.costUsd += task.usage.costUsd;
|
|
78
|
+
}
|
|
79
|
+
const verification = Object.fromEntries(verificationSchema.options.map((v) => [v, 0]));
|
|
80
|
+
const reviewerOf = new Map();
|
|
81
|
+
for (const finding of report.findings) {
|
|
82
|
+
of(finding.reviewer).findings[finding.severity] += 1;
|
|
83
|
+
verification[finding.verification ?? "unchecked"] += 1;
|
|
84
|
+
if (!reviewerOf.has(finding.fingerprint))
|
|
85
|
+
reviewerOf.set(finding.fingerprint, finding.reviewer);
|
|
86
|
+
}
|
|
87
|
+
const dismissed = new Set((report.rereview?.dismissed ?? []).map((f) => f.fingerprint));
|
|
88
|
+
const fixed = new Set((report.rereview?.fixed ?? []).map((f) => f.fingerprint).filter((fp) => !dismissed.has(fp)));
|
|
89
|
+
for (const fp of fixed) {
|
|
90
|
+
const reviewer = reviewerOf.get(fp);
|
|
91
|
+
if (reviewer)
|
|
92
|
+
of(reviewer).fixed += 1;
|
|
93
|
+
}
|
|
94
|
+
for (const fp of dismissed) {
|
|
95
|
+
const reviewer = reviewerOf.get(fp);
|
|
96
|
+
if (reviewer)
|
|
97
|
+
of(reviewer).dismissed += 1;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
|
|
101
|
+
verification,
|
|
102
|
+
outcomes: { fixed: fixed.size, dismissed: dismissed.size },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** The repository's origin without credentials, or its root path when it has none. */
|
|
106
|
+
async function repositoryId(root) {
|
|
107
|
+
try {
|
|
108
|
+
const { stdout } = await run("git", ["-C", root, "remote", "get-url", "origin"], {
|
|
109
|
+
timeout: 10_000,
|
|
110
|
+
});
|
|
111
|
+
const url = stdout.trim();
|
|
112
|
+
return url
|
|
113
|
+
.replace(/^[a-z+]+:\/\/[^@/]*@/i, (m) => m.slice(0, m.indexOf("//") + 2))
|
|
114
|
+
.replace(/\.git$/, "")
|
|
115
|
+
.toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return root;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** This machine's random salt, kept beside the credentials, made on first use. */
|
|
122
|
+
async function machineSalt(credentialsPath) {
|
|
123
|
+
const path = join(dirname(credentialsPath), "upload-salt");
|
|
124
|
+
try {
|
|
125
|
+
const existing = (await readFile(path, "utf8")).trim();
|
|
126
|
+
if (/^[0-9a-f]{64}$/.test(existing))
|
|
127
|
+
return existing;
|
|
128
|
+
}
|
|
129
|
+
catch { }
|
|
130
|
+
const fresh = randomBytes(32).toString("hex");
|
|
131
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
132
|
+
await writeFile(path, `${fresh}\n`, { mode: 0o600 });
|
|
133
|
+
return fresh;
|
|
134
|
+
}
|
|
135
|
+
/** The repository's hash, salted with the account's salt when given, else this machine's. */
|
|
136
|
+
export async function repoHash(root, credentialsPath, accountSalt) {
|
|
137
|
+
const salt = accountSalt ?? (await machineSalt(credentialsPath));
|
|
138
|
+
return createHash("sha256")
|
|
139
|
+
.update(`${salt}:${await repositoryId(root)}`)
|
|
140
|
+
.digest("hex");
|
|
141
|
+
}
|
|
142
|
+
/** Whether this run talks to ocra Cloud at all: signed in, not turned off. */
|
|
143
|
+
export async function cloudEnabled(deps) {
|
|
144
|
+
return (deps.env.OCRA_CLOUD !== "off" && (await readCredentials(deps.credentialsPath)) !== undefined);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Sends the counts, and the findings when given; answers how many findings
|
|
148
|
+
* the server kept, or undefined when it took nothing. A failure is a
|
|
149
|
+
* warning, never a failed review.
|
|
150
|
+
*/
|
|
151
|
+
export async function uploadReview(upload, deps, warn, findings) {
|
|
152
|
+
try {
|
|
153
|
+
const session = await cloudSession(deps);
|
|
154
|
+
if (!session)
|
|
155
|
+
return undefined;
|
|
156
|
+
const res = await deps.fetch(`${session.server}/api/reviews`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
"content-type": "application/json",
|
|
160
|
+
authorization: `Bearer ${session.access_token}`,
|
|
161
|
+
"user-agent": `ocra/${VERSION}`,
|
|
162
|
+
},
|
|
163
|
+
// The counts stay in `findings`; the shared list goes in `findingList`.
|
|
164
|
+
body: JSON.stringify(findings ? { ...upload, findingList: findings } : upload),
|
|
165
|
+
signal: AbortSignal.timeout(15_000),
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) {
|
|
168
|
+
warn(`ocra Cloud did not take the review's counts (HTTP ${res.status})`);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const answer = (await res.json().catch(() => ({})));
|
|
172
|
+
const kept = answer.findings;
|
|
173
|
+
return { findings: typeof kept === "number" && Number.isInteger(kept) && kept > 0 ? kept : 0 };
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
warn(`could not send the review's counts to ocra Cloud: ${error instanceof Error ? error.name : "error"}`);
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=cloud-upload.js.map
|
package/dist/review/command.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
|
+
import type { CloudDeps } from "../cloud.js";
|
|
3
|
+
import type { NpmRunner } from "../plugins-command.js";
|
|
2
4
|
import type { ReviewArgs } from "./args.js";
|
|
3
5
|
import { type Output } from "./progress.js";
|
|
4
6
|
import type { RuntimeLoaders } from "./runtimes.js";
|
|
@@ -20,6 +22,8 @@ export interface ReviewDeps {
|
|
|
20
22
|
now(): number;
|
|
21
23
|
heartbeatMs: number;
|
|
22
24
|
fetch?: typeof fetch;
|
|
25
|
+
cloud?: CloudDeps;
|
|
26
|
+
npm?: NpmRunner;
|
|
23
27
|
onInterrupt?(handler: () => void): () => void;
|
|
24
28
|
}
|
|
25
29
|
export declare function reviewCommand(args: ReviewArgs, io: {
|