@open-cr-agent/cli 0.1.1 → 0.2.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.
@@ -1,13 +1,19 @@
1
1
  import type { LocalTarget } from "@open-cr-agent/vcs-local";
2
- export type OutputFormat = "text" | "json";
2
+ export type OutputFormat = "text" | "json" | "sarif";
3
3
  export interface PullRequestTarget {
4
4
  number: number;
5
5
  repo?: string;
6
6
  publish: boolean;
7
7
  }
8
+ export interface MergeRequestTarget {
9
+ iid: number;
10
+ project?: string;
11
+ publish: boolean;
12
+ }
8
13
  export interface ReviewArgs {
9
14
  target: LocalTarget;
10
15
  pullRequest?: PullRequestTarget;
16
+ mergeRequest?: MergeRequestTarget;
11
17
  format: OutputFormat;
12
18
  output?: string;
13
19
  ignoreRepoConfig?: true;
@@ -19,6 +25,6 @@ export interface ReviewArgs {
19
25
  }
20
26
  export declare class UsageError extends Error {
21
27
  }
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";
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";
23
29
  export declare function parseReviewArgs(argv: string[]): ReviewArgs | "help";
24
30
  //# sourceMappingURL=args.d.ts.map
@@ -11,10 +11,12 @@ Options:
11
11
  --commit <sha> Review a single commit
12
12
  --pr <number> Review a GitHub pull request (needs GITHUB_TOKEN)
13
13
  --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
14
+ --mr <iid> Review a GitLab merge request (needs GITLAB_TOKEN)
15
+ --project <id|path> Project of --mr (default: CI_PROJECT_ID or origin)
16
+ --publish With --pr or --mr: post the review to it
17
+ --full With --pr or --mr: review every file, not only what
18
+ changed since the previous review
19
+ --format <format> text (default), json, or sarif (SARIF 2.1.0; not with --plan)
18
20
  --output <file> Write the result to a file instead of stdout
19
21
  --reviewers <ids> Run only these reviewers (comma-separated)
20
22
  --max-cost-usd <n> Spend limit: reviews use 80%, verification and judging the rest
@@ -38,13 +40,21 @@ export function parseReviewArgs(argv) {
38
40
  if (positionals.length > 0)
39
41
  throw new UsageError(`Unexpected argument: ${positionals[0]}`);
40
42
  const format = values.format ?? "text";
41
- if (format !== "text" && format !== "json") {
42
- throw new UsageError(`--format must be text or json, got ${format}`);
43
+ if (format !== "text" && format !== "json" && format !== "sarif") {
44
+ throw new UsageError(`--format must be text, json or sarif, got ${format}`);
43
45
  }
44
46
  const args = { target: target(values), format };
45
47
  const pullRequest = pullRequestTarget(values);
48
+ const mergeRequest = mergeRequestTarget(values);
49
+ if (pullRequest && mergeRequest)
50
+ throw new UsageError("--pr cannot be combined with --mr");
51
+ if (!pullRequest && !mergeRequest && values.publish) {
52
+ throw new UsageError("--publish requires --pr or --mr");
53
+ }
46
54
  if (pullRequest)
47
55
  args.pullRequest = pullRequest;
56
+ if (mergeRequest)
57
+ args.mergeRequest = mergeRequest;
48
58
  if (values.output !== undefined)
49
59
  args.output = values.output;
50
60
  if (values["no-repo-config"])
@@ -52,13 +62,15 @@ export function parseReviewArgs(argv) {
52
62
  if (values.ultra)
53
63
  args.ultra = true;
54
64
  if (values.full) {
55
- if (!pullRequest)
56
- throw new UsageError("--full needs --pr");
65
+ if (!pullRequest && !mergeRequest)
66
+ throw new UsageError("--full needs --pr or --mr");
57
67
  args.full = true;
58
68
  }
59
69
  if (values.plan) {
60
70
  if (values.publish)
61
71
  throw new UsageError("--plan cannot be combined with --publish");
72
+ if (format === "sarif")
73
+ throw new UsageError("--plan has no findings for --format sarif");
62
74
  args.plan = true;
63
75
  }
64
76
  if (values["max-cost-usd"] !== undefined) {
@@ -97,6 +109,8 @@ function parse(argv) {
97
109
  plan: { type: "boolean" },
98
110
  pr: { type: "string" },
99
111
  repo: { type: "string" },
112
+ mr: { type: "string" },
113
+ project: { type: "string" },
100
114
  publish: { type: "boolean" },
101
115
  help: { type: "boolean", short: "h" },
102
116
  },
@@ -104,8 +118,6 @@ function parse(argv) {
104
118
  }
105
119
  function pullRequestTarget(values) {
106
120
  if (values.pr === undefined) {
107
- if (values.publish)
108
- throw new UsageError("--publish requires --pr");
109
121
  if (values.repo !== undefined)
110
122
  throw new UsageError("--repo requires --pr");
111
123
  return undefined;
@@ -125,6 +137,29 @@ function pullRequestTarget(values) {
125
137
  target.repo = values.repo;
126
138
  return target;
127
139
  }
140
+ // A GitLab project is a numeric id or a full path, "group/sub/project".
141
+ const PROJECT = /^(\d+|[\w.-]+(\/[\w.-]+)+)$/;
142
+ function mergeRequestTarget(values) {
143
+ if (values.mr === undefined) {
144
+ if (values.project !== undefined)
145
+ throw new UsageError("--project requires --mr");
146
+ return undefined;
147
+ }
148
+ if (values.from !== undefined || values.to !== undefined || values.commit !== undefined) {
149
+ throw new UsageError("--mr cannot be combined with --from, --to or --commit");
150
+ }
151
+ const iid = Number(values.mr);
152
+ if (!Number.isInteger(iid) || iid <= 0) {
153
+ throw new UsageError(`--mr must be a merge request number, got ${values.mr}`);
154
+ }
155
+ if (values.project !== undefined && !PROJECT.test(values.project)) {
156
+ throw new UsageError(`--project must be a project id or group/project, got ${values.project}`);
157
+ }
158
+ const target = { iid, publish: values.publish === true };
159
+ if (values.project !== undefined)
160
+ target.project = values.project;
161
+ return target;
162
+ }
128
163
  function target(values) {
129
164
  if (values.commit !== undefined) {
130
165
  if (values.from !== undefined || values.to !== undefined) {
@@ -2,18 +2,22 @@ import { join, relative, resolve } from "node:path";
2
2
  import { agentsMdReviewerPlugin, correctnessReviewerPlugin, coverageGaps, defaultSelectionPolicy, docsReviewerPlugin, newSessionId, performanceReviewerPlugin, previewReview, runReview, securityReviewerPlugin, sessionJsonlPlugin, startPlugins, toPlanOutput, } from "@open-cr-agent/core";
3
3
  import { opencodeRuntimePlugin } from "@open-cr-agent/runtime-opencode";
4
4
  import { githubPlugin } from "@open-cr-agent/vcs-github";
5
+ import { gitlabPlugin } from "@open-cr-agent/vcs-gitlab";
5
6
  import { findRepositoryRoot, localGitPlugin } from "@open-cr-agent/vcs-local";
7
+ import { VERSION } from "../version.js";
6
8
  import { ConfigError } from "./config.js";
7
9
  import { renderPlan } from "./plan-render.js";
8
10
  import { ProgressPrinter } from "./progress.js";
9
11
  import { renderJson, renderText, safeJson } from "./render.js";
10
- import { localTarget, pullRequestTarget } from "./target.js";
12
+ import { renderSarif } from "./sarif.js";
13
+ import { localTarget, mergeRequestTarget, pullRequestTarget } from "./target.js";
11
14
  import { forTerminal } from "./terminal.js";
12
15
  export const SESSIONS_DIR = ".ocra/sessions";
13
16
  export const EXIT = { ok: 0, blocking: 1, error: 2, incomplete: 3, interrupted: 130 };
14
17
  export const BUILTIN_PLUGINS = [
15
18
  localGitPlugin,
16
19
  githubPlugin,
20
+ gitlabPlugin,
17
21
  opencodeRuntimePlugin,
18
22
  correctnessReviewerPlugin,
19
23
  securityReviewerPlugin,
@@ -25,9 +29,12 @@ export const BUILTIN_PLUGINS = [
25
29
  export async function reviewCommand(args, io, deps) {
26
30
  const root = await findRepositoryRoot(deps.cwd);
27
31
  const warn = (message) => io.err.write(`[ocra] Warning: ${forTerminal(message)}\n`);
32
+ const ignoreRepoConfig = args.ignoreRepoConfig === true;
28
33
  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);
34
+ ? await pullRequestTarget(args.pullRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
35
+ : args.mergeRequest
36
+ ? await mergeRequestTarget(args.mergeRequest, deps.cwd, root, deps.env, warn, deps.fetch, ignoreRepoConfig)
37
+ : await localTarget(args, deps.cwd, root, deps.env, warn, deps.fetch);
31
38
  const { config } = target;
32
39
  const session = { dir: join(root, SESSIONS_DIR), id: newSessionId() };
33
40
  // A plan calls no model and writes no session log.
@@ -61,7 +68,11 @@ export async function reviewCommand(args, io, deps) {
61
68
  await deps.writeFile(resolve(deps.cwd, args.output), rendered);
62
69
  return EXIT.ok;
63
70
  }
64
- const runtime = registry.createRuntime(config.runtime, { models: config.models, env: deps.env });
71
+ const runtime = registry.createRuntime(config.runtime, {
72
+ models: config.models,
73
+ env: deps.env,
74
+ providers: config.providers,
75
+ });
65
76
  const progress = new ProgressPrinter(io.err, { heartbeatMs: deps.heartbeatMs, now: deps.now });
66
77
  const interrupt = new AbortController();
67
78
  const stopListening = deps.onInterrupt?.(() => {
@@ -94,7 +105,11 @@ export async function reviewCommand(args, io, deps) {
94
105
  await runtime.dispose?.();
95
106
  }
96
107
  const sessionDir = relative(deps.cwd, join(session.dir, session.id)) || ".";
97
- const rendered = args.format === "json" ? renderJson(report) : renderText(report, sessionDir);
108
+ const rendered = args.format === "json"
109
+ ? renderJson(report)
110
+ : args.format === "sarif"
111
+ ? renderSarif(report, VERSION)
112
+ : renderText(report, sessionDir);
98
113
  if (args.output === undefined) {
99
114
  io.out.write(rendered);
100
115
  }
@@ -118,7 +133,7 @@ export async function reviewCommand(args, io, deps) {
118
133
  finally {
119
134
  stopHolding?.();
120
135
  }
121
- io.err.write("[ocra] Published the review to the pull request\n");
136
+ io.err.write(`[ocra] Published the review to the ${target.publishesTo ?? "change request"}\n`);
122
137
  }
123
138
  return exitCode(report, io.err);
124
139
  }
@@ -165,7 +180,7 @@ function exitCode(report, err) {
165
180
  // Incomplete comes first, even over a blocking verdict: the action lets
166
181
  // exit 1 pass unless fail-on-concerns is set, and a review that missed
167
182
  // files or could not check a critical finding must never pass.
168
- const { notReviewed } = coverageGaps(report.coverage);
183
+ const { notReviewed } = coverageGaps(report);
169
184
  if (notReviewed > 0) {
170
185
  err.write(`[ocra] ${notReviewed} selected file(s) were not reviewed; the review is incomplete.\n`);
171
186
  return EXIT.incomplete;
@@ -1,4 +1,4 @@
1
- import { type ModelChains, type RepoRule } from "@open-cr-agent/core";
1
+ import { type CustomProvider, type ModelChains, 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<{
@@ -31,10 +31,21 @@ declare const configSchema: z.ZodObject<{
31
31
  }>>;
32
32
  }, z.core.$strict>>>;
33
33
  pluginSettings: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
34
+ providers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
35
+ type: z.ZodLiteral<"openai-compatible">;
36
+ baseUrl: z.ZodURL;
37
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
38
+ models: z.ZodRecord<z.ZodString, z.ZodObject<{
39
+ input: z.ZodNumber;
40
+ output: z.ZodNumber;
41
+ cachedInput: z.ZodOptional<z.ZodNumber>;
42
+ }, z.core.$strict>>;
43
+ }, z.core.$strict>>>;
34
44
  extends: z.ZodOptional<z.ZodString>;
35
45
  }, z.core.$strict>;
36
- export type CliConfig = Omit<z.infer<typeof configSchema>, "models"> & {
46
+ export type CliConfig = Omit<z.infer<typeof configSchema>, "models" | "providers"> & {
37
47
  models: ModelChains;
48
+ providers: Record<string, CustomProvider>;
38
49
  rules: RepoRule[];
39
50
  };
40
51
  export interface LoadOptions {
@@ -7,6 +7,40 @@ export const CONFIG_PATH = ".ocra/config.json";
7
7
  const modelChain = z
8
8
  .union([z.string().min(1), z.array(z.string().min(1)).min(1)])
9
9
  .transform((value) => (typeof value === "string" ? [value] : value));
10
+ // Where review code is sent: over https, or plain http only to this machine.
11
+ // OpenCode replaces {env:NAME} and {file:path} anywhere in its configuration,
12
+ // so an address with braces could carry a variable or a file (the checkout's
13
+ // .git/config holds its token) to the endpoint.
14
+ const endpoint = z
15
+ .url()
16
+ .refine((value) => {
17
+ const url = new URL(value);
18
+ if (url.protocol === "https:")
19
+ return true;
20
+ const host = url.hostname.replace(/^\[|\]$/g, "");
21
+ return (url.protocol === "http:" &&
22
+ (host === "localhost" || host === "::1" || /^127\.\d+\.\d+\.\d+$/.test(host)));
23
+ }, "must be an https URL, or http on this machine (localhost, 127.0.0.1, ::1)")
24
+ .refine((value) => !/[{}]/.test(value), "must not contain { or }");
25
+ // The platforms' and clouds' credentials never go to a model endpoint; the
26
+ // same prefixes are never passed to the runtime by prefix (server-env.ts).
27
+ const PLATFORM_TOKENS = /^(GITHUB_|GH_|GITLAB_|CI_|ACTIONS_|RUNNER_|AWS_|AZURE_|NPM_|SSH_|OCRA_)/;
28
+ const price = z.number().min(0).max(10_000);
29
+ const providerSchema = z
30
+ .object({
31
+ type: z.literal("openai-compatible"),
32
+ baseUrl: endpoint,
33
+ apiKeyEnv: z
34
+ .string()
35
+ .regex(/^[A-Za-z_][A-Za-z0-9_]*$/, "must be an environment variable name")
36
+ .refine((name) => !PLATFORM_TOKENS.test(name), "must not name a platform or cloud credential")
37
+ .optional(),
38
+ // Prices in US dollars per million tokens; 0 means unpriced.
39
+ models: z
40
+ .record(z.string().regex(/^[\w./:@-]{1,200}$/), z.object({ input: price, output: price, cachedInput: price.optional() }).strict())
41
+ .refine((models) => Object.keys(models).length > 0, "must list at least one model"),
42
+ })
43
+ .strict();
10
44
  const configSchema = z
11
45
  .object({
12
46
  models: z
@@ -40,6 +74,7 @@ const configSchema = z
40
74
  .strict())
41
75
  .default({}),
42
76
  pluginSettings: z.record(z.string(), z.unknown()).default({}),
77
+ providers: z.record(z.string().regex(/^[a-z][a-z0-9-]{0,39}$/), providerSchema).default({}),
43
78
  extends: z.string().min(1).optional(),
44
79
  })
45
80
  .strict();
@@ -70,7 +105,36 @@ export async function loadConfig(root, env, options = { repository: true }) {
70
105
  if (chain.length > 0)
71
106
  models[tier] = chain;
72
107
  }
73
- return { ...parsed, models, rules };
108
+ for (const model of unpriced(parsed.providers, models)) {
109
+ options.warn?.(`${model} has a price of 0: reported cost and --max-cost-usd do not count its tokens`);
110
+ }
111
+ return { ...parsed, models, providers: toProviders(parsed.providers), rules };
112
+ }
113
+ function toProviders(declared) {
114
+ return Object.fromEntries(Object.entries(declared).map(([id, p]) => [
115
+ id,
116
+ {
117
+ baseUrl: p.baseUrl,
118
+ ...(p.apiKeyEnv ? { apiKeyEnv: p.apiKeyEnv } : {}),
119
+ models: Object.fromEntries(Object.entries(p.models).map(([model, price]) => [
120
+ model,
121
+ {
122
+ input: price.input,
123
+ output: price.output,
124
+ ...(price.cachedInput === undefined ? {} : { cachedInput: price.cachedInput }),
125
+ },
126
+ ])),
127
+ },
128
+ ]));
129
+ }
130
+ // Models of declared providers, named in a chain, priced at 0 per token.
131
+ function unpriced(providers, models) {
132
+ const named = new Set(Object.values(models).flat());
133
+ return [...named].filter((model) => {
134
+ const slash = model.indexOf("/");
135
+ const price = providers[model.slice(0, slash)]?.models[model.slice(slash + 1)];
136
+ return price !== undefined && price.input === 0 && price.output === 0;
137
+ });
74
138
  }
75
139
  // The user's own checkout, read like any local file (links followed): unlike
76
140
  // the review's reads, which never follow links (vcs-local), this is trusted
@@ -16,6 +16,7 @@ export declare const remoteConfigSchema: z.ZodObject<{
16
16
  exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
17
17
  reviewers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
18
18
  github: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
19
+ providers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
19
20
  rules: z.ZodOptional<z.ZodArray<z.ZodObject<{
20
21
  path: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
21
22
  rule: z.ZodString;
@@ -3,8 +3,9 @@ import { repoRuleSchema } from "@open-cr-agent/core";
3
3
  import { z } from "zod";
4
4
  const MAX_BYTES = 256 * 1024;
5
5
  const TIMEOUT_MS = 10_000;
6
- // What a shared configuration may set: settings and rules, never plugins or
7
- // a runtime, because it is fetched from outside the repository.
6
+ // What a shared configuration may set: settings, rules and model providers
7
+ // (a company gateway, for example), never plugins or a runtime, because it
8
+ // is fetched from outside the repository.
8
9
  export const remoteConfigSchema = z
9
10
  .object({
10
11
  models: z.record(z.enum(["top", "standard", "light"]), z.unknown()).optional(),
@@ -19,6 +20,7 @@ export const remoteConfigSchema = z
19
20
  exclude: z.array(z.string()).optional(),
20
21
  reviewers: z.record(z.string(), z.unknown()).optional(),
21
22
  github: z.record(z.string(), z.unknown()).optional(),
23
+ providers: z.record(z.string(), z.unknown()).optional(),
22
24
  rules: z.array(repoRuleSchema).max(500).optional(),
23
25
  })
24
26
  .strict();
@@ -47,6 +49,12 @@ export async function fetchRemoteConfig(spec, fetchImpl = fetch) {
47
49
  const parsed = remoteConfigSchema.safeParse(JSON.parse(body));
48
50
  if (!parsed.success)
49
51
  throw new Error(`${url.href} is invalid: ${z.prettifyError(parsed.error)}`);
52
+ // A provider decides where the code under review goes: unpinned, whoever
53
+ // serves the file could send it to an endpoint of their choosing.
54
+ if (pinned === undefined && Object.keys(parsed.data.providers ?? {}).length > 0) {
55
+ const digest = createHash("sha256").update(body).digest("hex");
56
+ throw new Error(`${url.href} declares providers, which a shared configuration may do only when pinned; check its content, then extend "${address}#sha256=${digest}"`);
57
+ }
50
58
  return parsed.data;
51
59
  }
52
60
  // Stops reading at the limit instead of buffering whatever the server sends.
@@ -73,7 +81,7 @@ async function readLimited(response, limit, name) {
73
81
  export function mergeConfig(remote, local) {
74
82
  const { rules: _rules, ...settings } = remote;
75
83
  const merged = { ...settings, ...local };
76
- for (const key of ["models", "reviewers", "github"]) {
84
+ for (const key of ["models", "reviewers", "github", "providers"]) {
77
85
  const a = settings[key];
78
86
  const b = local[key];
79
87
  if (a !== undefined || b !== undefined)
@@ -14,7 +14,7 @@ export function renderJson(report) {
14
14
  export const safeJson = serializeOutput;
15
15
  export function renderText(report, sessionDir) {
16
16
  const incomplete = report.tasks.filter((t) => t.status !== "completed");
17
- const { notReviewed, nothingReviewed } = coverageGaps(report.coverage);
17
+ const { notReviewed, nothingReviewed } = coverageGaps(report);
18
18
  const lines = [
19
19
  `Review: ${report.changeRequest.title}`,
20
20
  coverageLine(report),
@@ -79,7 +79,10 @@ export function renderText(report, sessionDir) {
79
79
  lines.push(`Incomplete: ${incomplete.length} of ${report.tasks.length} review task(s) did not finish.`);
80
80
  }
81
81
  if (notReviewed > 0) {
82
- lines.push(`Incomplete: ${notReviewed} selected file(s) were not reviewed.`);
82
+ const limit = report.spendLimit?.reached
83
+ ? `; the spend limit of $${report.spendLimit.usd} was reached`
84
+ : "";
85
+ lines.push(`Incomplete: ${notReviewed} selected file(s) were not reviewed${limit}.`);
83
86
  }
84
87
  for (const warning of report.warnings)
85
88
  lines.push(`Warning: ${warning}`);
@@ -111,7 +114,7 @@ function coverageLine(report) {
111
114
  `Risk tier: ${report.tier}`,
112
115
  `${count("reviewed")} reviewed`,
113
116
  `${count("failed")} failed`,
114
- ...(unreviewed > 0 ? [`${unreviewed} not covered by any reviewer`] : []),
117
+ ...(unreviewed > 0 ? [`${unreviewed} not started`] : []),
115
118
  ...(count("unchanged") > 0 ? [`${count("unchanged")} unchanged since the last review`] : []),
116
119
  `${count("excluded")} excluded`,
117
120
  ].join(" · ");
@@ -147,6 +150,12 @@ function groupByFile(findings) {
147
150
  function summaryLine(report) {
148
151
  const counts = SEVERITIES.map((s) => `${report.findings.filter((f) => f.severity === s).length} ${s}`);
149
152
  const { inputTokens, cachedTokens, outputTokens, reasoningTokens, costUsd } = report.usage;
150
- return `${report.findings.length} finding(s) (${counts.join(", ")}) · tokens: ${inputTokens} in (${cachedTokens} cached), ${outputTokens} out, ${reasoningTokens} reasoning · $${costUsd.toFixed(4)}`;
153
+ return `${report.findings.length} finding(s) (${counts.join(", ")}) · tokens: ${inputTokens} in (${cachedTokens} cached), ${outputTokens} out, ${reasoningTokens} reasoning · $${costUsd.toFixed(4)}${spendLimit(report)}`;
154
+ }
155
+ function spendLimit(report) {
156
+ const limit = report.spendLimit;
157
+ if (!limit)
158
+ return "";
159
+ return ` of $${limit.usd}${limit.reached ? " (limit reached)" : ""}`;
151
160
  }
152
161
  //# sourceMappingURL=render.js.map
@@ -0,0 +1,4 @@
1
+ import { type ReviewReport } from "@open-cr-agent/core";
2
+ export declare function renderSarif(report: ReviewReport, version: string): string;
3
+ export declare function plainText(text: string): string;
4
+ //# sourceMappingURL=sarif.d.ts.map
@@ -0,0 +1,147 @@
1
+ import { coverageGaps, serializeOutput, } from "@open-cr-agent/core";
2
+ // `--format sarif`: the review as a SARIF 2.1.0 log, for code scanning and
3
+ // security dashboards. One rule per reviewer category, one result per
4
+ // finding. Findings an earlier review reported in files this run did not
5
+ // review again stay open, so they are results too, marked unchanged.
6
+ // https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
7
+ const SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
8
+ const LEVEL = {
9
+ critical: "error",
10
+ warning: "warning",
11
+ suggestion: "note",
12
+ };
13
+ export function renderSarif(report, version) {
14
+ const open = [
15
+ ...(report.rereview?.unchanged ?? []),
16
+ ...(report.rereview?.notReproduced ?? []),
17
+ ...(report.rereview?.notRechecked ?? []),
18
+ ];
19
+ const categories = [
20
+ ...new Set([...report.findings.map((f) => f.category), ...(open.length > 0 ? [CARRIED] : [])]),
21
+ ].sort();
22
+ const index = new Map(categories.map((c, i) => [c, i]));
23
+ const { notReviewed } = coverageGaps(report);
24
+ const log = {
25
+ $schema: SCHEMA,
26
+ version: "2.1.0",
27
+ runs: [
28
+ {
29
+ tool: {
30
+ driver: {
31
+ name: "ocra",
32
+ informationUri: "https://ocra.majincheng.com",
33
+ version,
34
+ semanticVersion: version,
35
+ rules: categories.map((id) => ({
36
+ id,
37
+ name: id,
38
+ shortDescription: {
39
+ text: id === CARRIED ? CARRIED_DESCRIPTION : `Findings of ocra's ${id} reviewer`,
40
+ },
41
+ properties: { tags: [id] },
42
+ })),
43
+ },
44
+ },
45
+ invocations: [
46
+ {
47
+ executionSuccessful: notReviewed === 0 && report.unverifiedCriticals === 0,
48
+ toolExecutionNotifications: [
49
+ ...(notReviewed > 0
50
+ ? [
51
+ {
52
+ level: "warning",
53
+ message: { text: `${notReviewed} selected file(s) were not reviewed` },
54
+ },
55
+ ]
56
+ : []),
57
+ ...report.warnings.map((w) => ({
58
+ level: "warning",
59
+ message: { text: plainText(w) },
60
+ })),
61
+ ],
62
+ },
63
+ ],
64
+ results: [
65
+ ...report.findings.map((f) => fromFinding(f, index)),
66
+ ...open.map((f) => fromPrior(f, index)),
67
+ ],
68
+ properties: {
69
+ verdict: report.verdict,
70
+ tier: report.tier,
71
+ baseSha: report.changeRequest.baseSha,
72
+ headSha: report.changeRequest.headSha,
73
+ notReviewed: report.coverage
74
+ .filter((c) => c.status === "failed" || c.status === "unreviewed")
75
+ .map((c) => c.path),
76
+ unverifiedCriticals: report.unverifiedCriticals,
77
+ costUsd: report.usage.costUsd,
78
+ },
79
+ },
80
+ ],
81
+ };
82
+ return `${serializeOutput(log)}\n`;
83
+ }
84
+ // Open findings an earlier review reported, carried over under their own
85
+ // rule: their reviewer category is not recorded in the review state.
86
+ const CARRIED = "ocra-carried-over";
87
+ const CARRIED_DESCRIPTION = "Findings an earlier review reported that are still open in files this run did not review again";
88
+ function fromFinding(f, index) {
89
+ const text = [f.title, f.body, ...(f.suggestion ? [`Suggestion: ${f.suggestion}`] : [])].join("\n\n");
90
+ return {
91
+ ruleId: f.category,
92
+ ruleIndex: index.get(f.category) ?? 0,
93
+ level: LEVEL[f.severity],
94
+ message: { text: plainText(text) },
95
+ locations: [location(f.file, f.lineRange)],
96
+ partialFingerprints: { "ocra/v1": f.fingerprint },
97
+ properties: {
98
+ reviewer: f.reviewer,
99
+ severity: f.severity,
100
+ verification: f.verification ?? "unchecked",
101
+ status: f.status === "unfixed" ? "unfixed" : "new",
102
+ ...(f.lowConfidence ? { lowConfidence: true } : {}),
103
+ },
104
+ };
105
+ }
106
+ function fromPrior(f, index) {
107
+ return {
108
+ ruleId: CARRIED,
109
+ ruleIndex: index.get(CARRIED) ?? 0,
110
+ level: LEVEL[f.severity],
111
+ message: { text: plainText(f.title) },
112
+ // The review state keeps no lines for them, so they apply to the file.
113
+ locations: [location(f.file)],
114
+ partialFingerprints: { "ocra/v1": f.fingerprint },
115
+ baselineState: "unchanged",
116
+ properties: { severity: f.severity, verification: f.verification ?? "unchecked" },
117
+ };
118
+ }
119
+ function location(file, lines) {
120
+ return {
121
+ physicalLocation: {
122
+ // Each segment percent-encoded: a path may hold "#", "?" or spaces,
123
+ // which a URI would read as a fragment, a query or an error.
124
+ artifactLocation: {
125
+ uri: file.split("/").map(encodeURIComponent).join("/"),
126
+ uriBaseId: "%SRCROOT%",
127
+ },
128
+ ...(lines ? { region: { startLine: lines.start, endLine: lines.end } } : {}),
129
+ },
130
+ };
131
+ }
132
+ // SARIF viewers turn "[text](target)" in a message into a link (SARIF
133
+ // 3.11.6), and some turn bare web addresses into links too. Text from a
134
+ // reviewed change or a model must do neither: every backslash and bracket is
135
+ // escaped, backslashes first so a planted "\[" cannot undo the escape, and
136
+ // web addresses get a zero-width space in their scheme or after "www", as in
137
+ // pull request comments (vcs-platform's safeMarkdown, which also handles
138
+ // markup SARIF text does not have). Braces are doubled, since "{0}" is a
139
+ // placeholder (SARIF 3.11.5); GitHub shows "{{" as "{".
140
+ export function plainText(text) {
141
+ return text
142
+ .replace(/[\\[\]]/g, (c) => `\\${c}`)
143
+ .replace(/[{}]/g, (c) => c + c)
144
+ .replace(/\b(https?|ftp)(?=:\/\/)/gi, "$1​")
145
+ .replace(/\bwww(?=\.)/gi, "www​");
146
+ }
147
+ //# sourceMappingURL=sarif.js.map
@@ -1,5 +1,5 @@
1
1
  import type { OcraPlugin, PluginRegistry, VcsAdapter } from "@open-cr-agent/core";
2
- import type { PullRequestTarget, ReviewArgs } from "./args.js";
2
+ import type { MergeRequestTarget, PullRequestTarget, ReviewArgs } from "./args.js";
3
3
  import { type CliConfig } from "./config.js";
4
4
  export interface ReviewTarget {
5
5
  config: CliConfig;
@@ -7,9 +7,11 @@ export interface ReviewTarget {
7
7
  createVcs(registry: PluginRegistry): VcsAdapter;
8
8
  readTrusted?: (path: string) => Promise<string | undefined>;
9
9
  publish: boolean;
10
+ publishesTo?: string;
10
11
  }
11
12
  type Env = Readonly<Record<string, string | undefined>>;
12
13
  export declare function localTarget(args: ReviewArgs, cwd: string, root: string, env: Env, warn: (message: string) => void, fetchImpl?: typeof fetch): Promise<ReviewTarget>;
13
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
16
  export {};
15
17
  //# sourceMappingURL=target.d.ts.map
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_BOT_LOGIN, GitHubApi } from "@open-cr-agent/vcs-github";
2
+ import { GitLabApi } from "@open-cr-agent/vcs-gitlab";
2
3
  import { ensureCommits, filesChangedSince, LocalGitAdapter } from "@open-cr-agent/vcs-local";
3
4
  import { ConfigError, loadConfig } from "./config.js";
4
5
  import { loadExternalPlugins } from "./plugins.js";
@@ -72,8 +73,117 @@ export async function pullRequestTarget(pr, cwd, root, env, warn, fetchImpl, ign
72
73
  }),
73
74
  readTrusted,
74
75
  publish: pr.publish,
76
+ publishesTo: "pull request",
75
77
  };
76
78
  }
79
+ const GITLAB_API = "https://gitlab.com/api/v4";
80
+ // Like a pull request: configuration, guidelines and rules come from the
81
+ // target branch the merge request was based on, and repository plugins never
82
+ // load. GitLab CI sets CI_API_V4_URL and CI_PROJECT_ID; GITLAB_TOKEN must be
83
+ // 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) {
85
+ const token = env.GITLAB_TOKEN;
86
+ if (!token) {
87
+ throw new ConfigError("--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
+ }
89
+ const origin = await originUrl(root);
90
+ const project = mr.project ?? env.CI_PROJECT_ID ?? gitlabProject(origin);
91
+ const apiUrl = gitlabApi(env.CI_API_V4_URL, remoteHost(origin), warn);
92
+ const api = new GitLabApi(project, {
93
+ token,
94
+ baseUrl: apiUrl,
95
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
96
+ });
97
+ const merge = await api.getMergeRequest(mr.iid);
98
+ const refs = merge.diff_refs;
99
+ if (!refs)
100
+ throw new ConfigError(`merge request !${mr.iid} has no diff yet; try again shortly`);
101
+ await ensureCommits(root, [refs.start_sha, refs.head_sha], [`refs/merge-requests/${mr.iid}/head`]);
102
+ const base = new LocalGitAdapter({ cwd, target: { mode: "commit", commit: refs.start_sha } });
103
+ const readTrusted = (path) => base.readFile(path);
104
+ const config = await loadConfig(root, env, {
105
+ repository: !ignoreRepoConfig,
106
+ read: readTrusted,
107
+ warn,
108
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
109
+ });
110
+ if (config.plugins.length > 0) {
111
+ warn("plugins in .ocra/config.json are not loaded for merge requests");
112
+ }
113
+ const code = new LocalGitAdapter({
114
+ cwd,
115
+ target: { mode: "range", from: refs.start_sha, to: refs.head_sha },
116
+ });
117
+ return {
118
+ config,
119
+ plugins: [],
120
+ createVcs: (registry) => registry.createVcs("gitlab", {
121
+ project: /^\d+$/.test(project) ? Number(project) : project,
122
+ iid: mr.iid,
123
+ token,
124
+ apiUrl,
125
+ code,
126
+ snapshot: merge,
127
+ history: {
128
+ filesChangedSince: (from, to) => filesChangedSince(root, from, to),
129
+ },
130
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
131
+ }),
132
+ readTrusted,
133
+ publish: mr.publish,
134
+ publishesTo: "merge request",
135
+ };
136
+ }
137
+ function originUrl(root) {
138
+ return new LocalGitAdapter({ cwd: root, target: { mode: "workspace" } })
139
+ .remoteUrl("origin")
140
+ .catch(() => undefined);
141
+ }
142
+ // Where GITLAB_TOKEN goes: to CI_API_V4_URL, which GitLab CI sets, or else
143
+ // to gitlab.com, but only when origin is there too or is no remote host. A
144
+ // token for a self-managed instance must not travel to gitlab.com because
145
+ // the address was left out.
146
+ function gitlabApi(configured, origin, warn) {
147
+ if (configured) {
148
+ const api = remoteHost(configured);
149
+ if (api && origin && site(api) !== site(origin)) {
150
+ warn(`CI_API_V4_URL is on ${api} but origin is on ${origin}; GITLAB_TOKEN goes to ${api}`);
151
+ }
152
+ return configured;
153
+ }
154
+ if (origin && site(origin) !== "gitlab.com") {
155
+ throw new ConfigError(`origin is on ${origin}, not gitlab.com: set CI_API_V4_URL to its API, such as https://${origin}/api/v4, so that GITLAB_TOKEN goes only there`);
156
+ }
157
+ return GITLAB_API;
158
+ }
159
+ // GitLab.com answers on subdomains too, such as altssh.gitlab.com for SSH.
160
+ function site(host) {
161
+ return host === "gitlab.com" || host.endsWith(".gitlab.com") ? "gitlab.com" : host;
162
+ }
163
+ // The host of https://host/…, ssh://user@host:port/… or user@host:path; none
164
+ // for a local path, including C:\ on Windows.
165
+ function remoteHost(url) {
166
+ if (!url)
167
+ return undefined;
168
+ if (/^[\w+.-]+:\/\//.test(url)) {
169
+ try {
170
+ const parsed = new URL(url);
171
+ return parsed.protocol === "file:" ? undefined : parsed.hostname.toLowerCase() || undefined;
172
+ }
173
+ catch {
174
+ return undefined;
175
+ }
176
+ }
177
+ return /^(?:[^@\s/]+@)?([^:/\s\\]+):(?![\\/])/.exec(url)?.[1]?.toLowerCase();
178
+ }
179
+ // The project behind the origin remote: any host, since GitLab runs on many.
180
+ function gitlabProject(url) {
181
+ const match = url && /^(?:[\w+.-]+:\/\/[^/]+\/|[^@\s]+@[^:]+:)(.+?)(?:\.git)?\/?$/.exec(url);
182
+ if (!match?.[1]) {
183
+ throw new ConfigError("Cannot tell which GitLab project this is; pass --project <id|path>");
184
+ }
185
+ return match[1];
186
+ }
77
187
  async function repositoryName(root, env) {
78
188
  if (env.GITHUB_REPOSITORY)
79
189
  return env.GITHUB_REPOSITORY;
package/dist/run.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { type ReviewDeps } from "./review/command.js";
2
2
  import type { Output } from "./review/progress.js";
3
- export declare const VERSION: string;
4
3
  export declare function defaultDeps(): ReviewDeps;
5
4
  export declare function run(argv: string[], out: Output, err: Output, deps?: ReviewDeps): Promise<number>;
6
5
  //# sourceMappingURL=run.d.ts.map
package/dist/run.js CHANGED
@@ -1,13 +1,11 @@
1
1
  import { writeFile } from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
2
  import { parseArgs } from "node:util";
4
3
  import { errorMessage } from "@open-cr-agent/core";
5
4
  import { memoryCommand } from "./memory.js";
6
5
  import { parseReviewArgs, REVIEW_USAGE, UsageError } from "./review/args.js";
7
6
  import { BUILTIN_PLUGINS, EXIT, reviewCommand } from "./review/command.js";
8
7
  import { forTerminal } from "./review/terminal.js";
9
- // src/ and dist/ both sit one level below the package root.
10
- export const VERSION = createRequire(import.meta.url)("../package.json").version;
8
+ import { VERSION } from "./version.js";
11
9
  const USAGE = `Usage: ocra <command> [options]
12
10
 
13
11
  Commands:
@@ -0,0 +1,2 @@
1
+ export declare const VERSION: string;
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ // src/ and dist/ both sit one level below the package root.
3
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
4
+ //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-cr-agent/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.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",
@@ -35,10 +35,11 @@
35
35
  "!dist/**/*.map"
36
36
  ],
37
37
  "dependencies": {
38
- "@open-cr-agent/core": "0.1.1",
39
- "@open-cr-agent/runtime-opencode": "0.1.1",
40
- "@open-cr-agent/vcs-github": "0.1.1",
41
- "@open-cr-agent/vcs-local": "0.1.1",
38
+ "@open-cr-agent/core": "0.2.0",
39
+ "@open-cr-agent/runtime-opencode": "0.2.0",
40
+ "@open-cr-agent/vcs-github": "0.2.0",
41
+ "@open-cr-agent/vcs-gitlab": "0.2.0",
42
+ "@open-cr-agent/vcs-local": "0.2.0",
42
43
  "zod": "^4.6.5"
43
44
  },
44
45
  "publishConfig": {