@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/config.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { OcraError, } from "@open-cr-agent/core";
|
|
4
|
+
import { AGENT_ROLES, EFFORT_LEVELS, RISK_TIERS } from "@open-cr-agent/core/internal";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { fetchRemoteConfig, mergeConfig } from "./remote-config.js";
|
|
6
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);
|
|
7
11
|
const modelChain = z
|
|
8
12
|
.union([z.string().min(1), z.array(z.string().min(1)).min(1)])
|
|
9
13
|
.transform((value) => (typeof value === "string" ? [value] : value));
|
|
@@ -39,15 +43,27 @@ const providerSchema = z
|
|
|
39
43
|
models: z
|
|
40
44
|
.record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
|
|
41
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(),
|
|
42
49
|
})
|
|
43
50
|
.strict();
|
|
51
|
+
// The runtimes ocra ships; a plugin may register another name, so the
|
|
52
|
+
// schema lists these for editors and still accepts any other string.
|
|
53
|
+
const BUILTIN_RUNTIMES = ["opencode", "direct"];
|
|
44
54
|
const configSchema = z
|
|
45
55
|
.object({
|
|
56
|
+
$schema: z.string().optional(),
|
|
46
57
|
models: z
|
|
47
58
|
.object({ top: modelChain, standard: modelChain, light: modelChain })
|
|
48
59
|
.partial()
|
|
49
60
|
.strict()
|
|
50
61
|
.default({}),
|
|
62
|
+
effort: z
|
|
63
|
+
.object({ top: effort, standard: effort, light: effort })
|
|
64
|
+
.partial()
|
|
65
|
+
.strict()
|
|
66
|
+
.default({}),
|
|
51
67
|
concurrency: z.number().int().min(1).max(32).optional(),
|
|
52
68
|
taskTimeoutMinutes: z.number().positive().optional(),
|
|
53
69
|
runTimeoutMinutes: z.number().positive().optional(),
|
|
@@ -55,6 +71,20 @@ const configSchema = z
|
|
|
55
71
|
judge: z.boolean().optional(),
|
|
56
72
|
maxCostUsd: z.number().positive().optional(),
|
|
57
73
|
maxTasks: z.number().int().min(1).max(1_000).optional(),
|
|
74
|
+
// Unset: each provider's default. A seed makes sampling repeatable only
|
|
75
|
+
// where the provider supports it.
|
|
76
|
+
sampling: z
|
|
77
|
+
.object({
|
|
78
|
+
temperature: z.number().min(0).max(2),
|
|
79
|
+
seed: z
|
|
80
|
+
.number()
|
|
81
|
+
.int()
|
|
82
|
+
.min(0)
|
|
83
|
+
.max(2 ** 31 - 1),
|
|
84
|
+
})
|
|
85
|
+
.partial()
|
|
86
|
+
.strict()
|
|
87
|
+
.optional(),
|
|
58
88
|
github: z
|
|
59
89
|
.object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
|
|
60
90
|
.partial()
|
|
@@ -62,36 +92,65 @@ const configSchema = z
|
|
|
62
92
|
.default({}),
|
|
63
93
|
include: z.array(z.string().min(1)).default([]),
|
|
64
94
|
exclude: z.array(z.string().min(1)).default([]),
|
|
65
|
-
runtime: z.string().min(1).default("opencode"),
|
|
95
|
+
runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
|
|
66
96
|
plugins: z.array(z.string().min(1)).default([]),
|
|
67
97
|
reviewers: z
|
|
68
98
|
.record(z.string(), z
|
|
69
99
|
.object({
|
|
70
100
|
enabled: z.boolean(),
|
|
71
101
|
minTier: z.enum(RISK_TIERS),
|
|
102
|
+
effort,
|
|
72
103
|
})
|
|
73
104
|
.partial()
|
|
74
105
|
.strict())
|
|
75
106
|
.default({}),
|
|
107
|
+
roles: z
|
|
108
|
+
.partialRecord(z.enum(AGENT_ROLES), z.object({ effort }).partial().strict())
|
|
109
|
+
.default({}),
|
|
76
110
|
pluginSettings: z.record(z.string(), z.unknown()).default({}),
|
|
77
111
|
providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
|
|
78
112
|
extends: z.string().min(1).optional(),
|
|
79
113
|
})
|
|
80
114
|
.strict();
|
|
115
|
+
export const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
116
|
+
// JSON Schema (draft 2020-12) of .ocra/config.json, for editors: what a
|
|
117
|
+
// file may say, so defaults are optional and model chains take either form.
|
|
118
|
+
export function configJsonSchema() {
|
|
119
|
+
const generated = z.toJSONSchema(configSchema, { target: "draft-2020-12", io: "input" });
|
|
120
|
+
const { $schema, ...rest } = generated;
|
|
121
|
+
return {
|
|
122
|
+
$schema,
|
|
123
|
+
$id: CONFIG_SCHEMA_ID,
|
|
124
|
+
title: "ocra configuration",
|
|
125
|
+
description: "The .ocra/config.json file of a repository, or a file passed with --config. Unknown keys are rejected.",
|
|
126
|
+
...rest,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
81
129
|
const MODEL_ENV = {
|
|
82
130
|
top: "OCRA_MODEL_TOP",
|
|
83
131
|
standard: "OCRA_MODEL_STANDARD",
|
|
84
132
|
light: "OCRA_MODEL_LIGHT",
|
|
85
133
|
};
|
|
86
|
-
|
|
134
|
+
const EFFORT_ENV = {
|
|
135
|
+
top: "OCRA_EFFORT_TOP",
|
|
136
|
+
standard: "OCRA_EFFORT_STANDARD",
|
|
137
|
+
light: "OCRA_EFFORT_LIGHT",
|
|
138
|
+
};
|
|
139
|
+
export class ConfigError extends OcraError {
|
|
140
|
+
constructor(message, options) {
|
|
141
|
+
super("CONFIG_INVALID", message, options);
|
|
142
|
+
this.name = "ConfigError";
|
|
143
|
+
}
|
|
87
144
|
}
|
|
88
145
|
// Without the repository's file, only defaults and environment variables
|
|
89
146
|
// apply: that file can name plugins, and plugins run code.
|
|
90
147
|
export async function loadConfig(root, env, options = { repository: true }) {
|
|
91
148
|
const read = options.read ?? ((path) => readConfigFromDisk(root, path));
|
|
92
|
-
const { config: parsed, rules } = options.
|
|
93
|
-
? await readConfigFile(
|
|
94
|
-
:
|
|
149
|
+
const { config: parsed, rules } = options.file
|
|
150
|
+
? await readConfigFile(ownFile(options.file), options, options.file)
|
|
151
|
+
: options.repository
|
|
152
|
+
? await readConfigFile(read, options, CONFIG_PATH)
|
|
153
|
+
: { config: configSchema.parse({}), rules: [] };
|
|
95
154
|
const models = {};
|
|
96
155
|
for (const [tier, chain] of Object.entries(parsed.models)) {
|
|
97
156
|
if (chain)
|
|
@@ -105,10 +164,29 @@ export async function loadConfig(root, env, options = { repository: true }) {
|
|
|
105
164
|
if (chain.length > 0)
|
|
106
165
|
models[tier] = chain;
|
|
107
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
|
+
}
|
|
108
180
|
for (const model of unpriced(parsed.providers, models)) {
|
|
109
181
|
options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
|
|
110
182
|
}
|
|
111
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
...parsed,
|
|
185
|
+
models,
|
|
186
|
+
effort: efforts,
|
|
187
|
+
providers: toProviders(parsed.providers),
|
|
188
|
+
rules,
|
|
189
|
+
};
|
|
112
190
|
}
|
|
113
191
|
function toProviders(declared) {
|
|
114
192
|
return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
|
|
@@ -116,6 +194,7 @@ function toProviders(declared) {
|
|
|
116
194
|
{
|
|
117
195
|
baseUrl: p.baseUrl,
|
|
118
196
|
...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
|
|
197
|
+
...(p.effort ? { effort: p.effort } : {}),
|
|
119
198
|
models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
|
|
120
199
|
model,
|
|
121
200
|
{
|
|
@@ -139,6 +218,16 @@ function unpriced(providers, models) {
|
|
|
139
218
|
// The user's own checkout, read like any local file (links followed): unlike
|
|
140
219
|
// the review's reads, which never follow links (vcs-local), this is trusted
|
|
141
220
|
// configuration, and --no-repo-config skips it for code that is not.
|
|
221
|
+
function ownFile(file) {
|
|
222
|
+
return async () => {
|
|
223
|
+
try {
|
|
224
|
+
return await readFile(file, "utf8");
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
throw new ConfigError(`cannot read ${file}: ${error.message}`, { cause: error });
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
142
231
|
async function readConfigFromDisk(root, path) {
|
|
143
232
|
try {
|
|
144
233
|
return await readFile(join(root, path), "utf8");
|
|
@@ -149,7 +238,7 @@ async function readConfigFromDisk(root, path) {
|
|
|
149
238
|
throw error;
|
|
150
239
|
}
|
|
151
240
|
}
|
|
152
|
-
async function readConfigFile(read, options) {
|
|
241
|
+
async function readConfigFile(read, options, label) {
|
|
153
242
|
const text = await read(CONFIG_PATH);
|
|
154
243
|
if (text === undefined)
|
|
155
244
|
return { config: configSchema.parse({}), rules: [] };
|
|
@@ -158,11 +247,13 @@ async function readConfigFile(read, options) {
|
|
|
158
247
|
data = JSON.parse(text);
|
|
159
248
|
}
|
|
160
249
|
catch (error) {
|
|
161
|
-
throw new ConfigError(`${
|
|
250
|
+
throw new ConfigError(`${label} is not valid JSON: ${error.message}`, {
|
|
251
|
+
cause: error,
|
|
252
|
+
});
|
|
162
253
|
}
|
|
163
254
|
const local = configSchema.safeParse(data);
|
|
164
255
|
if (!local.success)
|
|
165
|
-
throw new ConfigError(`${
|
|
256
|
+
throw new ConfigError(`${label} is invalid: ${z.prettifyError(local.error)}`);
|
|
166
257
|
if (!local.data.extends)
|
|
167
258
|
return { config: local.data, rules: [] };
|
|
168
259
|
// A shared configuration that cannot be loaded costs its defaults, not the
|
|
@@ -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:");
|
package/dist/review/progress.js
CHANGED
|
@@ -33,7 +33,7 @@ export class ProgressPrinter {
|
|
|
33
33
|
function describe(event) {
|
|
34
34
|
switch (event.type) {
|
|
35
35
|
case "run_started":
|
|
36
|
-
return `Reviewing: ${event.changeRequest.title}`;
|
|
36
|
+
return `Reviewing: ${event.changeRequest.title} · run ${event.runId}`;
|
|
37
37
|
case "files_selected":
|
|
38
38
|
return `${event.selected} file(s) selected, ${event.excluded} excluded · risk tier: ${event.tier}`;
|
|
39
39
|
case "files_bundled":
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Sampling } from "@open-cr-agent/core";
|
|
2
|
+
import type { ReviewArgs } from "./args.js";
|
|
3
|
+
import type { CliConfig } from "./config.js";
|
|
4
|
+
export declare function requestedSampling(config: CliConfig, args: ReviewArgs): Sampling;
|
|
5
|
+
export declare function configHash(config: CliConfig, args: ReviewArgs): string;
|
|
6
|
+
//# sourceMappingURL=provenance.d.ts.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { stableHash } from "@open-cr-agent/core/internal";
|
|
2
|
+
// The sampling a review asks for: the configuration's, with --temperature
|
|
3
|
+
// and --seed on top.
|
|
4
|
+
export function requestedSampling(config, args) {
|
|
5
|
+
const temperature = args.temperature ?? config.sampling?.temperature;
|
|
6
|
+
const seed = args.seed ?? config.sampling?.seed;
|
|
7
|
+
return {
|
|
8
|
+
...(temperature === undefined ? {} : { temperature }),
|
|
9
|
+
...(seed === undefined ? {} : { seed }),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
// The effective configuration and the flags that change what is reviewed
|
|
13
|
+
// and how, so two runs can tell they were set up alike. Keys are names of
|
|
14
|
+
// environment variables, never their values; a password written into a
|
|
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.
|
|
17
|
+
export function configHash(config, args) {
|
|
18
|
+
const { sampling: _, providers, extends: shared, effort, roles, ...rest } = config;
|
|
19
|
+
return stableHash({
|
|
20
|
+
...rest,
|
|
21
|
+
...(Object.keys(effort).length > 0 ? { effort } : {}),
|
|
22
|
+
...(Object.keys(roles).length > 0 ? { roles } : {}),
|
|
23
|
+
...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
|
|
24
|
+
providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
|
|
25
|
+
id,
|
|
26
|
+
{ ...p, baseUrl: withoutCredentials(p.baseUrl) },
|
|
27
|
+
])),
|
|
28
|
+
flags: { reviewers: args.reviewers, ultra: args.ultra, maxCostUsd: args.maxCostUsd },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function withoutCredentials(value) {
|
|
32
|
+
if (!URL.canParse(value))
|
|
33
|
+
return value;
|
|
34
|
+
const url = new URL(value);
|
|
35
|
+
if (url.username === "" && url.password === "")
|
|
36
|
+
return value;
|
|
37
|
+
url.username = "";
|
|
38
|
+
url.password = "";
|
|
39
|
+
return url.toString();
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=provenance.js.map
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export declare const remoteConfigSchema: z.ZodObject<{
|
|
3
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
3
4
|
models: z.ZodOptional<z.ZodRecord<z.ZodEnum<{
|
|
4
5
|
light: "light";
|
|
5
6
|
standard: "standard";
|
|
6
7
|
top: "top";
|
|
7
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>>;
|
|
8
14
|
concurrency: z.ZodOptional<z.ZodUnknown>;
|
|
9
15
|
taskTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
10
16
|
runTimeoutMinutes: z.ZodOptional<z.ZodUnknown>;
|
|
@@ -15,6 +21,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
|
|
|
15
21
|
include: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
16
22
|
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
17
23
|
reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
24
|
+
roles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
18
25
|
github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
19
26
|
providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
20
27
|
rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
3
|
+
import { repoRuleSchema } from "@open-cr-agent/core/internal";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
const MAX_BYTES = 256 * 1024;
|
|
5
6
|
const TIMEOUT_MS = 10_000;
|
|
@@ -8,7 +9,9 @@ const TIMEOUT_MS = 10_000;
|
|
|
8
9
|
// is fetched from outside the repository.
|
|
9
10
|
export const remoteConfigSchema = z
|
|
10
11
|
.object({
|
|
12
|
+
$schema: z.string().optional(),
|
|
11
13
|
models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
14
|
+
effort: z.partialRecord(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
|
|
12
15
|
concurrency: z.unknown().optional(),
|
|
13
16
|
taskTimeoutMinutes: z.unknown().optional(),
|
|
14
17
|
runTimeoutMinutes: z.unknown().optional(),
|
|
@@ -19,6 +22,7 @@ export const remoteConfigSchema = z
|
|
|
19
22
|
include: z.array(z.string()).optional(),
|
|
20
23
|
exclude: z.array(z.string()).optional(),
|
|
21
24
|
reviewers: z.record(z.string(), z.unknown()).optional(),
|
|
25
|
+
roles: z.record(z.string(), z.unknown()).optional(),
|
|
22
26
|
github: z.record(z.string(), z.unknown()).optional(),
|
|
23
27
|
providers: z.record(z.string(), z.unknown()).optional(),
|
|
24
28
|
rules: z.array(repoRuleSchema).max(500).optional(),
|
|
@@ -29,7 +33,7 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
29
33
|
const [address = "", fragment] = spec.split("#", 2);
|
|
30
34
|
const url = new URL(address);
|
|
31
35
|
if (url.protocol !== "https:")
|
|
32
|
-
throw new
|
|
36
|
+
throw new OcraError("CONFIG_INVALID", `extends must be an https URL, got ${url.protocol}`);
|
|
33
37
|
const pinned = fragment?.startsWith("sha256=")
|
|
34
38
|
? fragment.slice("sha256=".length).toLowerCase()
|
|
35
39
|
: undefined;
|
|
@@ -39,21 +43,21 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
39
43
|
redirect: "error",
|
|
40
44
|
});
|
|
41
45
|
if (!response.ok)
|
|
42
|
-
throw new
|
|
46
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} answered ${response.status}`);
|
|
43
47
|
const body = await readLimited(response, MAX_BYTES, url.href);
|
|
44
48
|
if (pinned !== undefined) {
|
|
45
49
|
const actual = createHash("sha256").update(body).digest("hex");
|
|
46
50
|
if (actual !== pinned)
|
|
47
|
-
throw new
|
|
51
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} does not match its pinned sha256`);
|
|
48
52
|
}
|
|
49
53
|
const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
|
|
50
54
|
if (!parsed.success)
|
|
51
|
-
throw new
|
|
55
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
|
|
52
56
|
// A provider decides where the code under review goes: unpinned, whoever
|
|
53
57
|
// serves the file could send it to an endpoint of their choosing.
|
|
54
58
|
if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
|
|
55
59
|
const digest = createHash("sha256").update(body).digest("hex");
|
|
56
|
-
throw new
|
|
60
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
|
|
57
61
|
}
|
|
58
62
|
return parsed.data;
|
|
59
63
|
}
|
|
@@ -71,7 +75,7 @@ async function readLimited(response, limit, name) {
|
|
|
71
75
|
size += value.byteLength;
|
|
72
76
|
if (size > limit) {
|
|
73
77
|
await reader.cancel();
|
|
74
|
-
throw new
|
|
78
|
+
throw new OcraError("CONFIG_INVALID", `${name} is larger than 256 KB`);
|
|
75
79
|
}
|
|
76
80
|
chunks.push(value);
|
|
77
81
|
}
|
|
@@ -81,7 +85,7 @@ async function readLimited(response, limit, name) {
|
|
|
81
85
|
export function mergeConfig(remote, local) {
|
|
82
86
|
const { rules: _rules, ...settings } = remote;
|
|
83
87
|
const merged = { ...settings, ...local };
|
|
84
|
-
for (const key of ["models", "reviewers", "github", "providers"]) {
|
|
88
|
+
for (const key of ["models", "effort", "reviewers", "roles", "github", "providers"]) {
|
|
85
89
|
const a = settings[key];
|
|
86
90
|
const b = local[key];
|
|
87
91
|
if (a !== undefined || b !== undefined)
|
package/dist/review/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type ReviewReport
|
|
1
|
+
import { type ReviewReport } from "@open-cr-agent/core";
|
|
2
|
+
import { serializeOutput } from "@open-cr-agent/core/internal";
|
|
2
3
|
export declare function renderJson(report: ReviewReport): string;
|
|
3
4
|
export declare const safeJson: typeof serializeOutput;
|
|
4
5
|
export declare function renderText(report: ReviewReport, sessionDir?: string): string;
|
package/dist/review/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { coverageGaps,
|
|
1
|
+
import { coverageGaps, toReportOutput, } from "@open-cr-agent/core";
|
|
2
|
+
import { serializeOutput } from "@open-cr-agent/core/internal";
|
|
2
3
|
import { forTerminal } from "./terminal.js";
|
|
3
4
|
const SEVERITIES = ["critical", "warning", "suggestion"];
|
|
4
5
|
const VERIFICATION = {
|
|
@@ -86,8 +87,7 @@ export function renderText(report, sessionDir) {
|
|
|
86
87
|
}
|
|
87
88
|
for (const warning of report.warnings)
|
|
88
89
|
lines.push(`Warning: ${warning}`);
|
|
89
|
-
|
|
90
|
-
lines.push(`Session: ${sessionDir}`);
|
|
90
|
+
lines.push(`Run: ${report.runId}${sessionDir ? ` (${sessionDir})` : ""}`);
|
|
91
91
|
return forTerminal(`${lines.join("\n")}\n`);
|
|
92
92
|
}
|
|
93
93
|
function emptyMessage(report, nothingReviewed, notReviewed) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
|
+
export type RuntimeLoaders = Readonly<Record<string, () => Promise<OcraPlugin>>>;
|
|
3
|
+
export declare const BUILTIN_RUNTIMES: RuntimeLoaders;
|
|
4
|
+
export declare function loadRuntime(runtime: string, packageName: string, load: () => Promise<OcraPlugin>): Promise<OcraPlugin>;
|
|
5
|
+
//# sourceMappingURL=runtimes.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
3
|
+
export const BUILTIN_RUNTIMES = {
|
|
4
|
+
opencode: () => loadRuntime("opencode", "@open-cr-agent/runtime-opencode", async () => (await import("@open-cr-agent/runtime-opencode")).opencodeRuntimePlugin),
|
|
5
|
+
direct: () => loadRuntime("direct", "@open-cr-agent/runtime-direct", async () => (await import("@open-cr-agent/runtime-direct")).directRuntimePlugin),
|
|
6
|
+
};
|
|
7
|
+
export async function loadRuntime(runtime, packageName, load) {
|
|
8
|
+
try {
|
|
9
|
+
return await load();
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (!isMissingPackage(error, packageName))
|
|
13
|
+
throw error;
|
|
14
|
+
const other = runtime === "direct" ? "opencode" : "direct";
|
|
15
|
+
throw new OcraError("RUNTIME_START_FAILED", `The ${runtime} runtime needs ${packageName}, which is not installed ` +
|
|
16
|
+
"(an optional dependency of @open-cr-agent/cli, left out by --omit=optional). " +
|
|
17
|
+
`Install it next to ocra with npm install ${packageName}@${VERSION} (-g for a global ocra), ` +
|
|
18
|
+
`or set "runtime": "${other}" in the configuration.`, { cause: error });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Node reports a bare specifier it cannot resolve as ERR_MODULE_NOT_FOUND,
|
|
22
|
+
// naming the package; a module missing inside the package names another.
|
|
23
|
+
function isMissingPackage(error, packageName) {
|
|
24
|
+
if (!(error instanceof Error))
|
|
25
|
+
return false;
|
|
26
|
+
const code = error.code;
|
|
27
|
+
return code === "ERR_MODULE_NOT_FOUND" && error.message.includes(`'${packageName}'`);
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=runtimes.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { parseSarifLog } from "@open-cr-agent/core";
|
|
4
|
+
import { ConfigError } from "./config.js";
|
|
5
|
+
// A log is untrusted input like everything else that reaches the review; it
|
|
6
|
+
// is bounded before it is parsed.
|
|
7
|
+
export const MAX_SARIF_BYTES = 20 * 1024 * 1024;
|
|
8
|
+
export async function loadSarifLogs(paths, cwd) {
|
|
9
|
+
const logs = [];
|
|
10
|
+
for (const path of paths) {
|
|
11
|
+
const file = resolve(cwd, path);
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
const { size } = await stat(file);
|
|
15
|
+
if (size > MAX_SARIF_BYTES) {
|
|
16
|
+
throw new ConfigError(`--import-sarif ${path}: larger than ${MAX_SARIF_BYTES} bytes`);
|
|
17
|
+
}
|
|
18
|
+
text = await readFile(file, "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof ConfigError)
|
|
22
|
+
throw error;
|
|
23
|
+
throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
logs.push(parseSarifLog(text));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new ConfigError(`--import-sarif ${path}: ${error.message}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return logs;
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=sarif-input.js.map
|
package/dist/review/sarif.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { coverageGaps,
|
|
1
|
+
import { coverageGaps, } from "@open-cr-agent/core";
|
|
2
|
+
import { serializeOutput } from "@open-cr-agent/core/internal";
|
|
2
3
|
// `--format sarif`: the review as a SARIF 2.1.0 log, for code scanning and
|
|
3
4
|
// security dashboards. One rule per reviewer category, one result per
|
|
4
5
|
// finding. Findings an earlier review reported in files this run did not
|
|
@@ -26,17 +27,20 @@ export function renderSarif(report, version) {
|
|
|
26
27
|
version: "2.1.0",
|
|
27
28
|
runs: [
|
|
28
29
|
{
|
|
30
|
+
automationDetails: { id: `ocra/${report.runId}` },
|
|
29
31
|
tool: {
|
|
30
32
|
driver: {
|
|
31
33
|
name: "ocra",
|
|
32
|
-
informationUri: "https://
|
|
34
|
+
informationUri: "https://ocracloud.com",
|
|
33
35
|
version,
|
|
34
36
|
semanticVersion: version,
|
|
35
37
|
rules: categories.map((id) => ({
|
|
36
38
|
id,
|
|
37
39
|
name: id,
|
|
38
40
|
shortDescription: {
|
|
39
|
-
text: id === CARRIED
|
|
41
|
+
text: id === CARRIED
|
|
42
|
+
? CARRIED_DESCRIPTION
|
|
43
|
+
: `Findings of the ${id} reviewer or analyzer`,
|
|
40
44
|
},
|
|
41
45
|
properties: { tags: [id] },
|
|
42
46
|
})),
|
|
@@ -100,6 +104,8 @@ function fromFinding(f, index) {
|
|
|
100
104
|
verification: f.verification ?? "unchecked",
|
|
101
105
|
status: f.status === "unfixed" ? "unfixed" : "new",
|
|
102
106
|
...(f.lowConfidence ? { lowConfidence: true } : {}),
|
|
107
|
+
task: f.provenance.task,
|
|
108
|
+
...(f.provenance.model === undefined ? {} : { model: f.provenance.model }),
|
|
103
109
|
},
|
|
104
110
|
};
|
|
105
111
|
}
|
package/dist/review/target.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type OcraPlugin, type PluginRegistry, type VcsAdapter } from "@open-cr-agent/core";
|
|
2
2
|
import type { MergeRequestTarget, PullRequestTarget, ReviewArgs } from "./args.js";
|
|
3
3
|
import { type CliConfig } from "./config.js";
|
|
4
4
|
export interface ReviewTarget {
|
|
@@ -11,7 +11,7 @@ export interface ReviewTarget {
|
|
|
11
11
|
}
|
|
12
12
|
type Env = Readonly<Record<string, string | undefined>>;
|
|
13
13
|
export declare function localTarget(args: ReviewArgs, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch): Promise<ReviewTarget>;
|
|
14
|
-
export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
|
|
15
|
-
export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean): Promise<ReviewTarget>;
|
|
14
|
+
export declare function pullRequestTarget(pr: PullRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
|
|
15
|
+
export declare function mergeRequestTarget(mr: MergeRequestTarget, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch, ignoreRepoConfig?: boolean, configFile?: string): Promise<ReviewTarget>;
|
|
16
16
|
export {};
|
|
17
17
|
//# sourceMappingURL=target.d.ts.map
|
package/dist/review/target.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { OcraError, } from "@open-cr-agent/core";
|
|
3
|
+
import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github/internal";
|
|
4
|
+
import { GitLabApi } from "@open-cr-agent/vcs-gitlab/internal";
|
|
5
|
+
import { ensureCommits, filesChangedSince, LocalGitAdapter, } from "@open-cr-agent/vcs-local/internal";
|
|
4
6
|
import { ConfigError, loadConfig } from "./config.js";
|
|
5
7
|
import { loadExternalPlugins } from "./plugins.js";
|
|
6
8
|
export async function localTarget(args, cwd, root, env, warn, fetchImpl) {
|
|
7
9
|
const config = await loadConfig(root, env, {
|
|
8
10
|
repository: !args.ignoreRepoConfig,
|
|
11
|
+
...(args.configFile ? { file: args.configFile } : {}),
|
|
9
12
|
warn,
|
|
10
13
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
11
14
|
});
|
|
12
15
|
return {
|
|
13
16
|
config,
|
|
14
|
-
|
|
17
|
+
// Plugins named by the user's own file are resolved from where it is.
|
|
18
|
+
plugins: await loadExternalPlugins(config.plugins, args.configFile ? dirname(args.configFile) : root),
|
|
15
19
|
createVcs: (registry) => registry.createVcs("local", { cwd, target: args.target }),
|
|
16
20
|
...(args.ignoreRepoConfig ? { readTrusted: untrustedTreeReader(args, cwd) } : {}),
|
|
17
21
|
publish: false,
|
|
@@ -29,10 +33,11 @@ function untrustedTreeReader(args, cwd) {
|
|
|
29
33
|
}
|
|
30
34
|
// The pull request's own files are untrusted: configuration, guidelines and
|
|
31
35
|
// rules come from its base commit, and repository plugins never load.
|
|
32
|
-
export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
|
|
36
|
+
export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
|
|
33
37
|
const token = env.GITHUB_TOKEN ?? env.GH_TOKEN;
|
|
34
|
-
if (!token)
|
|
35
|
-
throw new
|
|
38
|
+
if (!token) {
|
|
39
|
+
throw new OcraError("CONFIG_CREDENTIALS_MISSING", "--pr needs a GitHub token in GITHUB_TOKEN or GH_TOKEN");
|
|
40
|
+
}
|
|
36
41
|
const [owner = "", repo = ""] = (pr.repo ?? (await repositoryName(root, env))).split("/");
|
|
37
42
|
const apiOptions = { token, ...(env.GITHUB_API_URL ? { baseUrl: env.GITHUB_API_URL } : {}) };
|
|
38
43
|
const api = new GitHubApi({ owner, repo }, fetchImpl ? { ...apiOptions, fetch: fetchImpl } : apiOptions);
|
|
@@ -43,11 +48,12 @@ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ign
|
|
|
43
48
|
const config = await loadConfig(root, env, {
|
|
44
49
|
repository: !ignoreRepoConfig,
|
|
45
50
|
read: readTrusted,
|
|
51
|
+
...(configFile ? { file: configFile } : {}),
|
|
46
52
|
warn,
|
|
47
53
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
48
54
|
});
|
|
49
55
|
if (config.plugins.length > 0) {
|
|
50
|
-
warn("plugins in
|
|
56
|
+
warn("plugins in the configuration are not loaded for pull requests");
|
|
51
57
|
}
|
|
52
58
|
const code = new LocalGitAdapter({
|
|
53
59
|
cwd,
|
|
@@ -81,10 +87,10 @@ const GITLAB_API = "https://gitlab.com/api/v4";
|
|
|
81
87
|
// target branch the merge request was based on, and repository plugins never
|
|
82
88
|
// load. GitLab CI sets CI_API_V4_URL and CI_PROJECT_ID; GITLAB_TOKEN must be
|
|
83
89
|
// a token that may write notes, which CI_JOB_TOKEN may not.
|
|
84
|
-
export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false) {
|
|
90
|
+
export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ignoreRepoConfig = false, configFile) {
|
|
85
91
|
const token = env.GITLAB_TOKEN;
|
|
86
92
|
if (!token) {
|
|
87
|
-
throw new
|
|
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)");
|
|
88
94
|
}
|
|
89
95
|
const origin = await originUrl(root);
|
|
90
96
|
const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
|
|
@@ -96,19 +102,21 @@ export async function mergeRequestTarget(mr, cwd, root, env, warn, fetchImpl, ig
|
|
|
96
102
|
});
|
|
97
103
|
const merge = await api.getMergeRequest(mr.iid);
|
|
98
104
|
const refs = merge.diff_refs;
|
|
99
|
-
if (!refs)
|
|
100
|
-
throw new
|
|
105
|
+
if (!refs) {
|
|
106
|
+
throw new OcraError("VCS_NOT_READY", `merge request !${mr.iid} has no diff yet; try again shortly`);
|
|
107
|
+
}
|
|
101
108
|
await ensureCommits(root, [refs.start_sha, refs.head_sha], [`refs/merge-requests/${mr.iid}/head`]);
|
|
102
109
|
const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: refs.start_sha } });
|
|
103
110
|
const readTrusted = (path) => base.readFile(path);
|
|
104
111
|
const config = await loadConfig(root, env, {
|
|
105
112
|
repository: !ignoreRepoConfig,
|
|
106
113
|
read: readTrusted,
|
|
114
|
+
...(configFile ? { file: configFile } : {}),
|
|
107
115
|
warn,
|
|
108
116
|
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
109
117
|
});
|
|
110
118
|
if (config.plugins.length > 0) {
|
|
111
|
-
warn("plugins in
|
|
119
|
+
warn("plugins in the configuration are not loaded for merge requests");
|
|
112
120
|
}
|
|
113
121
|
const code = new LocalGitAdapter({
|
|
114
122
|
cwd,
|
package/dist/review/terminal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isUnsafeCodePoint } from "@open-cr-agent/core";
|
|
1
|
+
import { isUnsafeCodePoint } from "@open-cr-agent/core/internal";
|
|
2
2
|
// Findings, commit messages and errors can carry text a diff planted; none of
|
|
3
3
|
// it may drive the user's terminal. C0 and C1 controls (ANSI and OSC escapes
|
|
4
4
|
// start with ESC or a C1 code) except tab and newline, carriage returns, and
|