@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.
- package/dist/review/args.d.ts +8 -2
- package/dist/review/args.js +45 -10
- package/dist/review/command.js +22 -7
- package/dist/review/config.d.ts +13 -2
- package/dist/review/config.js +65 -1
- package/dist/review/remote-config.d.ts +1 -0
- package/dist/review/remote-config.js +11 -3
- package/dist/review/render.js +13 -4
- package/dist/review/sarif.d.ts +4 -0
- package/dist/review/sarif.js +147 -0
- package/dist/review/target.d.ts +3 -1
- package/dist/review/target.js +110 -0
- package/dist/run.d.ts +0 -1
- package/dist/run.js +1 -3
- package/dist/version.d.ts +2 -0
- package/dist/version.js +4 -0
- package/package.json +6 -5
package/dist/review/args.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/review/args.js
CHANGED
|
@@ -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
|
-
--
|
|
15
|
-
--
|
|
16
|
-
|
|
17
|
-
--
|
|
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
|
|
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) {
|
package/dist/review/command.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
30
|
-
:
|
|
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, {
|
|
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"
|
|
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(
|
|
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
|
|
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;
|
package/dist/review/config.d.ts
CHANGED
|
@@ -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 {
|
package/dist/review/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
7
|
-
// a
|
|
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)
|
package/dist/review/render.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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,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
|
package/dist/review/target.d.ts
CHANGED
|
@@ -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
|
package/dist/review/target.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-cr-agent/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
39
|
-
"@open-cr-agent/runtime-opencode": "0.
|
|
40
|
-
"@open-cr-agent/vcs-github": "0.
|
|
41
|
-
"@open-cr-agent/vcs-
|
|
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": {
|