@coldtea/pr-lens-cli 0.1.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/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/args.d.ts +18 -0
- package/dist/args.d.ts.map +1 -0
- package/dist/args.js +49 -0
- package/dist/args.js.map +1 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +8 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +97 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/analyze.d.ts +4 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +158 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/comment.d.ts +4 -0
- package/dist/commands/comment.d.ts.map +1 -0
- package/dist/commands/comment.js +63 -0
- package/dist/commands/comment.js.map +1 -0
- package/dist/commands/export.d.ts +4 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +44 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/render.d.ts +4 -0
- package/dist/commands/render.d.ts.map +1 -0
- package/dist/commands/render.js +108 -0
- package/dist/commands/render.js.map +1 -0
- package/dist/commands/validate.d.ts +4 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +49 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/comment.d.ts +16 -0
- package/dist/comment.d.ts.map +1 -0
- package/dist/comment.js +148 -0
- package/dist/comment.js.map +1 -0
- package/dist/config-file.d.ts +10 -0
- package/dist/config-file.d.ts.map +1 -0
- package/dist/config-file.js +19 -0
- package/dist/config-file.js.map +1 -0
- package/dist/corrections.d.ts +16 -0
- package/dist/corrections.d.ts.map +1 -0
- package/dist/corrections.js +80 -0
- package/dist/corrections.js.map +1 -0
- package/dist/document.d.ts +23 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +75 -0
- package/dist/document.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -0
- package/dist/extract.d.ts +54 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +84 -0
- package/dist/extract.js.map +1 -0
- package/dist/git.d.ts +40 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +104 -0
- package/dist/git.js.map +1 -0
- package/dist/io.d.ts +6 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +35 -0
- package/dist/io.js.map +1 -0
- package/dist/prompt.d.ts +23 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +69 -0
- package/dist/prompt.js.map +1 -0
- package/dist/providers/gemini.d.ts +3 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +46 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/providers/http.d.ts +5 -0
- package/dist/providers/http.d.ts.map +1 -0
- package/dist/providers/http.js +26 -0
- package/dist/providers/http.js.map +1 -0
- package/dist/providers/index.d.ts +48 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +71 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/openai.d.ts +17 -0
- package/dist/providers/openai.d.ts.map +1 -0
- package/dist/providers/openai.js +42 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/snapshot.d.ts +15 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +77 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +5 -0
- package/dist/terminal.js.map +1 -0
- package/dist/version.d.ts +4 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -0
- package/src/args.ts +52 -0
- package/src/bin.ts +10 -0
- package/src/cli.ts +120 -0
- package/src/commands/analyze.ts +206 -0
- package/src/commands/comment.ts +77 -0
- package/src/commands/export.ts +61 -0
- package/src/commands/render.ts +125 -0
- package/src/commands/validate.ts +57 -0
- package/src/comment.ts +190 -0
- package/src/config-file.ts +26 -0
- package/src/corrections.ts +86 -0
- package/src/document.ts +119 -0
- package/src/errors.ts +36 -0
- package/src/extract.ts +136 -0
- package/src/git.ts +151 -0
- package/src/io.ts +38 -0
- package/src/prompt.ts +87 -0
- package/src/providers/gemini.ts +61 -0
- package/src/providers/http.ts +54 -0
- package/src/providers/index.ts +124 -0
- package/src/providers/openai.ts +68 -0
- package/src/snapshot.ts +102 -0
- package/src/terminal.ts +9 -0
- package/src/version.ts +4 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { analyzeCommand, USAGE as ANALYZE_USAGE } from "./commands/analyze.js";
|
|
3
|
+
import { commentCommand, USAGE as COMMENT_USAGE } from "./commands/comment.js";
|
|
4
|
+
import { exportCommand, USAGE as EXPORT_USAGE } from "./commands/export.js";
|
|
5
|
+
import { renderCommand, USAGE as RENDER_USAGE } from "./commands/render.js";
|
|
6
|
+
import { USAGE as VALIDATE_USAGE, validateCommand } from "./commands/validate.js";
|
|
7
|
+
import { formatError, PrLensCliError } from "./errors.js";
|
|
8
|
+
import type { Terminal } from "./terminal.js";
|
|
9
|
+
import { CLI_VERSION } from "./version.js";
|
|
10
|
+
|
|
11
|
+
const COMMANDS = ["analyze", "render", "comment", "validate", "export"] as const;
|
|
12
|
+
type CommandName = (typeof COMMANDS)[number];
|
|
13
|
+
|
|
14
|
+
const isCommand = (value: string): value is CommandName =>
|
|
15
|
+
COMMANDS.some((command) => command === value);
|
|
16
|
+
|
|
17
|
+
const HELP = `pr-lens — review what actually matters
|
|
18
|
+
|
|
19
|
+
pr-lens analyze --base <ref> a diff, read by your own model, as a graph document
|
|
20
|
+
pr-lens render <graph.json> that document, as light and dark SVGs
|
|
21
|
+
pr-lens comment --graph --manifest the pull request comment, as markdown
|
|
22
|
+
pr-lens validate <file...> any PR Lens document, checked against the contract
|
|
23
|
+
pr-lens export <graph.json> the merged state, as a map worth committing
|
|
24
|
+
|
|
25
|
+
pr-lens <command> --help what a command takes
|
|
26
|
+
pr-lens --version
|
|
27
|
+
|
|
28
|
+
Your model key is read from the environment and goes only to the provider you
|
|
29
|
+
name. https://github.com/coldteadotai/pr-lens`;
|
|
30
|
+
|
|
31
|
+
const usageFor = (command: CommandName): string => {
|
|
32
|
+
switch (command) {
|
|
33
|
+
case "analyze":
|
|
34
|
+
return ANALYZE_USAGE;
|
|
35
|
+
case "render":
|
|
36
|
+
return RENDER_USAGE;
|
|
37
|
+
case "comment":
|
|
38
|
+
return COMMENT_USAGE;
|
|
39
|
+
case "validate":
|
|
40
|
+
return VALIDATE_USAGE;
|
|
41
|
+
case "export":
|
|
42
|
+
return EXPORT_USAGE;
|
|
43
|
+
default:
|
|
44
|
+
return assertNever(command, "Unhandled command");
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const dispatch = (
|
|
49
|
+
command: CommandName,
|
|
50
|
+
args: readonly string[],
|
|
51
|
+
terminal: Terminal,
|
|
52
|
+
env: Record<string, string | undefined>,
|
|
53
|
+
): Promise<void> => {
|
|
54
|
+
switch (command) {
|
|
55
|
+
case "analyze":
|
|
56
|
+
return analyzeCommand(args, terminal, env);
|
|
57
|
+
case "render":
|
|
58
|
+
return renderCommand(args, terminal);
|
|
59
|
+
case "comment":
|
|
60
|
+
return commentCommand(args, terminal);
|
|
61
|
+
case "validate":
|
|
62
|
+
return validateCommand(args, terminal);
|
|
63
|
+
case "export":
|
|
64
|
+
return exportCommand(args, terminal);
|
|
65
|
+
default:
|
|
66
|
+
return assertNever(command, "Unhandled command");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const EXIT_OK = 0;
|
|
71
|
+
const EXIT_FAILED = 1;
|
|
72
|
+
const EXIT_MISUSED = 2;
|
|
73
|
+
|
|
74
|
+
export const run = async (
|
|
75
|
+
argv: readonly string[],
|
|
76
|
+
terminal: Terminal,
|
|
77
|
+
env: Record<string, string | undefined>,
|
|
78
|
+
): Promise<number> => {
|
|
79
|
+
const [name, ...args] = argv;
|
|
80
|
+
|
|
81
|
+
if (name === undefined) {
|
|
82
|
+
terminal.err(HELP);
|
|
83
|
+
return EXIT_MISUSED;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (name === "--help" || name === "-h" || name === "help") {
|
|
87
|
+
terminal.out(HELP);
|
|
88
|
+
return EXIT_OK;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (name === "--version" || name === "-v") {
|
|
92
|
+
terminal.out(CLI_VERSION);
|
|
93
|
+
return EXIT_OK;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!isCommand(name)) {
|
|
97
|
+
terminal.err(formatError(new PrLensCliError("USAGE", `unknown command ${JSON.stringify(name)}`)));
|
|
98
|
+
terminal.err(HELP);
|
|
99
|
+
return EXIT_MISUSED;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
103
|
+
terminal.out(usageFor(name));
|
|
104
|
+
return EXIT_OK;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
await dispatch(name, args, terminal, env);
|
|
109
|
+
return EXIT_OK;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
112
|
+
|
|
113
|
+
terminal.err(formatError(error));
|
|
114
|
+
if (error.code !== "USAGE") return EXIT_FAILED;
|
|
115
|
+
|
|
116
|
+
terminal.err("");
|
|
117
|
+
terminal.err(usageFor(name));
|
|
118
|
+
return EXIT_MISUSED;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { LENSES, type GraphDocInput, type Lens } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { parseOptions, readBoolean, readInt, readList, readString } from "../args.js";
|
|
4
|
+
import { discoverConfig, loadConfig, type LoadedConfig } from "../config-file.js";
|
|
5
|
+
import { PrLensCliError, usageError } from "../errors.js";
|
|
6
|
+
import { extractGraph } from "../extract.js";
|
|
7
|
+
import { collectDiff, mergeBase, parseRepoSlug, remoteSlug, repositoryRoot, resolveCommit } from "../git.js";
|
|
8
|
+
import { readTextFile, writeJsonFile } from "../io.js";
|
|
9
|
+
import { buildExtractionPrompt, SYSTEM_PROMPT } from "../prompt.js";
|
|
10
|
+
import { completeJson, isProviderId, PROVIDER_IDS, resolveProvider } from "../providers/index.js";
|
|
11
|
+
import type { Terminal } from "../terminal.js";
|
|
12
|
+
import { CLI_VERSION, GENERATOR_NAME } from "../version.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OUT = "pr-lens/graph.json";
|
|
15
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 32_768;
|
|
16
|
+
const DEFAULT_MAX_DIFF_BYTES = 400_000;
|
|
17
|
+
|
|
18
|
+
export const USAGE = `pr-lens analyze --base <ref> [options]
|
|
19
|
+
|
|
20
|
+
Reads the diff between two commits and asks your own model to describe it as a
|
|
21
|
+
graph document. The key never leaves your machine: it is read from the
|
|
22
|
+
environment, and the diff goes straight to the provider you name.
|
|
23
|
+
|
|
24
|
+
--base <ref> what the change is measured against (required)
|
|
25
|
+
--head <ref> the tip of the change (default HEAD)
|
|
26
|
+
--repo <dir> repository to read (default .)
|
|
27
|
+
--provider <id> ${PROVIDER_IDS.join(" | ")} (default gemini)
|
|
28
|
+
--model <name> model to ask (required for openai-compatible)
|
|
29
|
+
--base-url <url> provider endpoint, for a compatible or local server
|
|
30
|
+
--api-key-env <name> environment variable holding the key
|
|
31
|
+
--lens <lens> ${LENSES.join(", ")} (repeatable, default both)
|
|
32
|
+
--config <file> read the lenses from this config (default the repository's)
|
|
33
|
+
--no-config ignore the repository's config
|
|
34
|
+
--pr <number> pull request number, recorded in provenance
|
|
35
|
+
--repo-slug <owner/name> override the slug read from the git remote
|
|
36
|
+
--remote <name> remote to read the slug from (default origin)
|
|
37
|
+
--max-diff-bytes <n> truncate the diff sent to the model (default ${DEFAULT_MAX_DIFF_BYTES})
|
|
38
|
+
--max-output-tokens <n> room for the answer (default ${DEFAULT_MAX_OUTPUT_TOKENS})
|
|
39
|
+
--dry-run report what would be sent, and send nothing
|
|
40
|
+
-o, --out <file> where to write the document (default ${DEFAULT_OUT})`;
|
|
41
|
+
|
|
42
|
+
const readLenses = (values: Record<string, unknown>): Lens[] | undefined => {
|
|
43
|
+
const requested = readList(values.lens, "lens");
|
|
44
|
+
if (requested === undefined) return undefined;
|
|
45
|
+
|
|
46
|
+
const unknown = requested.filter((lens) => !LENSES.some((known) => known === lens));
|
|
47
|
+
if (unknown.length > 0)
|
|
48
|
+
throw usageError(`unknown lens ${unknown.join(", ")}`, `known lenses: ${LENSES.join(", ")}`);
|
|
49
|
+
|
|
50
|
+
return LENSES.filter((lens) => requested.includes(lens));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const readProviderId = (values: Record<string, unknown>) => {
|
|
54
|
+
const id = readString(values.provider, "provider") ?? "gemini";
|
|
55
|
+
if (!isProviderId(id))
|
|
56
|
+
throw usageError(`unknown provider ${JSON.stringify(id)}`, `known providers: ${PROVIDER_IDS.join(", ")}`);
|
|
57
|
+
return id;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const graphJsonSchema = (): Promise<string> =>
|
|
61
|
+
readTextFile(
|
|
62
|
+
createRequire(import.meta.url).resolve("@coldtea/pr-lens-schema/json-schema/graph-doc.schema.json"),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
export const analyzeCommand = async (
|
|
66
|
+
args: readonly string[],
|
|
67
|
+
terminal: Terminal,
|
|
68
|
+
env: Record<string, string | undefined>,
|
|
69
|
+
): Promise<void> => {
|
|
70
|
+
const { values, positionals } = parseOptions(args, {
|
|
71
|
+
base: { type: "string" },
|
|
72
|
+
head: { type: "string" },
|
|
73
|
+
repo: { type: "string" },
|
|
74
|
+
provider: { type: "string" },
|
|
75
|
+
model: { type: "string" },
|
|
76
|
+
"base-url": { type: "string" },
|
|
77
|
+
"api-key-env": { type: "string" },
|
|
78
|
+
lens: { type: "string", multiple: true },
|
|
79
|
+
config: { type: "string" },
|
|
80
|
+
"no-config": { type: "boolean" },
|
|
81
|
+
pr: { type: "string" },
|
|
82
|
+
"repo-slug": { type: "string" },
|
|
83
|
+
remote: { type: "string" },
|
|
84
|
+
"max-diff-bytes": { type: "string" },
|
|
85
|
+
"max-output-tokens": { type: "string" },
|
|
86
|
+
"dry-run": { type: "boolean" },
|
|
87
|
+
out: { type: "string", short: "o" },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (positionals.length > 0) throw usageError(`analyze takes no positional arguments, got ${positionals.join(" ")}`);
|
|
91
|
+
|
|
92
|
+
const base = readString(values.base, "base");
|
|
93
|
+
if (base === undefined) throw usageError("--base is required", "it names the commit the change is measured against, e.g. --base origin/main");
|
|
94
|
+
|
|
95
|
+
const repo = readString(values.repo, "repo") ?? process.cwd();
|
|
96
|
+
const head = readString(values.head, "head") ?? "HEAD";
|
|
97
|
+
const maxDiffBytes = readInt(values["max-diff-bytes"], "max-diff-bytes", DEFAULT_MAX_DIFF_BYTES);
|
|
98
|
+
|
|
99
|
+
const slugOption = readString(values["repo-slug"], "repo-slug");
|
|
100
|
+
const slug = slugOption === undefined
|
|
101
|
+
? await remoteSlug(repo, readString(values.remote, "remote") ?? "origin")
|
|
102
|
+
: parseRepoSlug(slugOption);
|
|
103
|
+
|
|
104
|
+
if (slug === undefined)
|
|
105
|
+
throw new PrLensCliError(
|
|
106
|
+
"REPOSITORY_UNKNOWN",
|
|
107
|
+
"cannot tell which repository this is",
|
|
108
|
+
"pass --repo-slug owner/name, or add a git remote the CLI can read",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const configPath = readString(values.config, "config");
|
|
112
|
+
const configured: LoadedConfig | undefined = readBoolean(values["no-config"])
|
|
113
|
+
? undefined
|
|
114
|
+
: configPath === undefined
|
|
115
|
+
? await discoverConfig(await repositoryRoot(repo))
|
|
116
|
+
: await loadConfig(configPath);
|
|
117
|
+
|
|
118
|
+
const requestedLenses = readLenses(values);
|
|
119
|
+
const lenses = requestedLenses ?? configured?.config.lenses ?? [...LENSES];
|
|
120
|
+
|
|
121
|
+
const headCommit = await resolveCommit(repo, head);
|
|
122
|
+
const baseCommit = await resolveCommit(repo, base);
|
|
123
|
+
const comparedAgainst = await mergeBase(repo, baseCommit.sha, headCommit.sha);
|
|
124
|
+
const diff = await collectDiff(repo, comparedAgainst, headCommit.sha, maxDiffBytes);
|
|
125
|
+
|
|
126
|
+
if (diff.files.length === 0)
|
|
127
|
+
throw new PrLensCliError(
|
|
128
|
+
"EMPTY_DIFF",
|
|
129
|
+
`${base} and ${head} have the same content`,
|
|
130
|
+
"there is nothing to draw",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const promptContext = {
|
|
134
|
+
repo: { owner: slug.owner, name: slug.name },
|
|
135
|
+
base: { sha: comparedAgainst, ref: baseCommit.ref },
|
|
136
|
+
head: { sha: headCommit.sha, ref: headCommit.ref },
|
|
137
|
+
diff,
|
|
138
|
+
lenses,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const user = buildExtractionPrompt(promptContext, await graphJsonSchema());
|
|
142
|
+
const provider = readBoolean(values["dry-run"])
|
|
143
|
+
? undefined
|
|
144
|
+
: resolveProvider(
|
|
145
|
+
{
|
|
146
|
+
id: readProviderId(values),
|
|
147
|
+
model: readString(values.model, "model"),
|
|
148
|
+
baseUrl: readString(values["base-url"], "base-url"),
|
|
149
|
+
apiKeyEnv: readString(values["api-key-env"], "api-key-env"),
|
|
150
|
+
},
|
|
151
|
+
env,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
terminal.err(
|
|
155
|
+
`${diff.files.length} files, +${diff.additions} -${diff.deletions}, ${Buffer.byteLength(diff.patch, "utf8")} bytes of diff${diff.truncatedAt === undefined ? "" : " (truncated)"}`,
|
|
156
|
+
);
|
|
157
|
+
terminal.err(`prompt: ${Buffer.byteLength(user, "utf8")} bytes`);
|
|
158
|
+
|
|
159
|
+
if (provider === undefined) {
|
|
160
|
+
terminal.out(`✓ dry run — nothing was sent, and nothing was written`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pr = values.pr === undefined ? undefined : readInt(values.pr, "pr", 0);
|
|
165
|
+
const pullRequest =
|
|
166
|
+
pr === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: { number: pr, url: `https://${slug.host}/${slug.owner}/${slug.name}/pull/${pr}` };
|
|
169
|
+
|
|
170
|
+
const provenance: GraphDocInput["provenance"] = {
|
|
171
|
+
repo: slug,
|
|
172
|
+
base: { sha: comparedAgainst, ref: baseCommit.ref },
|
|
173
|
+
head: { sha: headCommit.sha, ref: headCommit.ref },
|
|
174
|
+
...(pullRequest === undefined ? {} : { pullRequest }),
|
|
175
|
+
generator: { name: GENERATOR_NAME, version: CLI_VERSION, model: provider.model },
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
terminal.err(`asking ${provider.id} (${provider.model})…`);
|
|
179
|
+
|
|
180
|
+
const { document, attempts } = await extractGraph(
|
|
181
|
+
{
|
|
182
|
+
system: SYSTEM_PROMPT,
|
|
183
|
+
user,
|
|
184
|
+
maxOutputTokens: readInt(values["max-output-tokens"], "max-output-tokens", DEFAULT_MAX_OUTPUT_TOKENS),
|
|
185
|
+
known: {
|
|
186
|
+
provenance,
|
|
187
|
+
lenses,
|
|
188
|
+
stats: {
|
|
189
|
+
filesChanged: diff.files.length,
|
|
190
|
+
additions: diff.additions,
|
|
191
|
+
deletions: diff.deletions,
|
|
192
|
+
},
|
|
193
|
+
generatedAt: new Date().toISOString(),
|
|
194
|
+
},
|
|
195
|
+
onAttempt: (attempt) => {
|
|
196
|
+
if (attempt > 1) terminal.err(`the document did not validate — asking for a correction`);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
(request) => completeJson(provider, request),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const written = await writeJsonFile(readString(values.out, "out") ?? DEFAULT_OUT, document);
|
|
203
|
+
terminal.out(
|
|
204
|
+
`✓ ${written} — ${document.nodes.length} nodes, ${document.edges.length} edges, ${document.flows.length} flows${attempts > 1 ? ` (${attempts} attempts)` : ""}`,
|
|
205
|
+
);
|
|
206
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { graphContentHash } from "@coldtea/pr-lens-renderer";
|
|
2
|
+
import { parseOptions, readBoolean, readString } from "../args.js";
|
|
3
|
+
import { composeComment, COMMENT_MARKER } from "../comment.js";
|
|
4
|
+
import { loadConfig } from "../config-file.js";
|
|
5
|
+
import { readGraphDoc, readRenderManifest } from "../document.js";
|
|
6
|
+
import { usageError } from "../errors.js";
|
|
7
|
+
import { writeTextFile } from "../io.js";
|
|
8
|
+
import type { Terminal } from "../terminal.js";
|
|
9
|
+
|
|
10
|
+
export const USAGE = `pr-lens comment --graph <graph.json> --manifest <manifest.json> [options]
|
|
11
|
+
|
|
12
|
+
Composes the pull request comment: the diagrams as light/dark <picture> pairs,
|
|
13
|
+
the headline numbers, and the drill-down tree. It posts nothing — the markdown
|
|
14
|
+
goes to stdout, or to a file, for whatever does the posting.
|
|
15
|
+
|
|
16
|
+
--graph <file> the document that was rendered — the one the render
|
|
17
|
+
wrote beside the manifest, not the one it read
|
|
18
|
+
--manifest <file> what the render produced (required)
|
|
19
|
+
--asset-base-url <url> where the rendered SVGs are published, when the
|
|
20
|
+
manifest records local paths
|
|
21
|
+
--config <file> read 'branding' from a repository config
|
|
22
|
+
--no-branding leave off the PR Lens footer
|
|
23
|
+
--print-marker print the hidden marker that identifies the
|
|
24
|
+
comment, and nothing else
|
|
25
|
+
-o, --out <file> write the markdown here instead of stdout`;
|
|
26
|
+
|
|
27
|
+
export const commentCommand = async (args: readonly string[], terminal: Terminal): Promise<void> => {
|
|
28
|
+
const { values, positionals } = parseOptions(args, {
|
|
29
|
+
graph: { type: "string" },
|
|
30
|
+
manifest: { type: "string" },
|
|
31
|
+
"asset-base-url": { type: "string" },
|
|
32
|
+
config: { type: "string" },
|
|
33
|
+
"no-branding": { type: "boolean" },
|
|
34
|
+
"print-marker": { type: "boolean" },
|
|
35
|
+
out: { type: "string", short: "o" },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (positionals.length > 0)
|
|
39
|
+
throw usageError(`comment takes no positional arguments, got ${positionals.join(" ")}`);
|
|
40
|
+
|
|
41
|
+
if (readBoolean(values["print-marker"])) {
|
|
42
|
+
terminal.out(COMMENT_MARKER);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const graphPath = readString(values.graph, "graph");
|
|
47
|
+
const manifestPath = readString(values.manifest, "manifest");
|
|
48
|
+
if (graphPath === undefined || manifestPath === undefined)
|
|
49
|
+
throw usageError("--graph and --manifest are both required");
|
|
50
|
+
|
|
51
|
+
const configPath = readString(values.config, "config");
|
|
52
|
+
const configured = configPath === undefined ? undefined : await loadConfig(configPath);
|
|
53
|
+
|
|
54
|
+
const graph = await readGraphDoc(graphPath);
|
|
55
|
+
const manifest = await readRenderManifest(manifestPath);
|
|
56
|
+
|
|
57
|
+
if (graphContentHash(graph) !== manifest.graph.contentHash)
|
|
58
|
+
throw usageError(
|
|
59
|
+
`${graphPath} is not the document ${manifestPath} describes`,
|
|
60
|
+
"corrections change what the diagrams show, so a comment built from a different document would announce sections that were never drawn. Pass the drawn.graph.json the render wrote beside the manifest",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const body = composeComment({
|
|
64
|
+
graph,
|
|
65
|
+
manifest,
|
|
66
|
+
assetBaseUrl: readString(values["asset-base-url"], "asset-base-url"),
|
|
67
|
+
branding: readBoolean(values["no-branding"]) ? false : (configured?.config.branding ?? true),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const out = readString(values.out, "out");
|
|
71
|
+
if (out === undefined) {
|
|
72
|
+
terminal.out(body.trimEnd());
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
terminal.err(`✓ ${await writeTextFile(out, body)}`);
|
|
77
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expectOne, parseOptions, readString } from "../args.js";
|
|
2
|
+
import { invalidDocument, readGraphDoc } from "../document.js";
|
|
3
|
+
import { PrLensCliError, usageError } from "../errors.js";
|
|
4
|
+
import { writeJsonFile } from "../io.js";
|
|
5
|
+
import { toStoredMap } from "../snapshot.js";
|
|
6
|
+
import type { Terminal } from "../terminal.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_OUT = ".github/pr-lens.map.json";
|
|
9
|
+
|
|
10
|
+
export const USAGE = `pr-lens export <graph.json> [options]
|
|
11
|
+
|
|
12
|
+
Turns a pull-request document into the stored map of the system once that pull
|
|
13
|
+
request has merged: elements the change deletes are dropped, the rest stops
|
|
14
|
+
being annotated, and the result is stamped with the single commit it reflects.
|
|
15
|
+
|
|
16
|
+
The map is a snapshot, not a source of truth — nothing reads it back into the
|
|
17
|
+
pipeline. Committing it gives a repository something to read, to diff, and to
|
|
18
|
+
hand an agent.
|
|
19
|
+
|
|
20
|
+
-o, --out <file> where to write it (default ${DEFAULT_OUT})
|
|
21
|
+
--id <id> map id, so a patch can name it (default <owner>/<name>)
|
|
22
|
+
--sha <sha> the commit it reflects, in full (default the document's head)`;
|
|
23
|
+
|
|
24
|
+
export const exportCommand = async (args: readonly string[], terminal: Terminal): Promise<void> => {
|
|
25
|
+
const { values, positionals } = parseOptions(args, {
|
|
26
|
+
out: { type: "string", short: "o" },
|
|
27
|
+
id: { type: "string" },
|
|
28
|
+
sha: { type: "string" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const source = expectOne(positionals, "one graph document to export");
|
|
32
|
+
if (/^https?:\/\//.test(source))
|
|
33
|
+
throw usageError(
|
|
34
|
+
"export reads a local graph document",
|
|
35
|
+
"fetching a map from a hosted pipeline is not something the CLI does yet",
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const graph = await readGraphDoc(source);
|
|
39
|
+
const out = readString(values.out, "out") ?? DEFAULT_OUT;
|
|
40
|
+
|
|
41
|
+
const stored = toStoredMap(graph, {
|
|
42
|
+
id: readString(values.id, "id") ?? `${graph.provenance.repo.owner}/${graph.provenance.repo.name}`,
|
|
43
|
+
sha: readString(values.sha, "sha") ?? graph.provenance.head.sha,
|
|
44
|
+
generatedAt: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!stored.ok) {
|
|
48
|
+
if (stored.error.code === "NOT_A_SNAPSHOT" || stored.error.code === "PATCH_CONFLICT")
|
|
49
|
+
throw new PrLensCliError(
|
|
50
|
+
"INVALID_DOCUMENT",
|
|
51
|
+
`${source} cannot be exported as a stored map [${stored.error.code}]`,
|
|
52
|
+
stored.error.message,
|
|
53
|
+
);
|
|
54
|
+
throw invalidDocument(source, "graph", stored.error);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const written = await writeJsonFile(out, stored.value);
|
|
58
|
+
terminal.out(
|
|
59
|
+
`✓ ${written} — stored map of ${stored.value.provenance.repo.owner}/${stored.value.provenance.repo.name} at ${stored.value.provenance.head.sha.slice(0, 7)} · ${stored.value.nodes.length} nodes, ${stored.value.edges.length} edges`,
|
|
60
|
+
);
|
|
61
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { applyCorrections, PrLensRenderError, renderAll, THEMES, type Theme } from "@coldtea/pr-lens-renderer";
|
|
2
|
+
import { safeParseGraphDoc, type Config, type GraphDoc } from "@coldtea/pr-lens-schema";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { expectOne, parseOptions, readBoolean, readString } from "../args.js";
|
|
5
|
+
import { discoverConfig, loadConfig } from "../config-file.js";
|
|
6
|
+
import { unmatchedCorrections } from "../corrections.js";
|
|
7
|
+
import { readGraphDoc } from "../document.js";
|
|
8
|
+
import { PrLensCliError, usageError } from "../errors.js";
|
|
9
|
+
import { repositoryRoot } from "../git.js";
|
|
10
|
+
import { writeJsonFile, writeTextFile } from "../io.js";
|
|
11
|
+
import type { Terminal } from "../terminal.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_OUT = "pr-lens";
|
|
14
|
+
const MANIFEST = "manifest.json";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The document as it was drawn, written beside the manifest.
|
|
18
|
+
*
|
|
19
|
+
* Corrections are applied here, so the document on the way in and the
|
|
20
|
+
* diagrams on the way out can describe different systems — a view whose every
|
|
21
|
+
* element was excluded is drawn by neither. Whatever composes the comment has
|
|
22
|
+
* to read the one the pictures came from, and it is a different file from the
|
|
23
|
+
* one analyze wrote so that neither overwrites the other.
|
|
24
|
+
*/
|
|
25
|
+
const DRAWN = "drawn.graph.json";
|
|
26
|
+
|
|
27
|
+
export const USAGE = `pr-lens render <graph.json> [options]
|
|
28
|
+
|
|
29
|
+
Draws the document as self-contained SVGs — one per drill-down section per
|
|
30
|
+
lens, in light and dark — and writes the manifest describing them. A document
|
|
31
|
+
with no sections gets one diagram per lens it declares.
|
|
32
|
+
|
|
33
|
+
-o, --out <dir> where the SVGs and the manifest go (default ${DEFAULT_OUT}/)
|
|
34
|
+
--theme <theme> light | dark | both (default both)
|
|
35
|
+
--config <file> corrections to draw with (default the repository's, if any)
|
|
36
|
+
--no-config ignore the repository's corrections`;
|
|
37
|
+
|
|
38
|
+
const readThemes = (value: unknown): readonly Theme[] => {
|
|
39
|
+
const theme = readString(value, "theme") ?? "both";
|
|
40
|
+
switch (theme) {
|
|
41
|
+
case "light":
|
|
42
|
+
case "dark":
|
|
43
|
+
return [theme];
|
|
44
|
+
case "both":
|
|
45
|
+
return THEMES;
|
|
46
|
+
default:
|
|
47
|
+
throw usageError(`--theme takes light, dark or both, got ${JSON.stringify(theme)}`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The overlay is applied here rather than by passing the config into
|
|
53
|
+
* `renderAll`, which would apply the same function to the same document — but
|
|
54
|
+
* out of reach, and the comment has to be composed from what was drawn.
|
|
55
|
+
*/
|
|
56
|
+
const correct = (graph: GraphDoc, config: Config): GraphDoc => {
|
|
57
|
+
try {
|
|
58
|
+
return applyCorrections(graph, config.map);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (!(error instanceof PrLensRenderError)) throw error;
|
|
61
|
+
throw new PrLensCliError("RENDER_FAILED", `${error.message} [${error.code}]`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const renderCommand = async (args: readonly string[], terminal: Terminal): Promise<void> => {
|
|
66
|
+
const { values, positionals } = parseOptions(args, {
|
|
67
|
+
out: { type: "string", short: "o" },
|
|
68
|
+
theme: { type: "string" },
|
|
69
|
+
config: { type: "string" },
|
|
70
|
+
"no-config": { type: "boolean" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const graph = await readGraphDoc(expectOne(positionals, "one graph document to render"));
|
|
74
|
+
const out = readString(values.out, "out") ?? DEFAULT_OUT;
|
|
75
|
+
|
|
76
|
+
const configPath = readString(values.config, "config");
|
|
77
|
+
const configured = readBoolean(values["no-config"])
|
|
78
|
+
? undefined
|
|
79
|
+
: configPath === undefined
|
|
80
|
+
? await discoverConfig(await repositoryRoot(process.cwd()).catch(() => process.cwd()))
|
|
81
|
+
: await loadConfig(configPath);
|
|
82
|
+
|
|
83
|
+
const themes = readThemes(values.theme);
|
|
84
|
+
|
|
85
|
+
const drawn = (() => {
|
|
86
|
+
if (configured === undefined) return graph;
|
|
87
|
+
|
|
88
|
+
for (const warning of unmatchedCorrections(graph, configured.config))
|
|
89
|
+
terminal.err(`${configured.path}: ${warning}`);
|
|
90
|
+
|
|
91
|
+
const corrected = correct(graph, configured.config);
|
|
92
|
+
const parsed = safeParseGraphDoc(corrected);
|
|
93
|
+
if (!parsed.ok)
|
|
94
|
+
throw new PrLensCliError(
|
|
95
|
+
`INVALID_DOCUMENT`,
|
|
96
|
+
`${configured.path} leaves a document that no longer validates [${parsed.error.code}]`,
|
|
97
|
+
parsed.error.message,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return parsed.value;
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
const rendered = (() => {
|
|
104
|
+
try {
|
|
105
|
+
return renderAll(drawn, { themes });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof PrLensRenderError)) throw error;
|
|
108
|
+
throw new PrLensCliError("RENDER_FAILED", `${error.message} [${error.code}]`);
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
|
|
112
|
+
for (const drawing of rendered.assets) {
|
|
113
|
+
const { id, path } = drawing.asset;
|
|
114
|
+
if (path === undefined)
|
|
115
|
+
throw new PrLensCliError("RENDER_FAILED", `the render named no file for asset '${id}'`);
|
|
116
|
+
await writeTextFile(join(out, path), drawing.svg);
|
|
117
|
+
}
|
|
118
|
+
await writeJsonFile(join(out, DRAWN), drawn);
|
|
119
|
+
await writeJsonFile(join(out, MANIFEST), rendered.manifest);
|
|
120
|
+
|
|
121
|
+
const diagrams = new Set(rendered.assets.map((asset) => asset.asset.view ?? asset.lens));
|
|
122
|
+
terminal.out(
|
|
123
|
+
`✓ ${join(out, MANIFEST)} — ${rendered.assets.length} SVGs across ${diagrams.size} ${diagrams.size === 1 ? "diagram" : "diagrams"}${configured === undefined ? "" : `, corrected by ${configured.path}`}`,
|
|
124
|
+
);
|
|
125
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
2
|
+
import { parseOptions } from "../args.js";
|
|
3
|
+
import { validateDocumentFile, type ValidatedDocument } from "../document.js";
|
|
4
|
+
import { formatError, PrLensCliError, usageError } from "../errors.js";
|
|
5
|
+
import type { Terminal } from "../terminal.js";
|
|
6
|
+
|
|
7
|
+
const count = (n: number, singular: string): string =>
|
|
8
|
+
`${n} ${n === 1 ? singular : `${singular}s`}`;
|
|
9
|
+
|
|
10
|
+
const describe = (validated: ValidatedDocument): string => {
|
|
11
|
+
switch (validated.kind) {
|
|
12
|
+
case "graph": {
|
|
13
|
+
const { lanes, nodes, edges, flows, lenses } = validated.document;
|
|
14
|
+
return `graph document · ${count(lanes.length, "lane")}, ${count(nodes.length, "node")}, ${count(edges.length, "edge")}, ${count(flows.length, "flow")} · ${lenses.join(", ")}`;
|
|
15
|
+
}
|
|
16
|
+
case "patch":
|
|
17
|
+
return `patch document · ${count(validated.document.ops.length, "operation")} · ${validated.document.target.fromSha.slice(0, 7)} → ${validated.document.target.toSha.slice(0, 7)}`;
|
|
18
|
+
case "render-manifest":
|
|
19
|
+
return `render manifest · ${count(validated.document.assets.length, "asset")}`;
|
|
20
|
+
case "config": {
|
|
21
|
+
const { map, lenses } = validated.document;
|
|
22
|
+
const corrections =
|
|
23
|
+
map.rename.length + map.exclude.length + map.lane.length + map.group.length;
|
|
24
|
+
return `config · ${lenses.join(", ")} · ${count(corrections, "correction")}`;
|
|
25
|
+
}
|
|
26
|
+
default:
|
|
27
|
+
return assertNever(validated, "Unhandled document");
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const USAGE = `pr-lens validate <file...>
|
|
32
|
+
|
|
33
|
+
Parses graph documents, patch documents, render manifests and configs — JSON or
|
|
34
|
+
YAML — and reports every problem in each, rather than only the first. A file
|
|
35
|
+
without a 'kind' field is read as a config.`;
|
|
36
|
+
|
|
37
|
+
export const validateCommand = async (args: readonly string[], terminal: Terminal): Promise<void> => {
|
|
38
|
+
const { positionals } = parseOptions(args, {});
|
|
39
|
+
if (positionals.length === 0) throw usageError("expected at least one file to validate");
|
|
40
|
+
|
|
41
|
+
let failed = 0;
|
|
42
|
+
for (const path of positionals) {
|
|
43
|
+
try {
|
|
44
|
+
terminal.out(`✓ ${path} — ${describe(await validateDocumentFile(path))}`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof PrLensCliError)) throw error;
|
|
47
|
+
failed += 1;
|
|
48
|
+
terminal.err(formatError(error));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (failed > 0)
|
|
53
|
+
throw new PrLensCliError(
|
|
54
|
+
"INVALID_DOCUMENT",
|
|
55
|
+
`${failed} of ${count(positionals.length, "document")} did not validate`,
|
|
56
|
+
);
|
|
57
|
+
};
|