@open-cr-agent/cli 0.2.0 → 0.3.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/dist/memory.js +2 -2
- package/dist/metrics.d.ts +47 -0
- package/dist/metrics.js +227 -0
- package/dist/review/args.d.ts +8 -2
- package/dist/review/args.js +41 -2
- package/dist/review/command.d.ts +2 -0
- package/dist/review/command.js +27 -11
- package/dist/review/config.d.ts +17 -3
- package/dist/review/config.js +60 -9
- package/dist/review/plan-render.d.ts +1 -1
- package/dist/review/progress.js +1 -1
- package/dist/review/provenance.d.ts +6 -0
- package/dist/review/provenance.js +38 -0
- package/dist/review/remote-config.d.ts +1 -0
- package/dist/review/remote-config.js +9 -7
- 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 +8 -2
- 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 +17 -5
- package/package.json +12 -7
package/dist/memory.js
CHANGED
|
@@ -2,8 +2,8 @@ import { constants, existsSync } from "node:fs";
|
|
|
2
2
|
import { lstat, mkdir, open, readdir, readFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
|
-
import { MEMORY_PATH, parseMemory, serializeMemory
|
|
6
|
-
import { findRepositoryRoot } from "@open-cr-agent/vcs-local";
|
|
5
|
+
import { MEMORY_PATH, parseMemory, serializeMemory } from "@open-cr-agent/core/internal";
|
|
6
|
+
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
7
7
|
import { UsageError } from "./review/args.js";
|
|
8
8
|
import { SESSIONS_DIR } from "./review/command.js";
|
|
9
9
|
import { forTerminal } from "./review/terminal.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Severity, type Verification } from "@open-cr-agent/core";
|
|
2
|
+
import type { Output } from "./review/progress.js";
|
|
3
|
+
export declare const METRICS_VERSION = 1;
|
|
4
|
+
export declare const METRICS_USAGE = "Usage: ocra metrics [options]\n\nCounts over the finished reviews in .ocra/sessions: runs, cost, findings,\nwhat reviewers later fixed or dismissed, per reviewer. Reads each session's\nreport.json (report version 1); nothing calls a model.\n\nOptions:\n --sessions <dir> Sessions directory (default: .ocra/sessions of this repository)\n --since <date> Only runs started at or after this ISO 8601 date\n --format <text|json> Output format (default: text); the JSON carries \"version\": 1\n -h, --help Show help\n";
|
|
5
|
+
export interface ReviewerMetrics {
|
|
6
|
+
tasks: number;
|
|
7
|
+
failedTasks: number;
|
|
8
|
+
findings: number;
|
|
9
|
+
costUsd: number;
|
|
10
|
+
fixed: number;
|
|
11
|
+
dismissed: number;
|
|
12
|
+
acceptanceRate: number | null;
|
|
13
|
+
}
|
|
14
|
+
export interface Metrics {
|
|
15
|
+
version: typeof METRICS_VERSION;
|
|
16
|
+
sessionsDir: string;
|
|
17
|
+
since?: string;
|
|
18
|
+
runs: {
|
|
19
|
+
total: number;
|
|
20
|
+
byVerdict: Record<string, number>;
|
|
21
|
+
incomplete: number;
|
|
22
|
+
unreadable: number;
|
|
23
|
+
};
|
|
24
|
+
cost: {
|
|
25
|
+
usd: number;
|
|
26
|
+
inputTokens: number;
|
|
27
|
+
outputTokens: number;
|
|
28
|
+
usdPerRun: number | null;
|
|
29
|
+
};
|
|
30
|
+
findings: {
|
|
31
|
+
reported: number;
|
|
32
|
+
unique: number;
|
|
33
|
+
bySeverity: Record<Severity, number>;
|
|
34
|
+
byVerification: Record<Verification, number>;
|
|
35
|
+
};
|
|
36
|
+
outcomes: {
|
|
37
|
+
fixed: number;
|
|
38
|
+
dismissed: number;
|
|
39
|
+
acceptanceRate: number | null;
|
|
40
|
+
};
|
|
41
|
+
reviewers: Record<string, ReviewerMetrics>;
|
|
42
|
+
}
|
|
43
|
+
export declare function metricsCommand(argv: string[], out: Output, cwd: string): Promise<number>;
|
|
44
|
+
export declare function sessionStart(id: string): Date | undefined;
|
|
45
|
+
export declare function collectMetrics(sessionsDir: string, since?: Date): Promise<Metrics>;
|
|
46
|
+
export declare function renderMetrics(m: Metrics): string;
|
|
47
|
+
//# sourceMappingURL=metrics.d.ts.map
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { reportOutputSchema, } from "@open-cr-agent/core";
|
|
5
|
+
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
6
|
+
import { UsageError } from "./review/args.js";
|
|
7
|
+
import { SESSIONS_DIR } from "./review/command.js";
|
|
8
|
+
import { forTerminal } from "./review/terminal.js";
|
|
9
|
+
export const METRICS_VERSION = 1;
|
|
10
|
+
export const METRICS_USAGE = `Usage: ocra metrics [options]
|
|
11
|
+
|
|
12
|
+
Counts over the finished reviews in ${SESSIONS_DIR}: runs, cost, findings,
|
|
13
|
+
what reviewers later fixed or dismissed, per reviewer. Reads each session's
|
|
14
|
+
report.json (report version 1); nothing calls a model.
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--sessions <dir> Sessions directory (default: ${SESSIONS_DIR} of this repository)
|
|
18
|
+
--since <date> Only runs started at or after this ISO 8601 date
|
|
19
|
+
--format <text|json> Output format (default: text); the JSON carries "version": ${METRICS_VERSION}
|
|
20
|
+
-h, --help Show help
|
|
21
|
+
`;
|
|
22
|
+
export async function metricsCommand(argv, out, cwd) {
|
|
23
|
+
const { values } = parseArgs({
|
|
24
|
+
args: argv,
|
|
25
|
+
allowPositionals: false,
|
|
26
|
+
strict: true,
|
|
27
|
+
options: {
|
|
28
|
+
sessions: { type: "string" },
|
|
29
|
+
since: { type: "string" },
|
|
30
|
+
format: { type: "string" },
|
|
31
|
+
help: { type: "boolean", short: "h" },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
if (values.help) {
|
|
35
|
+
out.write(METRICS_USAGE);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
const format = values.format ?? "text";
|
|
39
|
+
if (format !== "text" && format !== "json")
|
|
40
|
+
throw new UsageError("--format must be text or json");
|
|
41
|
+
const since = values.since === undefined ? undefined : parseSince(values.since);
|
|
42
|
+
const sessionsDir = values.sessions
|
|
43
|
+
? resolve(cwd, values.sessions)
|
|
44
|
+
: join(await findRepositoryRoot(cwd), SESSIONS_DIR);
|
|
45
|
+
const metrics = await collectMetrics(sessionsDir, since);
|
|
46
|
+
out.write(format === "json" ? `${JSON.stringify(metrics, null, 2)}\n` : renderMetrics(metrics));
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
function parseSince(text) {
|
|
50
|
+
const date = new Date(text);
|
|
51
|
+
if (Number.isNaN(date.getTime()))
|
|
52
|
+
throw new UsageError(`--since needs an ISO 8601 date, not "${text}"`);
|
|
53
|
+
return date;
|
|
54
|
+
}
|
|
55
|
+
// A session directory is named by its run id, whose first part is the start
|
|
56
|
+
// time (newRunId in core); that is what --since compares against.
|
|
57
|
+
export function sessionStart(id) {
|
|
58
|
+
const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/.exec(id);
|
|
59
|
+
if (!m)
|
|
60
|
+
return undefined;
|
|
61
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
62
|
+
return new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`);
|
|
63
|
+
}
|
|
64
|
+
export async function collectMetrics(sessionsDir, since) {
|
|
65
|
+
const names = (await readdir(sessionsDir).catch(() => []))
|
|
66
|
+
.filter((n) => !n.startsWith("."))
|
|
67
|
+
.filter((n) => {
|
|
68
|
+
const start = sessionStart(n);
|
|
69
|
+
return since === undefined || (start !== undefined && start >= since);
|
|
70
|
+
})
|
|
71
|
+
.sort();
|
|
72
|
+
const reports = [];
|
|
73
|
+
let unreadable = 0;
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
const report = await readReport(join(sessionsDir, name, "report.json"));
|
|
76
|
+
if (report)
|
|
77
|
+
reports.push(report);
|
|
78
|
+
else
|
|
79
|
+
unreadable += 1;
|
|
80
|
+
}
|
|
81
|
+
return aggregate(reports, {
|
|
82
|
+
sessionsDir,
|
|
83
|
+
unreadable,
|
|
84
|
+
...(since ? { since: since.toISOString() } : {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function readReport(path) {
|
|
88
|
+
let data;
|
|
89
|
+
try {
|
|
90
|
+
data = JSON.parse(await readFile(path, "utf8"));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const parsed = reportOutputSchema.safeParse(data);
|
|
96
|
+
return parsed.success ? parsed.data : undefined;
|
|
97
|
+
}
|
|
98
|
+
function aggregate(reports, meta) {
|
|
99
|
+
const byVerdict = {};
|
|
100
|
+
const bySeverity = { critical: 0, warning: 0, suggestion: 0 };
|
|
101
|
+
const byVerification = { confirmed: 0, uncertain: 0, unchecked: 0 };
|
|
102
|
+
const reviewers = new Map();
|
|
103
|
+
const reviewerOf = new Map();
|
|
104
|
+
const unique = new Set();
|
|
105
|
+
const fixed = new Set();
|
|
106
|
+
const dismissed = new Set();
|
|
107
|
+
let incomplete = 0;
|
|
108
|
+
let reported = 0;
|
|
109
|
+
let usd = 0;
|
|
110
|
+
let inputTokens = 0;
|
|
111
|
+
let outputTokens = 0;
|
|
112
|
+
const forReviewer = (id) => {
|
|
113
|
+
let r = reviewers.get(id);
|
|
114
|
+
if (!r) {
|
|
115
|
+
r = {
|
|
116
|
+
tasks: 0,
|
|
117
|
+
failedTasks: 0,
|
|
118
|
+
findings: 0,
|
|
119
|
+
costUsd: 0,
|
|
120
|
+
fixed: 0,
|
|
121
|
+
dismissed: 0,
|
|
122
|
+
acceptanceRate: null,
|
|
123
|
+
};
|
|
124
|
+
reviewers.set(id, r);
|
|
125
|
+
}
|
|
126
|
+
return r;
|
|
127
|
+
};
|
|
128
|
+
for (const report of reports) {
|
|
129
|
+
byVerdict[report.verdict] = (byVerdict[report.verdict] ?? 0) + 1;
|
|
130
|
+
if (report.coverage.some((c) => c.status === "failed" || c.status === "unreviewed"))
|
|
131
|
+
incomplete += 1;
|
|
132
|
+
usd += report.usage.costUsd;
|
|
133
|
+
inputTokens += report.usage.inputTokens;
|
|
134
|
+
outputTokens += report.usage.outputTokens;
|
|
135
|
+
for (const task of report.tasks) {
|
|
136
|
+
const r = forReviewer(task.reviewer);
|
|
137
|
+
r.tasks += 1;
|
|
138
|
+
if (task.status === "failed" || task.status === "timed_out")
|
|
139
|
+
r.failedTasks += 1;
|
|
140
|
+
r.costUsd += task.usage.costUsd;
|
|
141
|
+
}
|
|
142
|
+
for (const finding of report.findings) {
|
|
143
|
+
reported += 1;
|
|
144
|
+
unique.add(finding.fingerprint);
|
|
145
|
+
bySeverity[finding.severity] += 1;
|
|
146
|
+
byVerification[finding.verification] += 1;
|
|
147
|
+
forReviewer(finding.reviewer).findings += 1;
|
|
148
|
+
if (!reviewerOf.has(finding.fingerprint))
|
|
149
|
+
reviewerOf.set(finding.fingerprint, finding.reviewer);
|
|
150
|
+
}
|
|
151
|
+
for (const f of report.rereview?.fixed ?? [])
|
|
152
|
+
fixed.add(f.fingerprint);
|
|
153
|
+
for (const f of report.rereview?.dismissed ?? [])
|
|
154
|
+
dismissed.add(f.fingerprint);
|
|
155
|
+
}
|
|
156
|
+
// A fingerprint can be dismissed in one review and gone in a later one:
|
|
157
|
+
// the reviewer's decision stands.
|
|
158
|
+
for (const fp of dismissed)
|
|
159
|
+
fixed.delete(fp);
|
|
160
|
+
for (const fp of fixed) {
|
|
161
|
+
const reviewer = reviewerOf.get(fp);
|
|
162
|
+
if (reviewer)
|
|
163
|
+
forReviewer(reviewer).fixed += 1;
|
|
164
|
+
}
|
|
165
|
+
for (const fp of dismissed) {
|
|
166
|
+
const reviewer = reviewerOf.get(fp);
|
|
167
|
+
if (reviewer)
|
|
168
|
+
forReviewer(reviewer).dismissed += 1;
|
|
169
|
+
}
|
|
170
|
+
for (const r of reviewers.values())
|
|
171
|
+
r.acceptanceRate = rate(r.fixed, r.dismissed);
|
|
172
|
+
return {
|
|
173
|
+
version: METRICS_VERSION,
|
|
174
|
+
sessionsDir: meta.sessionsDir,
|
|
175
|
+
...(meta.since ? { since: meta.since } : {}),
|
|
176
|
+
runs: { total: reports.length, byVerdict, incomplete, unreadable: meta.unreadable },
|
|
177
|
+
cost: {
|
|
178
|
+
usd,
|
|
179
|
+
inputTokens,
|
|
180
|
+
outputTokens,
|
|
181
|
+
usdPerRun: reports.length > 0 ? usd / reports.length : null,
|
|
182
|
+
},
|
|
183
|
+
findings: { reported, unique: unique.size, bySeverity, byVerification },
|
|
184
|
+
outcomes: {
|
|
185
|
+
fixed: fixed.size,
|
|
186
|
+
dismissed: dismissed.size,
|
|
187
|
+
acceptanceRate: rate(fixed.size, dismissed.size),
|
|
188
|
+
},
|
|
189
|
+
reviewers: Object.fromEntries([...reviewers].sort(([a], [b]) => a.localeCompare(b))),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function rate(fixed, dismissed) {
|
|
193
|
+
return fixed + dismissed > 0 ? fixed / (fixed + dismissed) : null;
|
|
194
|
+
}
|
|
195
|
+
const percent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
|
|
196
|
+
const dollars = (value) => (value === null ? "n/a" : `$${value.toFixed(2)}`);
|
|
197
|
+
export function renderMetrics(m) {
|
|
198
|
+
const lines = [
|
|
199
|
+
`Runs: ${m.runs.total}${m.since ? ` since ${m.since}` : ""} (${m.runs.incomplete} incomplete, ${m.runs.unreadable} unreadable)`,
|
|
200
|
+
` by verdict: ${entries(m.runs.byVerdict)}`,
|
|
201
|
+
`Cost: ${dollars(m.cost.usd)} total, ${dollars(m.cost.usdPerRun)} per run · ${m.cost.inputTokens} in / ${m.cost.outputTokens} out tokens`,
|
|
202
|
+
`Findings: ${m.findings.reported} reported, ${m.findings.unique} unique`,
|
|
203
|
+
` by severity: ${entries(m.findings.bySeverity)}`,
|
|
204
|
+
` by verification: ${entries(m.findings.byVerification)}`,
|
|
205
|
+
`Outcomes: ${m.outcomes.fixed} fixed, ${m.outcomes.dismissed} dismissed, acceptance ${percent(m.outcomes.acceptanceRate)}`,
|
|
206
|
+
"",
|
|
207
|
+
"Reviewer Tasks Failed Findings Cost Fixed Dismissed Acceptance",
|
|
208
|
+
];
|
|
209
|
+
for (const [id, r] of Object.entries(m.reviewers)) {
|
|
210
|
+
lines.push([
|
|
211
|
+
id.padEnd(18),
|
|
212
|
+
String(r.tasks).padStart(5),
|
|
213
|
+
String(r.failedTasks).padStart(7),
|
|
214
|
+
String(r.findings).padStart(9),
|
|
215
|
+
dollars(r.costUsd).padStart(9),
|
|
216
|
+
String(r.fixed).padStart(6),
|
|
217
|
+
String(r.dismissed).padStart(10),
|
|
218
|
+
percent(r.acceptanceRate).padStart(11),
|
|
219
|
+
].join(" "));
|
|
220
|
+
}
|
|
221
|
+
return `${forTerminal(lines.join("\n"))}\n`;
|
|
222
|
+
}
|
|
223
|
+
function entries(record) {
|
|
224
|
+
const parts = Object.entries(record).filter(([, n]) => n > 0);
|
|
225
|
+
return parts.length === 0 ? "none" : parts.map(([k, n]) => `${k} ${n}`).join(", ");
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=metrics.js.map
|
package/dist/review/args.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
1
2
|
import type { LocalTarget } from "@open-cr-agent/vcs-local";
|
|
2
3
|
export type OutputFormat = "text" | "json" | "sarif";
|
|
3
4
|
export interface PullRequestTarget {
|
|
@@ -22,9 +23,14 @@ export interface ReviewArgs {
|
|
|
22
23
|
ultra?: true;
|
|
23
24
|
full?: true;
|
|
24
25
|
plan?: true;
|
|
26
|
+
importSarif?: string[];
|
|
27
|
+
configFile?: string;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
seed?: number;
|
|
25
30
|
}
|
|
26
|
-
export declare class UsageError extends
|
|
31
|
+
export declare class UsageError extends OcraError {
|
|
32
|
+
constructor(message: string);
|
|
27
33
|
}
|
|
28
|
-
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 --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 --no-repo-config Ignore .ocra/config.json and its plugins (for untrusted\n code); models come from OCRA_MODEL_* variables\n -h, --help Show help\n";
|
|
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";
|
|
29
35
|
export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
|
|
30
36
|
//# sourceMappingURL=args.d.ts.map
|
package/dist/review/args.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
|
-
|
|
2
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
3
|
+
export class UsageError extends OcraError {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super("INPUT_USAGE", message);
|
|
6
|
+
this.name = "UsageError";
|
|
7
|
+
}
|
|
3
8
|
}
|
|
4
9
|
export const REVIEW_USAGE = `Usage: ocra review [options]
|
|
5
10
|
|
|
@@ -20,10 +25,16 @@ Options:
|
|
|
20
25
|
--output <file> Write the result to a file instead of stdout
|
|
21
26
|
--reviewers <ids> Run only these reviewers (comma-separated)
|
|
22
27
|
--max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest
|
|
28
|
+
--import-sarif <file> Add the results of a SARIF 2.1.0 log (Semgrep, CodeQL, …)
|
|
29
|
+
that fall on the change; repeatable; not with --plan
|
|
23
30
|
--plan Show files, bundles, review tasks and prompt sizes; call no model
|
|
24
31
|
--ultra Favor recall: all reviewers at every tier, two samples each (about 2x cost)
|
|
32
|
+
--temperature <n> Sampling temperature, 0 to 2 (default: the provider's, or sampling in config)
|
|
33
|
+
--seed <n> Sampling seed, where the runtime and provider support one
|
|
34
|
+
--config <file> Read this configuration file instead of the repository's
|
|
35
|
+
.ocra/config.json; it applies with --no-repo-config too
|
|
25
36
|
--no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
|
|
26
|
-
code); models come from OCRA_MODEL_* variables
|
|
37
|
+
code); models come from OCRA_MODEL_* variables or --config
|
|
27
38
|
-h, --help Show help
|
|
28
39
|
`;
|
|
29
40
|
export function parseReviewArgs(argv) {
|
|
@@ -79,6 +90,30 @@ export function parseReviewArgs(argv) {
|
|
|
79
90
|
throw new UsageError(`--max-cost-usd must be a positive number, got ${values["max-cost-usd"]}`);
|
|
80
91
|
args.maxCostUsd = max;
|
|
81
92
|
}
|
|
93
|
+
if (values.temperature !== undefined) {
|
|
94
|
+
const temperature = Number(values.temperature);
|
|
95
|
+
if (values.temperature.trim() === "" || !(temperature >= 0 && temperature <= 2)) {
|
|
96
|
+
throw new UsageError(`--temperature must be a number from 0 to 2, got ${values.temperature}`);
|
|
97
|
+
}
|
|
98
|
+
args.temperature = temperature;
|
|
99
|
+
}
|
|
100
|
+
if (values.seed !== undefined) {
|
|
101
|
+
const seed = Number(values.seed);
|
|
102
|
+
if (!/^\d+$/.test(values.seed) || seed > 2 ** 31 - 1) {
|
|
103
|
+
throw new UsageError(`--seed must be a whole number from 0 to 2147483647, got ${values.seed}`);
|
|
104
|
+
}
|
|
105
|
+
args.seed = seed;
|
|
106
|
+
}
|
|
107
|
+
if (values.config !== undefined) {
|
|
108
|
+
if (values.config === "")
|
|
109
|
+
throw new UsageError("--config needs a file");
|
|
110
|
+
args.configFile = values.config;
|
|
111
|
+
}
|
|
112
|
+
if (values["import-sarif"] !== undefined && values["import-sarif"].length > 0) {
|
|
113
|
+
if (values.plan)
|
|
114
|
+
throw new UsageError("--import-sarif is not used by --plan");
|
|
115
|
+
args.importSarif = values["import-sarif"];
|
|
116
|
+
}
|
|
82
117
|
if (values.reviewers !== undefined) {
|
|
83
118
|
const ids = values.reviewers
|
|
84
119
|
.split(",")
|
|
@@ -102,9 +137,13 @@ function parse(argv) {
|
|
|
102
137
|
format: { type: "string" },
|
|
103
138
|
output: { type: "string" },
|
|
104
139
|
"no-repo-config": { type: "boolean" },
|
|
140
|
+
config: { type: "string" },
|
|
105
141
|
reviewers: { type: "string" },
|
|
106
142
|
"max-cost-usd": { type: "string" },
|
|
143
|
+
"import-sarif": { type: "string", multiple: true },
|
|
107
144
|
ultra: { type: "boolean" },
|
|
145
|
+
temperature: { type: "string" },
|
|
146
|
+
seed: { type: "string" },
|
|
108
147
|
full: { type: "boolean" },
|
|
109
148
|
plan: { type: "boolean" },
|
|
110
149
|
pr: { type: "string" },
|
package/dist/review/command.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type OcraPlugin } from "@open-cr-agent/core";
|
|
2
2
|
import type { ReviewArgs } from "./args.js";
|
|
3
3
|
import { type Output } from "./progress.js";
|
|
4
|
+
import type { RuntimeLoaders } from "./runtimes.js";
|
|
4
5
|
export declare const SESSIONS_DIR = ".ocra/sessions";
|
|
5
6
|
export declare const EXIT: {
|
|
6
7
|
readonly ok: 0;
|
|
@@ -14,6 +15,7 @@ export interface ReviewDeps {
|
|
|
14
15
|
cwd: string;
|
|
15
16
|
env: Readonly<Record<string, string | undefined>>;
|
|
16
17
|
builtinPlugins: readonly OcraPlugin[];
|
|
18
|
+
runtimes: RuntimeLoaders;
|
|
17
19
|
writeFile(path: string, content: string): Promise<void>;
|
|
18
20
|
now(): number;
|
|
19
21
|
heartbeatMs: number;
|
package/dist/review/command.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { join, relative, resolve } from "node:path";
|
|
2
|
-
import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps,
|
|
3
|
-
import {
|
|
2
|
+
import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, docsReviewerPlugin, performanceReviewerPlugin, review, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, } from "@open-cr-agent/core";
|
|
3
|
+
import { defaultSelectionPolicy, newRunId, previewReview, toPlanOutput, } from "@open-cr-agent/core/internal";
|
|
4
4
|
import { githubPlugin } from "@open-cr-agent/vcs-github";
|
|
5
5
|
import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
|
|
6
|
-
import {
|
|
6
|
+
import { localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
7
|
+
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
7
8
|
import { VERSION } from "../version.js";
|
|
8
9
|
import { ConfigError } from "./config.js";
|
|
9
10
|
import { renderPlan } from "./plan-render.js";
|
|
10
11
|
import { ProgressPrinter } from "./progress.js";
|
|
12
|
+
import { configHash, requestedSampling } from "./provenance.js";
|
|
11
13
|
import { renderJson, renderText, safeJson } from "./render.js";
|
|
12
14
|
import { renderSarif } from "./sarif.js";
|
|
15
|
+
import { loadSarifLogs } from "./sarif-input.js";
|
|
13
16
|
import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
|
|
14
17
|
import { forTerminal } from "./terminal.js";
|
|
15
18
|
export const SESSIONS_DIR = ".ocra/sessions";
|
|
@@ -18,7 +21,6 @@ export const BUILTIN_PLUGINS = [
|
|
|
18
21
|
localGitPlugin,
|
|
19
22
|
githubPlugin,
|
|
20
23
|
gitlabPlugin,
|
|
21
|
-
opencodeRuntimePlugin,
|
|
22
24
|
correctnessReviewerPlugin,
|
|
23
25
|
securityReviewerPlugin,
|
|
24
26
|
performanceReviewerPlugin,
|
|
@@ -30,17 +32,20 @@ export async function reviewCommand(args, io, deps) {
|
|
|
30
32
|
const root = await findRepositoryRoot(deps.cwd);
|
|
31
33
|
const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
|
|
32
34
|
const ignoreRepoConfig = args.ignoreRepoConfig === true;
|
|
35
|
+
const configFile = args.configFile ? resolve(deps.cwd, args.configFile) : undefined;
|
|
36
|
+
const localArgs = configFile ? { ...args, configFile } : args;
|
|
33
37
|
const target = args.pullRequest
|
|
34
|
-
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
|
|
38
|
+
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
35
39
|
: args.mergeRequest
|
|
36
|
-
? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
|
|
37
|
-
: await localTarget(
|
|
40
|
+
? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
41
|
+
: await localTarget(localArgs, deps.cwd, root, deps.env, warn, deps.fetch);
|
|
38
42
|
const { config } = target;
|
|
39
|
-
const session = { dir: join(root, SESSIONS_DIR), id:
|
|
40
|
-
// A plan calls no model
|
|
43
|
+
const session = { dir: join(root, SESSIONS_DIR), id: newRunId() };
|
|
44
|
+
// A plan calls no model, writes no session log and imports no runtime: it
|
|
45
|
+
// works without the optional runtime-opencode.
|
|
41
46
|
const builtins = args.plan
|
|
42
47
|
? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
|
|
43
|
-
: deps.builtinPlugins;
|
|
48
|
+
: [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
|
|
44
49
|
const registry = await startPlugins([...builtins, ...target.plugins], {
|
|
45
50
|
settings: args.plan
|
|
46
51
|
? config.pluginSettings
|
|
@@ -68,10 +73,13 @@ export async function reviewCommand(args, io, deps) {
|
|
|
68
73
|
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
69
74
|
return EXIT.ok;
|
|
70
75
|
}
|
|
76
|
+
const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
|
|
77
|
+
const sampling = requestedSampling(config, args);
|
|
71
78
|
const runtime = registry.createRuntime(config.runtime, {
|
|
72
79
|
models: config.models,
|
|
73
80
|
env: deps.env,
|
|
74
81
|
providers: config.providers,
|
|
82
|
+
...(Object.keys(sampling).length > 0 ? { sampling } : {}),
|
|
75
83
|
});
|
|
76
84
|
const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
|
|
77
85
|
const interrupt = new AbortController();
|
|
@@ -81,13 +89,16 @@ export async function reviewCommand(args, io, deps) {
|
|
|
81
89
|
});
|
|
82
90
|
let report;
|
|
83
91
|
try {
|
|
84
|
-
report = await
|
|
92
|
+
report = await review({
|
|
93
|
+
runId: session.id,
|
|
85
94
|
signal: interrupt.signal,
|
|
86
95
|
...runOptions(config),
|
|
87
96
|
reviewerOverrides: overrides,
|
|
97
|
+
provenance: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
|
|
88
98
|
...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
89
99
|
...(args.ultra ? { ultra: true } : {}),
|
|
90
100
|
...(args.full ? { fullReview: true } : {}),
|
|
101
|
+
...(sarif.length > 0 ? { sarif } : {}),
|
|
91
102
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
92
103
|
vcs,
|
|
93
104
|
runtime,
|
|
@@ -137,6 +148,11 @@ export async function reviewCommand(args, io, deps) {
|
|
|
137
148
|
}
|
|
138
149
|
return exitCode(report, io.err);
|
|
139
150
|
}
|
|
151
|
+
// A runtime not built in comes from a plugin of the configuration.
|
|
152
|
+
async function builtinRuntime(runtimes, name) {
|
|
153
|
+
const load = Object.hasOwn(runtimes, name) ? runtimes[name] : undefined;
|
|
154
|
+
return load ? [await load()] : [];
|
|
155
|
+
}
|
|
140
156
|
function runOptions(config) {
|
|
141
157
|
const options = {
|
|
142
158
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
package/dist/review/config.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { type CustomProvider, type ModelChains, type RepoRule } from "@open-cr-agent/core";
|
|
1
|
+
import { type CustomProvider, type ModelChains, OcraError, type RepoRule } from "@open-cr-agent/core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
export declare const CONFIG_PATH = ".ocra/config.json";
|
|
4
4
|
declare const configSchema: z.ZodObject<{
|
|
5
|
+
$schema: z.ZodOptional<z.ZodString>;
|
|
5
6
|
models: z.ZodDefault<z.ZodObject<{
|
|
6
7
|
top: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
7
8
|
standard: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>, z.ZodTransform<string[], string | string[]>>>;
|
|
@@ -14,13 +15,20 @@ declare const configSchema: z.ZodObject<{
|
|
|
14
15
|
judge: z.ZodOptional<z.ZodBoolean>;
|
|
15
16
|
maxCostUsd: z.ZodOptional<z.ZodNumber>;
|
|
16
17
|
maxTasks: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
sampling: z.ZodOptional<z.ZodObject<{
|
|
19
|
+
temperature: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
seed: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
}, z.core.$strict>>;
|
|
17
22
|
github: z.ZodDefault<z.ZodObject<{
|
|
18
23
|
requestChanges: z.ZodOptional<z.ZodBoolean>;
|
|
19
24
|
botLogin: z.ZodOptional<z.ZodString>;
|
|
20
25
|
}, z.core.$strict>>;
|
|
21
26
|
include: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
22
27
|
exclude: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
23
|
-
runtime: z.ZodDefault<z.
|
|
28
|
+
runtime: z.ZodDefault<z.ZodUnion<[z.ZodEnum<{
|
|
29
|
+
direct: "direct";
|
|
30
|
+
opencode: "opencode";
|
|
31
|
+
}>, z.ZodString]>>;
|
|
24
32
|
plugins: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
25
33
|
reviewers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
26
34
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -43,6 +51,8 @@ declare const configSchema: z.ZodObject<{
|
|
|
43
51
|
}, z.core.$strict>>>;
|
|
44
52
|
extends: z.ZodOptional<z.ZodString>;
|
|
45
53
|
}, z.core.$strict>;
|
|
54
|
+
export declare const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
55
|
+
export declare function configJsonSchema(): Record<string, unknown>;
|
|
46
56
|
export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
|
|
47
57
|
models: ModelChains;
|
|
48
58
|
providers: Record<string, CustomProvider>;
|
|
@@ -51,10 +61,14 @@ export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers
|
|
|
51
61
|
export interface LoadOptions {
|
|
52
62
|
repository: boolean;
|
|
53
63
|
read?: (path: string) => Promise<string | undefined>;
|
|
64
|
+
file?: string;
|
|
54
65
|
fetch?: typeof fetch;
|
|
55
66
|
warn?: (message: string) => void;
|
|
56
67
|
}
|
|
57
|
-
export declare class ConfigError extends
|
|
68
|
+
export declare class ConfigError extends OcraError {
|
|
69
|
+
constructor(message: string, options?: {
|
|
70
|
+
cause?: unknown;
|
|
71
|
+
});
|
|
58
72
|
}
|
|
59
73
|
export declare function loadConfig(root: string, env: Readonly<Record<string, string | undefined>>, options?: LoadOptions): Promise<CliConfig>;
|
|
60
74
|
export {};
|
package/dist/review/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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 { 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";
|
|
@@ -41,8 +42,12 @@ const providerSchema = z
|
|
|
41
42
|
.refine((models) => Object.keys(models).length > 0, "must list at least one model"),
|
|
42
43
|
})
|
|
43
44
|
.strict();
|
|
45
|
+
// The runtimes ocra ships; a plugin may register another name, so the
|
|
46
|
+
// schema lists these for editors and still accepts any other string.
|
|
47
|
+
const BUILTIN_RUNTIMES = ["opencode", "direct"];
|
|
44
48
|
const configSchema = z
|
|
45
49
|
.object({
|
|
50
|
+
$schema: z.string().optional(),
|
|
46
51
|
models: z
|
|
47
52
|
.object({ top: modelChain, standard: modelChain, light: modelChain })
|
|
48
53
|
.partial()
|
|
@@ -55,6 +60,20 @@ const configSchema = z
|
|
|
55
60
|
judge: z.boolean().optional(),
|
|
56
61
|
maxCostUsd: z.number().positive().optional(),
|
|
57
62
|
maxTasks: z.number().int().min(1).max(1_000).optional(),
|
|
63
|
+
// Unset: each provider's default. A seed makes sampling repeatable only
|
|
64
|
+
// where the provider supports it.
|
|
65
|
+
sampling: z
|
|
66
|
+
.object({
|
|
67
|
+
temperature: z.number().min(0).max(2),
|
|
68
|
+
seed: z
|
|
69
|
+
.number()
|
|
70
|
+
.int()
|
|
71
|
+
.min(0)
|
|
72
|
+
.max(2 ** 31 - 1),
|
|
73
|
+
})
|
|
74
|
+
.partial()
|
|
75
|
+
.strict()
|
|
76
|
+
.optional(),
|
|
58
77
|
github: z
|
|
59
78
|
.object({ requestChanges: z.boolean(), botLogin: z.string().min(1) })
|
|
60
79
|
.partial()
|
|
@@ -62,7 +81,7 @@ const configSchema = z
|
|
|
62
81
|
.default({}),
|
|
63
82
|
include: z.array(z.string().min(1)).default([]),
|
|
64
83
|
exclude: z.array(z.string().min(1)).default([]),
|
|
65
|
-
runtime: z.string().min(1).default("opencode"),
|
|
84
|
+
runtime: z.enum(BUILTIN_RUNTIMES).or(z.string().min(1)).default("opencode"),
|
|
66
85
|
plugins: z.array(z.string().min(1)).default([]),
|
|
67
86
|
reviewers: z
|
|
68
87
|
.record(z.string(), z
|
|
@@ -78,20 +97,40 @@ const configSchema = z
|
|
|
78
97
|
extends: z.string().min(1).optional(),
|
|
79
98
|
})
|
|
80
99
|
.strict();
|
|
100
|
+
export const CONFIG_SCHEMA_ID = "https://ocra.majincheng.com/schema/config.v1.json";
|
|
101
|
+
// JSON Schema (draft 2020-12) of .ocra/config.json, for editors: what a
|
|
102
|
+
// file may say, so defaults are optional and model chains take either form.
|
|
103
|
+
export function configJsonSchema() {
|
|
104
|
+
const generated = z.toJSONSchema(configSchema, { target: "draft-2020-12", io: "input" });
|
|
105
|
+
const { $schema, ...rest } = generated;
|
|
106
|
+
return {
|
|
107
|
+
$schema,
|
|
108
|
+
$id: CONFIG_SCHEMA_ID,
|
|
109
|
+
title: "ocra configuration",
|
|
110
|
+
description: "The .ocra/config.json file of a repository, or a file passed with --config. Unknown keys are rejected.",
|
|
111
|
+
...rest,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
81
114
|
const MODEL_ENV = {
|
|
82
115
|
top: "OCRA_MODEL_TOP",
|
|
83
116
|
standard: "OCRA_MODEL_STANDARD",
|
|
84
117
|
light: "OCRA_MODEL_LIGHT",
|
|
85
118
|
};
|
|
86
|
-
export class ConfigError extends
|
|
119
|
+
export class ConfigError extends OcraError {
|
|
120
|
+
constructor(message, options) {
|
|
121
|
+
super("CONFIG_INVALID", message, options);
|
|
122
|
+
this.name = "ConfigError";
|
|
123
|
+
}
|
|
87
124
|
}
|
|
88
125
|
// Without the repository's file, only defaults and environment variables
|
|
89
126
|
// apply: that file can name plugins, and plugins run code.
|
|
90
127
|
export async function loadConfig(root, env, options = { repository: true }) {
|
|
91
128
|
const read = options.read ?? ((path) => readConfigFromDisk(root, path));
|
|
92
|
-
const { config: parsed, rules } = options.
|
|
93
|
-
? await readConfigFile(
|
|
94
|
-
:
|
|
129
|
+
const { config: parsed, rules } = options.file
|
|
130
|
+
? await readConfigFile(ownFile(options.file), options, options.file)
|
|
131
|
+
: options.repository
|
|
132
|
+
? await readConfigFile(read, options, CONFIG_PATH)
|
|
133
|
+
: { config: configSchema.parse({}), rules: [] };
|
|
95
134
|
const models = {};
|
|
96
135
|
for (const [tier, chain] of Object.entries(parsed.models)) {
|
|
97
136
|
if (chain)
|
|
@@ -139,6 +178,16 @@ function unpriced(providers, models) {
|
|
|
139
178
|
// The user's own checkout, read like any local file (links followed): unlike
|
|
140
179
|
// the review's reads, which never follow links (vcs-local), this is trusted
|
|
141
180
|
// configuration, and --no-repo-config skips it for code that is not.
|
|
181
|
+
function ownFile(file) {
|
|
182
|
+
return async () => {
|
|
183
|
+
try {
|
|
184
|
+
return await readFile(file, "utf8");
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
throw new ConfigError(`cannot read ${file}: ${error.message}`, { cause: error });
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
}
|
|
142
191
|
async function readConfigFromDisk(root, path) {
|
|
143
192
|
try {
|
|
144
193
|
return await readFile(join(root, path), "utf8");
|
|
@@ -149,7 +198,7 @@ async function readConfigFromDisk(root, path) {
|
|
|
149
198
|
throw error;
|
|
150
199
|
}
|
|
151
200
|
}
|
|
152
|
-
async function readConfigFile(read, options) {
|
|
201
|
+
async function readConfigFile(read, options, label) {
|
|
153
202
|
const text = await read(CONFIG_PATH);
|
|
154
203
|
if (text === undefined)
|
|
155
204
|
return { config: configSchema.parse({}), rules: [] };
|
|
@@ -158,11 +207,13 @@ async function readConfigFile(read, options) {
|
|
|
158
207
|
data = JSON.parse(text);
|
|
159
208
|
}
|
|
160
209
|
catch (error) {
|
|
161
|
-
throw new ConfigError(`${
|
|
210
|
+
throw new ConfigError(`${label} is not valid JSON: ${error.message}`, {
|
|
211
|
+
cause: error,
|
|
212
|
+
});
|
|
162
213
|
}
|
|
163
214
|
const local = configSchema.safeParse(data);
|
|
164
215
|
if (!local.success)
|
|
165
|
-
throw new ConfigError(`${
|
|
216
|
+
throw new ConfigError(`${label} is invalid: ${z.prettifyError(local.error)}`);
|
|
166
217
|
if (!local.data.extends)
|
|
167
218
|
return { config: local.data, rules: [] };
|
|
168
219
|
// A shared configuration that cannot be loaded costs its defaults, not the
|
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,38 @@
|
|
|
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.
|
|
16
|
+
export function configHash(config, args) {
|
|
17
|
+
const { sampling: _, providers, extends: shared, ...rest } = config;
|
|
18
|
+
return stableHash({
|
|
19
|
+
...rest,
|
|
20
|
+
...(shared === undefined ? {} : { extends: withoutCredentials(shared) }),
|
|
21
|
+
providers: Object.fromEntries(Object.entries(providers).map(([id, p]) => [
|
|
22
|
+
id,
|
|
23
|
+
{ ...p, baseUrl: withoutCredentials(p.baseUrl) },
|
|
24
|
+
])),
|
|
25
|
+
flags: { reviewers: args.reviewers, ultra: args.ultra, maxCostUsd: args.maxCostUsd },
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function withoutCredentials(value) {
|
|
29
|
+
if (!URL.canParse(value))
|
|
30
|
+
return value;
|
|
31
|
+
const url = new URL(value);
|
|
32
|
+
if (url.username === "" && url.password === "")
|
|
33
|
+
return value;
|
|
34
|
+
url.username = "";
|
|
35
|
+
url.password = "";
|
|
36
|
+
return url.toString();
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=provenance.js.map
|
|
@@ -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,6 +9,7 @@ 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(),
|
|
12
14
|
concurrency: z.unknown().optional(),
|
|
13
15
|
taskTimeoutMinutes: z.unknown().optional(),
|
|
@@ -29,7 +31,7 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
29
31
|
const [address = "", fragment] = spec.split("#", 2);
|
|
30
32
|
const url = new URL(address);
|
|
31
33
|
if (url.protocol !== "https:")
|
|
32
|
-
throw new
|
|
34
|
+
throw new OcraError("CONFIG_INVALID", `extends must be an https URL, got ${url.protocol}`);
|
|
33
35
|
const pinned = fragment?.startsWith("sha256=")
|
|
34
36
|
? fragment.slice("sha256=".length).toLowerCase()
|
|
35
37
|
: undefined;
|
|
@@ -39,21 +41,21 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
|
|
|
39
41
|
redirect: "error",
|
|
40
42
|
});
|
|
41
43
|
if (!response.ok)
|
|
42
|
-
throw new
|
|
44
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} answered ${response.status}`);
|
|
43
45
|
const body = await readLimited(response, MAX_BYTES, url.href);
|
|
44
46
|
if (pinned !== undefined) {
|
|
45
47
|
const actual = createHash("sha256").update(body).digest("hex");
|
|
46
48
|
if (actual !== pinned)
|
|
47
|
-
throw new
|
|
49
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} does not match its pinned sha256`);
|
|
48
50
|
}
|
|
49
51
|
const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
|
|
50
52
|
if (!parsed.success)
|
|
51
|
-
throw new
|
|
53
|
+
throw new OcraError("CONFIG_INVALID", `${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
|
|
52
54
|
// A provider decides where the code under review goes: unpinned, whoever
|
|
53
55
|
// serves the file could send it to an endpoint of their choosing.
|
|
54
56
|
if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
|
|
55
57
|
const digest = createHash("sha256").update(body).digest("hex");
|
|
56
|
-
throw new
|
|
58
|
+
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
59
|
}
|
|
58
60
|
return parsed.data;
|
|
59
61
|
}
|
|
@@ -71,7 +73,7 @@ async function readLimited(response, limit, name) {
|
|
|
71
73
|
size += value.byteLength;
|
|
72
74
|
if (size > limit) {
|
|
73
75
|
await reader.cancel();
|
|
74
|
-
throw new
|
|
76
|
+
throw new OcraError("CONFIG_INVALID", `${name} is larger than 256 KB`);
|
|
75
77
|
}
|
|
76
78
|
chunks.push(value);
|
|
77
79
|
}
|
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,6 +27,7 @@ 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",
|
|
@@ -36,7 +38,9 @@ export function renderSarif(report, version) {
|
|
|
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: a project access token with the api scope and the Developer role (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
|
package/dist/run.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { isOcraError } from "@open-cr-agent/core";
|
|
4
|
+
import { errorMessage } from "@open-cr-agent/core/internal";
|
|
4
5
|
import { memoryCommand } from "./memory.js";
|
|
6
|
+
import { metricsCommand } from "./metrics.js";
|
|
5
7
|
import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
|
|
6
8
|
import { BUILTIN_PLUGINS, EXIT, reviewCommand } from "./review/command.js";
|
|
9
|
+
import { BUILTIN_RUNTIMES } from "./review/runtimes.js";
|
|
7
10
|
import { forTerminal } from "./review/terminal.js";
|
|
8
11
|
import { VERSION } from "./version.js";
|
|
9
12
|
const USAGE = `Usage: ocra <command> [options]
|
|
@@ -11,6 +14,7 @@ const USAGE = `Usage: ocra <command> [options]
|
|
|
11
14
|
Commands:
|
|
12
15
|
review Review code changes (run "ocra review --help" for options)
|
|
13
16
|
memory Remember findings the team accepts (run "ocra memory --help")
|
|
17
|
+
metrics Counts over past reviews: runs, cost, findings, per reviewer (run "ocra metrics --help")
|
|
14
18
|
|
|
15
19
|
Options:
|
|
16
20
|
-h, --help Show help
|
|
@@ -21,6 +25,7 @@ export function defaultDeps() {
|
|
|
21
25
|
cwd: process.cwd(),
|
|
22
26
|
env: process.env,
|
|
23
27
|
builtinPlugins: BUILTIN_PLUGINS,
|
|
28
|
+
runtimes: BUILTIN_RUNTIMES,
|
|
24
29
|
writeFile: (path, content) => writeFile(path, content, "utf8"),
|
|
25
30
|
now: Date.now,
|
|
26
31
|
heartbeatMs: 30_000,
|
|
@@ -45,12 +50,14 @@ export async function run(argv, out, err, deps = defaultDeps()) {
|
|
|
45
50
|
const [command, ...rest] = argv;
|
|
46
51
|
if (command === "review")
|
|
47
52
|
return review(rest, out, err, deps);
|
|
48
|
-
if (command === "memory") {
|
|
53
|
+
if (command === "memory" || command === "metrics") {
|
|
49
54
|
try {
|
|
50
|
-
return
|
|
55
|
+
return command === "memory"
|
|
56
|
+
? await memoryCommand(rest, out, deps.cwd)
|
|
57
|
+
: await metricsCommand(rest, out, deps.cwd);
|
|
51
58
|
}
|
|
52
59
|
catch (error) {
|
|
53
|
-
err.write(
|
|
60
|
+
err.write(failure(error));
|
|
54
61
|
return EXIT.error;
|
|
55
62
|
}
|
|
56
63
|
}
|
|
@@ -91,9 +98,14 @@ async function review(argv, out, err, deps) {
|
|
|
91
98
|
err.write(`${error.message}\n\n${REVIEW_USAGE}`);
|
|
92
99
|
}
|
|
93
100
|
else {
|
|
94
|
-
err.write(
|
|
101
|
+
err.write(failure(error));
|
|
95
102
|
}
|
|
96
103
|
return EXIT.error;
|
|
97
104
|
}
|
|
98
105
|
}
|
|
106
|
+
// The code is what a script may match on; the message is for people.
|
|
107
|
+
function failure(error) {
|
|
108
|
+
const code = isOcraError(error) ? ` [${error.code}]` : "";
|
|
109
|
+
return `ocra${code}: ${forTerminal(errorMessage(error))}\n`;
|
|
110
|
+
}
|
|
99
111
|
//# sourceMappingURL=run.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-cr-agent/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -23,9 +23,11 @@
|
|
|
23
23
|
"type": "module",
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
+
"@open-cr-agent/source": "./src/index.ts",
|
|
26
27
|
"types": "./dist/index.d.ts",
|
|
27
28
|
"default": "./dist/index.js"
|
|
28
|
-
}
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
29
31
|
},
|
|
30
32
|
"bin": {
|
|
31
33
|
"ocra": "./dist/main.js"
|
|
@@ -35,13 +37,16 @@
|
|
|
35
37
|
"!dist/**/*.map"
|
|
36
38
|
],
|
|
37
39
|
"dependencies": {
|
|
38
|
-
"@open-cr-agent/core": "0.
|
|
39
|
-
"@open-cr-agent/runtime-
|
|
40
|
-
"@open-cr-agent/vcs-github": "0.
|
|
41
|
-
"@open-cr-agent/vcs-gitlab": "0.
|
|
42
|
-
"@open-cr-agent/vcs-local": "0.
|
|
40
|
+
"@open-cr-agent/core": "0.3.0",
|
|
41
|
+
"@open-cr-agent/runtime-direct": "0.3.0",
|
|
42
|
+
"@open-cr-agent/vcs-github": "0.3.0",
|
|
43
|
+
"@open-cr-agent/vcs-gitlab": "0.3.0",
|
|
44
|
+
"@open-cr-agent/vcs-local": "0.3.0",
|
|
43
45
|
"zod": "^4.6.5"
|
|
44
46
|
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"@open-cr-agent/runtime-opencode": "0.3.0"
|
|
49
|
+
},
|
|
45
50
|
"publishConfig": {
|
|
46
51
|
"access": "public"
|
|
47
52
|
}
|