@open-cr-agent/cli 0.1.2 → 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 +15 -3
- package/dist/review/args.js +86 -12
- package/dist/review/command.d.ts +2 -0
- package/dist/review/command.js +46 -15
- package/dist/review/config.d.ts +29 -4
- package/dist/review/config.js +125 -10
- 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 +2 -0
- package/dist/review/remote-config.js +19 -9
- package/dist/review/render.d.ts +2 -1
- package/dist/review/render.js +16 -7
- 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.d.ts +4 -0
- package/dist/review/sarif.js +153 -0
- package/dist/review/target.d.ts +5 -3
- package/dist/review/target.js +125 -7
- package/dist/review/terminal.js +1 -1
- package/dist/run.d.ts +0 -1
- package/dist/run.js +18 -8
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +12 -6
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,13 +1,20 @@
|
|
|
1
|
+
import { OcraError } from "@open-cr-agent/core";
|
|
1
2
|
import type { LocalTarget } from "@open-cr-agent/vcs-local";
|
|
2
|
-
export type OutputFormat = "text" | "json";
|
|
3
|
+
export type OutputFormat = "text" | "json" | "sarif";
|
|
3
4
|
export interface PullRequestTarget {
|
|
4
5
|
number: number;
|
|
5
6
|
repo?: string;
|
|
6
7
|
publish: boolean;
|
|
7
8
|
}
|
|
9
|
+
export interface MergeRequestTarget {
|
|
10
|
+
iid: number;
|
|
11
|
+
project?: string;
|
|
12
|
+
publish: boolean;
|
|
13
|
+
}
|
|
8
14
|
export interface ReviewArgs {
|
|
9
15
|
target: LocalTarget;
|
|
10
16
|
pullRequest?: PullRequestTarget;
|
|
17
|
+
mergeRequest?: MergeRequestTarget;
|
|
11
18
|
format: OutputFormat;
|
|
12
19
|
output?: string;
|
|
13
20
|
ignoreRepoConfig?: true;
|
|
@@ -16,9 +23,14 @@ export interface ReviewArgs {
|
|
|
16
23
|
ultra?: true;
|
|
17
24
|
full?: true;
|
|
18
25
|
plan?: true;
|
|
26
|
+
importSarif?: string[];
|
|
27
|
+
configFile?: string;
|
|
28
|
+
temperature?: number;
|
|
29
|
+
seed?: number;
|
|
19
30
|
}
|
|
20
|
-
export declare class UsageError extends
|
|
31
|
+
export declare class UsageError extends OcraError {
|
|
32
|
+
constructor(message: string);
|
|
21
33
|
}
|
|
22
|
-
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 --publish With --pr: post the review to
|
|
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";
|
|
23
35
|
export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
|
|
24
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
|
|
|
@@ -11,17 +16,25 @@ Options:
|
|
|
11
16
|
--commit <sha> Review a single commit
|
|
12
17
|
--pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)
|
|
13
18
|
--repo <owner/name> Repository of --pr (default: GITHUB_REPOSITORY or origin)
|
|
14
|
-
--
|
|
15
|
-
--
|
|
16
|
-
|
|
17
|
-
--
|
|
19
|
+
--mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)
|
|
20
|
+
--project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)
|
|
21
|
+
--publish With --pr or --mr: post the review to it
|
|
22
|
+
--full With --pr or --mr: review every file, not only what
|
|
23
|
+
changed since the previous review
|
|
24
|
+
--format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)
|
|
18
25
|
--output <file> Write the result to a file instead of stdout
|
|
19
26
|
--reviewers <ids> Run only these reviewers (comma-separated)
|
|
20
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
|
|
21
30
|
--plan Show files, bundles, review tasks and prompt sizes; call no model
|
|
22
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
|
|
23
36
|
--no-repo-config Ignore .ocra/config.json and its plugins (for untrusted
|
|
24
|
-
code); models come from OCRA_MODEL_* variables
|
|
37
|
+
code); models come from OCRA_MODEL_* variables or --config
|
|
25
38
|
-h, --help Show help
|
|
26
39
|
`;
|
|
27
40
|
export function parseReviewArgs(argv) {
|
|
@@ -38,13 +51,21 @@ export function parseReviewArgs(argv) {
|
|
|
38
51
|
if (positionals.length > 0)
|
|
39
52
|
throw new UsageError(`Unexpected argument: ${positionals[0]}`);
|
|
40
53
|
const format = values.format ?? "text";
|
|
41
|
-
if (format !== "text" && format !== "json") {
|
|
42
|
-
throw new UsageError(`--format must be text or
|
|
54
|
+
if (format !== "text" && format !== "json" && format !== "sarif") {
|
|
55
|
+
throw new UsageError(`--format must be text, json or sarif, got ${format}`);
|
|
43
56
|
}
|
|
44
57
|
const args = { target: target(values), format };
|
|
45
58
|
const pullRequest = pullRequestTarget(values);
|
|
59
|
+
const mergeRequest = mergeRequestTarget(values);
|
|
60
|
+
if (pullRequest && mergeRequest)
|
|
61
|
+
throw new UsageError("--pr cannot be combined with --mr");
|
|
62
|
+
if (!pullRequest && !mergeRequest && values.publish) {
|
|
63
|
+
throw new UsageError("--publish requires --pr or --mr");
|
|
64
|
+
}
|
|
46
65
|
if (pullRequest)
|
|
47
66
|
args.pullRequest = pullRequest;
|
|
67
|
+
if (mergeRequest)
|
|
68
|
+
args.mergeRequest = mergeRequest;
|
|
48
69
|
if (values.output !== undefined)
|
|
49
70
|
args.output = values.output;
|
|
50
71
|
if (values["no-repo-config"])
|
|
@@ -52,13 +73,15 @@ export function parseReviewArgs(argv) {
|
|
|
52
73
|
if (values.ultra)
|
|
53
74
|
args.ultra = true;
|
|
54
75
|
if (values.full) {
|
|
55
|
-
if (!pullRequest)
|
|
56
|
-
throw new UsageError("--full needs --pr");
|
|
76
|
+
if (!pullRequest && !mergeRequest)
|
|
77
|
+
throw new UsageError("--full needs --pr or --mr");
|
|
57
78
|
args.full = true;
|
|
58
79
|
}
|
|
59
80
|
if (values.plan) {
|
|
60
81
|
if (values.publish)
|
|
61
82
|
throw new UsageError("--plan cannot be combined with --publish");
|
|
83
|
+
if (format === "sarif")
|
|
84
|
+
throw new UsageError("--plan has no findings for --format sarif");
|
|
62
85
|
args.plan = true;
|
|
63
86
|
}
|
|
64
87
|
if (values["max-cost-usd"] !== undefined) {
|
|
@@ -67,6 +90,30 @@ export function parseReviewArgs(argv) {
|
|
|
67
90
|
throw new UsageError(`--max-cost-usd must be a positive number, got ${values["max-cost-usd"]}`);
|
|
68
91
|
args.maxCostUsd = max;
|
|
69
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
|
+
}
|
|
70
117
|
if (values.reviewers !== undefined) {
|
|
71
118
|
const ids = values.reviewers
|
|
72
119
|
.split(",")
|
|
@@ -90,13 +137,19 @@ function parse(argv) {
|
|
|
90
137
|
format: { type: "string" },
|
|
91
138
|
output: { type: "string" },
|
|
92
139
|
"no-repo-config": { type: "boolean" },
|
|
140
|
+
config: { type: "string" },
|
|
93
141
|
reviewers: { type: "string" },
|
|
94
142
|
"max-cost-usd": { type: "string" },
|
|
143
|
+
"import-sarif": { type: "string", multiple: true },
|
|
95
144
|
ultra: { type: "boolean" },
|
|
145
|
+
temperature: { type: "string" },
|
|
146
|
+
seed: { type: "string" },
|
|
96
147
|
full: { type: "boolean" },
|
|
97
148
|
plan: { type: "boolean" },
|
|
98
149
|
pr: { type: "string" },
|
|
99
150
|
repo: { type: "string" },
|
|
151
|
+
mr: { type: "string" },
|
|
152
|
+
project: { type: "string" },
|
|
100
153
|
publish: { type: "boolean" },
|
|
101
154
|
help: { type: "boolean", short: "h" },
|
|
102
155
|
},
|
|
@@ -104,8 +157,6 @@ function parse(argv) {
|
|
|
104
157
|
}
|
|
105
158
|
function pullRequestTarget(values) {
|
|
106
159
|
if (values.pr === undefined) {
|
|
107
|
-
if (values.publish)
|
|
108
|
-
throw new UsageError("--publish requires --pr");
|
|
109
160
|
if (values.repo !== undefined)
|
|
110
161
|
throw new UsageError("--repo requires --pr");
|
|
111
162
|
return undefined;
|
|
@@ -125,6 +176,29 @@ function pullRequestTarget(values) {
|
|
|
125
176
|
target.repo = values.repo;
|
|
126
177
|
return target;
|
|
127
178
|
}
|
|
179
|
+
// A GitLab project is a numeric id or a full path, "group/sub/project".
|
|
180
|
+
const PROJECT = /^(\d+|[\w.-]+(\/[\w.-]+)+)$/;
|
|
181
|
+
function mergeRequestTarget(values) {
|
|
182
|
+
if (values.mr === undefined) {
|
|
183
|
+
if (values.project !== undefined)
|
|
184
|
+
throw new UsageError("--project requires --mr");
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
if (values.from !== undefined || values.to !== undefined || values.commit !== undefined) {
|
|
188
|
+
throw new UsageError("--mr cannot be combined with --from, --to or --commit");
|
|
189
|
+
}
|
|
190
|
+
const iid = Number(values.mr);
|
|
191
|
+
if (!Number.isInteger(iid) || iid <= 0) {
|
|
192
|
+
throw new UsageError(`--mr must be a merge request number, got ${values.mr}`);
|
|
193
|
+
}
|
|
194
|
+
if (values.project !== undefined && !PROJECT.test(values.project)) {
|
|
195
|
+
throw new UsageError(`--project must be a project id or group/project, got ${values.project}`);
|
|
196
|
+
}
|
|
197
|
+
const target = { iid, publish: values.publish === true };
|
|
198
|
+
if (values.project !== undefined)
|
|
199
|
+
target.project = values.project;
|
|
200
|
+
return target;
|
|
201
|
+
}
|
|
128
202
|
function target(values) {
|
|
129
203
|
if (values.commit !== undefined) {
|
|
130
204
|
if (values.from !== undefined || values.to !== undefined) {
|
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,20 +1,26 @@
|
|
|
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
|
-
import {
|
|
5
|
+
import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
|
|
6
|
+
import { localGitPlugin } from "@open-cr-agent/vcs-local";
|
|
7
|
+
import { findRepositoryRoot } from "@open-cr-agent/vcs-local/internal";
|
|
8
|
+
import { VERSION } from "../version.js";
|
|
6
9
|
import { ConfigError } from "./config.js";
|
|
7
10
|
import { renderPlan } from "./plan-render.js";
|
|
8
11
|
import { ProgressPrinter } from "./progress.js";
|
|
12
|
+
import { configHash, requestedSampling } from "./provenance.js";
|
|
9
13
|
import { renderJson, renderText, safeJson } from "./render.js";
|
|
10
|
-
import {
|
|
14
|
+
import { renderSarif } from "./sarif.js";
|
|
15
|
+
import { loadSarifLogs } from "./sarif-input.js";
|
|
16
|
+
import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
|
|
11
17
|
import { forTerminal } from "./terminal.js";
|
|
12
18
|
export const SESSIONS_DIR = ".ocra/sessions";
|
|
13
19
|
export const EXIT = { ok: 0, blocking: 1, error: 2, incomplete: 3, interrupted: 130 };
|
|
14
20
|
export const BUILTIN_PLUGINS = [
|
|
15
21
|
localGitPlugin,
|
|
16
22
|
githubPlugin,
|
|
17
|
-
|
|
23
|
+
gitlabPlugin,
|
|
18
24
|
correctnessReviewerPlugin,
|
|
19
25
|
securityReviewerPlugin,
|
|
20
26
|
performanceReviewerPlugin,
|
|
@@ -25,15 +31,21 @@ export const BUILTIN_PLUGINS = [
|
|
|
25
31
|
export async function reviewCommand(args, io, deps) {
|
|
26
32
|
const root = await findRepositoryRoot(deps.cwd);
|
|
27
33
|
const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
|
|
34
|
+
const ignoreRepoConfig = args.ignoreRepoConfig === true;
|
|
35
|
+
const configFile = args.configFile ? resolve(deps.cwd, args.configFile) : undefined;
|
|
36
|
+
const localArgs = configFile ? { ...args, configFile } : args;
|
|
28
37
|
const target = args.pullRequest
|
|
29
|
-
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch,
|
|
30
|
-
:
|
|
38
|
+
? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig, configFile)
|
|
39
|
+
: args.mergeRequest
|
|
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);
|
|
31
42
|
const { config } = target;
|
|
32
|
-
const session = { dir: join(root, SESSIONS_DIR), id:
|
|
33
|
-
// 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.
|
|
34
46
|
const builtins = args.plan
|
|
35
47
|
? deps.builtinPlugins.filter((p) => p.name !== sessionJsonlPlugin.name)
|
|
36
|
-
: deps.builtinPlugins;
|
|
48
|
+
: [...deps.builtinPlugins, ...(await builtinRuntime(deps.runtimes, config.runtime))];
|
|
37
49
|
const registry = await startPlugins([...builtins, ...target.plugins], {
|
|
38
50
|
settings: args.plan
|
|
39
51
|
? config.pluginSettings
|
|
@@ -61,7 +73,14 @@ export async function reviewCommand(args, io, deps) {
|
|
|
61
73
|
await deps.writeFile(resolve(deps.cwd, args.output), rendered);
|
|
62
74
|
return EXIT.ok;
|
|
63
75
|
}
|
|
64
|
-
const
|
|
76
|
+
const sarif = await loadSarifLogs(args.importSarif ?? [], deps.cwd);
|
|
77
|
+
const sampling = requestedSampling(config, args);
|
|
78
|
+
const runtime = registry.createRuntime(config.runtime, {
|
|
79
|
+
models: config.models,
|
|
80
|
+
env: deps.env,
|
|
81
|
+
providers: config.providers,
|
|
82
|
+
...(Object.keys(sampling).length > 0 ? { sampling } : {}),
|
|
83
|
+
});
|
|
65
84
|
const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
|
|
66
85
|
const interrupt = new AbortController();
|
|
67
86
|
const stopListening = deps.onInterrupt?.(() => {
|
|
@@ -70,13 +89,16 @@ export async function reviewCommand(args, io, deps) {
|
|
|
70
89
|
});
|
|
71
90
|
let report;
|
|
72
91
|
try {
|
|
73
|
-
report = await
|
|
92
|
+
report = await review({
|
|
93
|
+
runId: session.id,
|
|
74
94
|
signal: interrupt.signal,
|
|
75
95
|
...runOptions(config),
|
|
76
96
|
reviewerOverrides: overrides,
|
|
97
|
+
provenance: { ocraVersion: VERSION, configHash: configHash(config, args), sampling },
|
|
77
98
|
...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}),
|
|
78
99
|
...(args.ultra ? { ultra: true } : {}),
|
|
79
100
|
...(args.full ? { fullReview: true } : {}),
|
|
101
|
+
...(sarif.length > 0 ? { sarif } : {}),
|
|
80
102
|
...(target.readTrusted ? { readTrusted: target.readTrusted } : {}),
|
|
81
103
|
vcs,
|
|
82
104
|
runtime,
|
|
@@ -94,7 +116,11 @@ export async function reviewCommand(args, io, deps) {
|
|
|
94
116
|
await runtime.dispose?.();
|
|
95
117
|
}
|
|
96
118
|
const sessionDir = relative(deps.cwd, join(session.dir, session.id)) || ".";
|
|
97
|
-
const rendered = args.format === "json"
|
|
119
|
+
const rendered = args.format === "json"
|
|
120
|
+
? renderJson(report)
|
|
121
|
+
: args.format === "sarif"
|
|
122
|
+
? renderSarif(report, VERSION)
|
|
123
|
+
: renderText(report, sessionDir);
|
|
98
124
|
if (args.output === undefined) {
|
|
99
125
|
io.out.write(rendered);
|
|
100
126
|
}
|
|
@@ -118,10 +144,15 @@ export async function reviewCommand(args, io, deps) {
|
|
|
118
144
|
finally {
|
|
119
145
|
stopHolding?.();
|
|
120
146
|
}
|
|
121
|
-
io.err.write(
|
|
147
|
+
io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
|
|
122
148
|
}
|
|
123
149
|
return exitCode(report, io.err);
|
|
124
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
|
+
}
|
|
125
156
|
function runOptions(config) {
|
|
126
157
|
const options = {
|
|
127
158
|
selection: { ...defaultSelectionPolicy, include: config.include, exclude: config.exclude },
|
|
@@ -165,7 +196,7 @@ function exitCode(report, err) {
|
|
|
165
196
|
// Incomplete comes first, even over a blocking verdict: the action lets
|
|
166
197
|
// exit 1 pass unless fail-on-concerns is set, and a review that missed
|
|
167
198
|
// files or could not check a critical finding must never pass.
|
|
168
|
-
const { notReviewed } = coverageGaps(report
|
|
199
|
+
const { notReviewed } = coverageGaps(report);
|
|
169
200
|
if (notReviewed > 0) {
|
|
170
201
|
err.write(`[ocra] ${notReviewed} selected file(s) were not reviewed; the review is incomplete.\n`);
|
|
171
202
|
return EXIT.incomplete;
|