@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 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, } from "@open-cr-agent/core";
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
@@ -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
@@ -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 Error {
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 the pull request\n --full With --pr: review every file, not only what changed since\n the previous review\n --format <format> text (default) or json\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";
23
35
  export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
24
36
  //# sourceMappingURL=args.d.ts.map
@@ -1,5 +1,10 @@
1
1
  import { parseArgs } from "node:util";
2
- export class UsageError extends Error {
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
- --publish With --pr: post the review to the pull request
15
- --full With --pr: review every file, not only what changed since
16
- the previous review
17
- --format <format> text (default) or json
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 json, got ${format}`);
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) {
@@ -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;
@@ -1,20 +1,26 @@
1
1
  import { join, relative, resolve } from "node:path";
2
- import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, defaultSelectionPolicy, docsReviewerPlugin, newSessionId, performanceReviewerPlugin, previewReview, runReview, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, toPlanOutput, } from "@open-cr-agent/core";
3
- import { opencodeRuntimePlugin } from "@open-cr-agent/runtime-opencode";
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 { findRepositoryRoot, localGitPlugin } from "@open-cr-agent/vcs-local";
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 { localTarget, pullRequestTarget } from "./target.js";
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
- opencodeRuntimePlugin,
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, args.ignoreRepoConfig === true)
30
- : await localTarget(args, deps.cwd, root, deps.env, warn, deps.fetch);
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: newSessionId() };
33
- // A plan calls no model and writes no session log.
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 runtime = registry.createRuntime(config.runtime, { models: config.models, env: deps.env });
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 runReview({
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" ? renderJson(report) : renderText(report, sessionDir);
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("[ocra] Published the review to the pull request\n");
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.coverage);
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;