@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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/dist/args.d.ts +18 -0
  4. package/dist/args.d.ts.map +1 -0
  5. package/dist/args.js +49 -0
  6. package/dist/args.js.map +1 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +8 -0
  10. package/dist/bin.js.map +1 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +97 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/commands/analyze.d.ts +4 -0
  16. package/dist/commands/analyze.d.ts.map +1 -0
  17. package/dist/commands/analyze.js +158 -0
  18. package/dist/commands/analyze.js.map +1 -0
  19. package/dist/commands/comment.d.ts +4 -0
  20. package/dist/commands/comment.d.ts.map +1 -0
  21. package/dist/commands/comment.js +63 -0
  22. package/dist/commands/comment.js.map +1 -0
  23. package/dist/commands/export.d.ts +4 -0
  24. package/dist/commands/export.d.ts.map +1 -0
  25. package/dist/commands/export.js +44 -0
  26. package/dist/commands/export.js.map +1 -0
  27. package/dist/commands/render.d.ts +4 -0
  28. package/dist/commands/render.d.ts.map +1 -0
  29. package/dist/commands/render.js +108 -0
  30. package/dist/commands/render.js.map +1 -0
  31. package/dist/commands/validate.d.ts +4 -0
  32. package/dist/commands/validate.d.ts.map +1 -0
  33. package/dist/commands/validate.js +49 -0
  34. package/dist/commands/validate.js.map +1 -0
  35. package/dist/comment.d.ts +16 -0
  36. package/dist/comment.d.ts.map +1 -0
  37. package/dist/comment.js +148 -0
  38. package/dist/comment.js.map +1 -0
  39. package/dist/config-file.d.ts +10 -0
  40. package/dist/config-file.d.ts.map +1 -0
  41. package/dist/config-file.js +19 -0
  42. package/dist/config-file.js.map +1 -0
  43. package/dist/corrections.d.ts +16 -0
  44. package/dist/corrections.d.ts.map +1 -0
  45. package/dist/corrections.js +80 -0
  46. package/dist/corrections.js.map +1 -0
  47. package/dist/document.d.ts +23 -0
  48. package/dist/document.d.ts.map +1 -0
  49. package/dist/document.js +75 -0
  50. package/dist/document.js.map +1 -0
  51. package/dist/errors.d.ts +15 -0
  52. package/dist/errors.d.ts.map +1 -0
  53. package/dist/errors.js +14 -0
  54. package/dist/errors.js.map +1 -0
  55. package/dist/extract.d.ts +54 -0
  56. package/dist/extract.d.ts.map +1 -0
  57. package/dist/extract.js +84 -0
  58. package/dist/extract.js.map +1 -0
  59. package/dist/git.d.ts +40 -0
  60. package/dist/git.d.ts.map +1 -0
  61. package/dist/git.js +104 -0
  62. package/dist/git.js.map +1 -0
  63. package/dist/io.d.ts +6 -0
  64. package/dist/io.d.ts.map +1 -0
  65. package/dist/io.js +35 -0
  66. package/dist/io.js.map +1 -0
  67. package/dist/prompt.d.ts +23 -0
  68. package/dist/prompt.d.ts.map +1 -0
  69. package/dist/prompt.js +69 -0
  70. package/dist/prompt.js.map +1 -0
  71. package/dist/providers/gemini.d.ts +3 -0
  72. package/dist/providers/gemini.d.ts.map +1 -0
  73. package/dist/providers/gemini.js +46 -0
  74. package/dist/providers/gemini.js.map +1 -0
  75. package/dist/providers/http.d.ts +5 -0
  76. package/dist/providers/http.d.ts.map +1 -0
  77. package/dist/providers/http.js +26 -0
  78. package/dist/providers/http.js.map +1 -0
  79. package/dist/providers/index.d.ts +48 -0
  80. package/dist/providers/index.d.ts.map +1 -0
  81. package/dist/providers/index.js +71 -0
  82. package/dist/providers/index.js.map +1 -0
  83. package/dist/providers/openai.d.ts +17 -0
  84. package/dist/providers/openai.d.ts.map +1 -0
  85. package/dist/providers/openai.js +42 -0
  86. package/dist/providers/openai.js.map +1 -0
  87. package/dist/snapshot.d.ts +15 -0
  88. package/dist/snapshot.d.ts.map +1 -0
  89. package/dist/snapshot.js +77 -0
  90. package/dist/snapshot.js.map +1 -0
  91. package/dist/terminal.d.ts +6 -0
  92. package/dist/terminal.d.ts.map +1 -0
  93. package/dist/terminal.js +5 -0
  94. package/dist/terminal.js.map +1 -0
  95. package/dist/version.d.ts +4 -0
  96. package/dist/version.d.ts.map +1 -0
  97. package/dist/version.js +4 -0
  98. package/dist/version.js.map +1 -0
  99. package/package.json +57 -0
  100. package/src/args.ts +52 -0
  101. package/src/bin.ts +10 -0
  102. package/src/cli.ts +120 -0
  103. package/src/commands/analyze.ts +206 -0
  104. package/src/commands/comment.ts +77 -0
  105. package/src/commands/export.ts +61 -0
  106. package/src/commands/render.ts +125 -0
  107. package/src/commands/validate.ts +57 -0
  108. package/src/comment.ts +190 -0
  109. package/src/config-file.ts +26 -0
  110. package/src/corrections.ts +86 -0
  111. package/src/document.ts +119 -0
  112. package/src/errors.ts +36 -0
  113. package/src/extract.ts +136 -0
  114. package/src/git.ts +151 -0
  115. package/src/io.ts +38 -0
  116. package/src/prompt.ts +87 -0
  117. package/src/providers/gemini.ts +61 -0
  118. package/src/providers/http.ts +54 -0
  119. package/src/providers/index.ts +124 -0
  120. package/src/providers/openai.ts +68 -0
  121. package/src/snapshot.ts +102 -0
  122. package/src/terminal.ts +9 -0
  123. package/src/version.ts +4 -0
package/src/git.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { PrLensCliError } from "./errors.js";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ /** Large enough for any diff worth sending to a model; a bigger one is truncated anyway. */
8
+ const MAX_GIT_OUTPUT_BYTES = 64 * 1024 * 1024;
9
+
10
+ export const git = async (repo: string, args: readonly string[]): Promise<string> => {
11
+ try {
12
+ const { stdout } = await run("git", [...args], {
13
+ cwd: repo,
14
+ maxBuffer: MAX_GIT_OUTPUT_BYTES,
15
+ encoding: "utf8",
16
+ });
17
+ return stdout;
18
+ } catch (error) {
19
+ const stderr =
20
+ typeof error === "object" && error !== null && "stderr" in error ? String(error.stderr) : "";
21
+ throw new PrLensCliError(
22
+ "GIT_FAILED",
23
+ `git ${args.join(" ")} failed in ${repo}`,
24
+ stderr.trim() || (error instanceof Error ? error.message : String(error)),
25
+ );
26
+ }
27
+ };
28
+
29
+ export type Commit = { sha: string; ref: string | undefined };
30
+
31
+ /**
32
+ * A ref becomes the commit it named at the moment of the run, and a branch
33
+ * keeps its name for the permalinks. `HEAD` is resolved to a branch name when
34
+ * it is on one, since "HEAD" tells a reader of the comment nothing.
35
+ */
36
+ export const resolveCommit = async (repo: string, ref: string): Promise<Commit> => {
37
+ const sha = (await git(repo, ["rev-parse", "--verify", `${ref}^{commit}`])).trim();
38
+ if (ref !== "HEAD") return { sha, ref };
39
+
40
+ const branch = (await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
41
+ return { sha, ref: branch === "HEAD" ? undefined : branch };
42
+ };
43
+
44
+ /**
45
+ * The commit the pull request would be compared against, not the tip of the
46
+ * base branch: a diff against the tip would attribute every change made on
47
+ * the base branch since the fork point to this pull request.
48
+ */
49
+ export const mergeBase = async (repo: string, base: string, head: string): Promise<string> => {
50
+ const merged = await git(repo, ["merge-base", base, head]).catch(() => undefined);
51
+ if (merged === undefined)
52
+ throw new PrLensCliError(
53
+ "GIT_FAILED",
54
+ `${base} and ${head} have no common ancestor`,
55
+ "fetch the base branch, or pass --base with a ref that shares history with the head",
56
+ );
57
+ return merged.trim();
58
+ };
59
+
60
+ export type ChangedFile = {
61
+ path: string;
62
+ additions: number | undefined;
63
+ deletions: number | undefined;
64
+ };
65
+
66
+ export type Diff = {
67
+ files: ChangedFile[];
68
+ additions: number;
69
+ deletions: number;
70
+ patch: string;
71
+ truncatedAt: number | undefined;
72
+ };
73
+
74
+ /** `--numstat` reports a binary file's line counts as `-`, which is not zero. */
75
+ const parseNumstat = (stdout: string): ChangedFile[] =>
76
+ stdout
77
+ .split("\n")
78
+ .filter((line) => line.trim() !== "")
79
+ .flatMap((line) => {
80
+ const [additions, deletions, ...rest] = line.split("\t");
81
+ const path = rest.join("\t");
82
+ if (additions === undefined || deletions === undefined || path === "") return [];
83
+ const count = (value: string): number | undefined =>
84
+ value === "-" ? undefined : Number(value);
85
+ return [{ path, additions: count(additions), deletions: count(deletions) }];
86
+ });
87
+
88
+ const sum = (values: readonly (number | undefined)[]): number =>
89
+ values.reduce<number>((total, value) => total + (value ?? 0), 0);
90
+
91
+ export const collectDiff = async (
92
+ repo: string,
93
+ base: string,
94
+ head: string,
95
+ maxPatchBytes: number,
96
+ ): Promise<Diff> => {
97
+ const files = parseNumstat(await git(repo, ["diff", "--numstat", "--find-renames", base, head]));
98
+ const patch = await git(repo, [
99
+ "diff",
100
+ "--no-color",
101
+ "--find-renames",
102
+ "--unified=3",
103
+ base,
104
+ head,
105
+ ]);
106
+
107
+ const withinBudget = Buffer.byteLength(patch, "utf8") <= maxPatchBytes;
108
+
109
+ return {
110
+ files,
111
+ additions: sum(files.map((file) => file.additions)),
112
+ deletions: sum(files.map((file) => file.deletions)),
113
+ patch: withinBudget ? patch : Buffer.from(patch, "utf8").subarray(0, maxPatchBytes).toString("utf8"),
114
+ truncatedAt: withinBudget ? undefined : maxPatchBytes,
115
+ };
116
+ };
117
+
118
+ export type RepoSlug = { owner: string; name: string; host: string };
119
+
120
+ const REMOTE_URL = /^(?:(?:ssh|git|https?):\/\/)?(?:[^@/]+@)?([^/:]+)[:/](.+?)(?:\.git)?\/?$/;
121
+
122
+ export const parseRemoteUrl = (url: string): RepoSlug | undefined => {
123
+ const match = REMOTE_URL.exec(url.trim());
124
+ if (!match) return undefined;
125
+
126
+ const [, host, path] = match;
127
+ if (host === undefined || path === undefined) return undefined;
128
+
129
+ const segments = path.split("/").filter((segment) => segment !== "");
130
+ const name = segments.at(-1);
131
+ const owner = segments.slice(0, -1).join("/");
132
+ if (name === undefined || owner === "") return undefined;
133
+
134
+ return { owner, name, host };
135
+ };
136
+
137
+ export const parseRepoSlug = (slug: string): RepoSlug | undefined => {
138
+ const segments = slug.split("/").filter((segment) => segment !== "");
139
+ const name = segments.at(-1);
140
+ const owner = segments.slice(0, -1).join("/");
141
+ if (name === undefined || owner === "" || segments.length !== 2) return undefined;
142
+ return { owner, name, host: "github.com" };
143
+ };
144
+
145
+ export const remoteSlug = async (repo: string, remote: string): Promise<RepoSlug | undefined> => {
146
+ const url = await git(repo, ["remote", "get-url", remote]).catch(() => undefined);
147
+ return url === undefined ? undefined : parseRemoteUrl(url);
148
+ };
149
+
150
+ export const repositoryRoot = async (repo: string): Promise<string> =>
151
+ (await git(repo, ["rev-parse", "--show-toplevel"])).trim();
package/src/io.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { PrLensCliError } from "./errors.js";
4
+
5
+ const describe = (error: unknown): string =>
6
+ error instanceof Error ? error.message : String(error);
7
+
8
+ export const readTextFile = async (path: string): Promise<string> => {
9
+ try {
10
+ return await readFile(resolve(path), "utf8");
11
+ } catch (error) {
12
+ throw new PrLensCliError("UNREADABLE_FILE", `cannot read ${path}`, describe(error));
13
+ }
14
+ };
15
+
16
+ export const readJsonFile = async (path: string): Promise<unknown> => {
17
+ const text = await readTextFile(path);
18
+ try {
19
+ return JSON.parse(text);
20
+ } catch (error) {
21
+ throw new PrLensCliError("UNREADABLE_FILE", `${path} is not valid JSON`, describe(error));
22
+ }
23
+ };
24
+
25
+ export const writeTextFile = async (path: string, contents: string): Promise<string> => {
26
+ const absolute = resolve(path);
27
+ try {
28
+ await mkdir(dirname(absolute), { recursive: true });
29
+ await writeFile(absolute, contents, "utf8");
30
+ } catch (error) {
31
+ throw new PrLensCliError("UNREADABLE_FILE", `cannot write ${path}`, describe(error));
32
+ }
33
+ return absolute;
34
+ };
35
+
36
+ /** One trailing newline, so written documents behave in a diff and in a shell. */
37
+ export const writeJsonFile = (path: string, value: unknown): Promise<string> =>
38
+ writeTextFile(path, `${JSON.stringify(value, null, 2)}\n`);
package/src/prompt.ts ADDED
@@ -0,0 +1,87 @@
1
+ import type { Lens, PrLensSchemaError } from "@coldtea/pr-lens-schema";
2
+ import type { Diff } from "./git.js";
3
+
4
+ export type PromptContext = {
5
+ repo: { owner: string; name: string };
6
+ base: { sha: string; ref: string | undefined };
7
+ head: { sha: string; ref: string | undefined };
8
+ diff: Diff;
9
+ lenses: readonly Lens[];
10
+ };
11
+
12
+ export const SYSTEM_PROMPT = [
13
+ "You read a pull request diff and produce one PR Lens graph document: the map a reviewer wishes they had before reading the code.",
14
+ "",
15
+ "You are not a review bot. You never report bugs, risks, style or security findings — there is no field for them and a document that carries them is rejected. Your only job is to describe what the system looks like and how data moves through it, and to mark what this change did to it.",
16
+ "",
17
+ "Rules the validator enforces, in the order they are usually broken:",
18
+ "- Unknown fields are rejected outright. Emit only what the schema names.",
19
+ "- Every edge endpoint and every flow participant must be a node id you declared, and every node must sit in a lane id you declared. An edge to an undeclared node is the single most common failure.",
20
+ "- Ids match ^[A-Za-z0-9][A-Za-z0-9._:/-]*$ and are unique within their collection. Prefer readable kebab-case, e.g. 'broadcast-sender'.",
21
+ "- File paths are repository-relative and POSIX: no leading slash, no drive letter, no backslash, no '..' segment.",
22
+ "- A flow's step order is the order of the messages array. There is no step number field.",
23
+ "- A message with kind 'self' has from equal to to, and no other kind may.",
24
+ "- A document that carries flows must declare the 'data-flow' lens.",
25
+ "",
26
+ "What makes the document worth reading:",
27
+ "- Include the unchanged neighbours the changed code touches, marked delta 'unchanged'. They are the context that makes blast radius legible; a diagram of only changed nodes says nothing about impact.",
28
+ "- Lanes are the reader's mental model of the system — a runtime, a tier, a boundary they already hold in their head — not the folder tree.",
29
+ "- Use as few lanes as carry that meaning: three or four. Every lane makes the diagram wider, and a diagram wider than a pull request comment is shown scaled down — past about four lanes the labels on the cards stop being readable without opening the image. If you reach six, two of them are usually the same boundary at different granularity: merge them, and express the finer split as a `group` on the nodes instead, which costs no width.",
30
+ "- Mark at most one or two edges 'hero': the connection the change is really about.",
31
+ "- Give the document a title a reviewer would recognise, and a summary that answers 'what does this change do?' in a short paragraph.",
32
+ "- Attach file refs to nodes, edges and steps wherever the diff shows where they live: they become the permalinks a reader clicks.",
33
+ "- Add a data flow only when the change actually has an ordered sequence worth animating. One good flow beats three thin ones.",
34
+ "- Build the drill-down tree: a view over the whole document, and a focused view for each part of the change that deserves its own section. Views are what a reader expands in the comment, so a document without them renders as one picture and nothing else.",
35
+ "- Stat chips carry the numbers that make the change legible, e.g. label 'Postmark calls' value '500x fewer'. Leave out files changed and line counts — those are filled in for you.",
36
+ "",
37
+ "Answer with the JSON document alone. No prose, no markdown fence.",
38
+ ].join("\n");
39
+
40
+ const fileList = (diff: Diff): string =>
41
+ diff.files
42
+ .map((file) => {
43
+ const added = file.additions === undefined ? "bin" : `+${file.additions}`;
44
+ const removed = file.deletions === undefined ? "" : ` -${file.deletions}`;
45
+ return ` ${file.path} (${added}${removed})`;
46
+ })
47
+ .join("\n");
48
+
49
+ export const buildExtractionPrompt = (context: PromptContext, jsonSchema: string): string =>
50
+ [
51
+ `Repository: ${context.repo.owner}/${context.repo.name}`,
52
+ `Base commit: ${context.base.sha}${context.base.ref ? ` (${context.base.ref})` : ""}`,
53
+ `Head commit: ${context.head.sha}${context.head.ref ? ` (${context.head.ref})` : ""}`,
54
+ `Lenses to fill: ${context.lenses.join(", ")} — exactly these, and no others. Anything needing a lens that is not listed is out of scope for this document.`,
55
+ "",
56
+ `Changed files (${context.diff.files.length}, +${context.diff.additions} -${context.diff.deletions}):`,
57
+ fileList(context.diff),
58
+ "",
59
+ "Leave out schemaVersion, kind, generatedAt, provenance, and the numeric fields of stats. Those are filled in from the repository itself, and anything you write there is discarded.",
60
+ "",
61
+ "JSON Schema of the document (draft 2020-12). A field with a default may be omitted:",
62
+ "",
63
+ jsonSchema,
64
+ "",
65
+ "The diff follows.",
66
+ context.diff.truncatedAt === undefined
67
+ ? ""
68
+ : `It was truncated at ${context.diff.truncatedAt} bytes, so the tail of the change is missing; describe what you can see and do not invent the rest.`,
69
+ "",
70
+ context.diff.patch,
71
+ ].join("\n");
72
+
73
+ export const buildJsonRepairPrompt = (reason: string): string =>
74
+ [
75
+ `That answer could not be read as JSON: ${reason}`,
76
+ "",
77
+ "Return the whole document again as a single JSON object and nothing else — no prose around it, no markdown fence, and nothing after the closing brace. If the previous answer was cut off, shorten the document so it fits: fewer nodes and one flow beat a document that never ends.",
78
+ ].join("\n");
79
+
80
+ export const buildRepairPrompt = (error: PrLensSchemaError): string =>
81
+ [
82
+ "That document was rejected. Each line is one problem, as a path into the document, a reason, and the machine code:",
83
+ "",
84
+ error.message,
85
+ "",
86
+ "Return the whole corrected document again, as JSON alone. Fix only what was named — do not restructure the parts that validated, and do not add fields the schema does not name.",
87
+ ].join("\n");
@@ -0,0 +1,61 @@
1
+ import { z } from "zod";
2
+ import { cutOff, postJson, unreadableResponse } from "./http.js";
3
+ import type { JsonCompletion, Provider } from "./index.js";
4
+
5
+ /**
6
+ * Reasoning parts come back in the same array as the answer and must not be
7
+ * concatenated into it.
8
+ */
9
+ const Response = z.object({
10
+ candidates: z
11
+ .array(
12
+ z.object({
13
+ content: z
14
+ .object({ parts: z.array(z.object({ text: z.string().optional(), thought: z.boolean().optional() })).optional() })
15
+ .optional(),
16
+ finishReason: z.string().optional(),
17
+ }),
18
+ )
19
+ .optional(),
20
+ });
21
+
22
+ export const geminiCompleteJson = async (
23
+ provider: Provider,
24
+ request: JsonCompletion,
25
+ ): Promise<string> => {
26
+ const body = await postJson(
27
+ `${provider.baseUrl}/models/${provider.model}:generateContent`,
28
+ { "x-goog-api-key": provider.apiKey },
29
+ {
30
+ systemInstruction: { parts: [{ text: request.system }] },
31
+ contents: request.turns.map((turn) => ({ role: turn.role, parts: [{ text: turn.text }] })),
32
+ generationConfig: {
33
+ responseMimeType: "application/json",
34
+ temperature: 0,
35
+ maxOutputTokens: request.maxOutputTokens,
36
+ },
37
+ },
38
+ );
39
+
40
+ const parsed = Response.safeParse(body);
41
+ if (!parsed.success)
42
+ throw unreadableResponse("Gemini", "the response did not have the shape the API documents");
43
+
44
+ const candidate = parsed.data.candidates?.[0];
45
+ if (candidate === undefined)
46
+ throw unreadableResponse("Gemini", "the response carried no candidate — the prompt may have been blocked");
47
+
48
+ const text = (candidate.content?.parts ?? [])
49
+ .filter((part) => part.thought !== true)
50
+ .map((part) => part.text ?? "")
51
+ .join("");
52
+
53
+ if (text.trim() === "") {
54
+ if (candidate.finishReason === "MAX_TOKENS") throw cutOff("Gemini");
55
+ throw unreadableResponse("Gemini", `finish reason: ${candidate.finishReason ?? "unknown"}`);
56
+ }
57
+
58
+ if (candidate.finishReason === "MAX_TOKENS") throw cutOff("Gemini");
59
+
60
+ return text;
61
+ };
@@ -0,0 +1,54 @@
1
+ import { PrLensCliError } from "../errors.js";
2
+
3
+ /** Long enough for a reasoning model on a large diff, short enough to fail a stuck CI job. */
4
+ const REQUEST_TIMEOUT_MS = 300_000;
5
+
6
+ const MAX_ERROR_BODY_CHARS = 600;
7
+
8
+ export const postJson = async (
9
+ url: string,
10
+ headers: Record<string, string>,
11
+ body: unknown,
12
+ ): Promise<unknown> => {
13
+ const response = await fetch(url, {
14
+ method: "POST",
15
+ headers: { "content-type": "application/json", ...headers },
16
+ body: JSON.stringify(body),
17
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
18
+ }).catch((error: unknown) => {
19
+ throw new PrLensCliError(
20
+ "PROVIDER_FAILED",
21
+ `the request to ${url} did not complete`,
22
+ error instanceof Error ? error.message : String(error),
23
+ );
24
+ });
25
+
26
+ const text = await response.text();
27
+
28
+ if (!response.ok)
29
+ throw new PrLensCliError(
30
+ "PROVIDER_FAILED",
31
+ `${url} answered ${response.status} ${response.statusText}`,
32
+ text.slice(0, MAX_ERROR_BODY_CHARS),
33
+ );
34
+
35
+ try {
36
+ return JSON.parse(text);
37
+ } catch {
38
+ throw new PrLensCliError(
39
+ "PROVIDER_FAILED",
40
+ `${url} answered with something that is not JSON`,
41
+ text.slice(0, MAX_ERROR_BODY_CHARS),
42
+ );
43
+ }
44
+ };
45
+
46
+ export const cutOff = (provider: string): PrLensCliError =>
47
+ new PrLensCliError(
48
+ "PROVIDER_FAILED",
49
+ `${provider} stopped before finishing the document`,
50
+ "the answer hit the output token limit — raise --max-output-tokens, or narrow the diff with --base",
51
+ );
52
+
53
+ export const unreadableResponse = (provider: string, detail: string): PrLensCliError =>
54
+ new PrLensCliError("PROVIDER_FAILED", `${provider} returned no usable text`, detail);
@@ -0,0 +1,124 @@
1
+ import { assertNever } from "@coldtea/pr-lens-schema";
2
+ import { PrLensCliError } from "../errors.js";
3
+ import { geminiCompleteJson } from "./gemini.js";
4
+ import { openAiCompleteJson } from "./openai.js";
5
+
6
+ /**
7
+ * Three shapes rather than a list of vendors: Gemini's own API, OpenAI's own
8
+ * API, and the `/chat/completions` shape everyone else copied from it.
9
+ *
10
+ * OpenAI is separate from the servers that copied it because the two have
11
+ * drifted: it renamed the output limit to `max_completion_tokens` and its
12
+ * newer models reject `max_tokens`, which is the only spelling DeepSeek,
13
+ * Ollama and llama.cpp know. Everything else about the request is shared, and
14
+ * a new vendor still needs no code here — only a `--base-url`.
15
+ */
16
+ export const PROVIDER_IDS = ["gemini", "openai", "openai-compatible"] as const;
17
+ export type ProviderId = (typeof PROVIDER_IDS)[number];
18
+
19
+ export const isProviderId = (value: string): value is ProviderId =>
20
+ PROVIDER_IDS.some((id) => id === value);
21
+
22
+ export type Provider = {
23
+ id: ProviderId;
24
+ model: string;
25
+ apiKey: string;
26
+ baseUrl: string;
27
+ };
28
+
29
+ export type Turn = { role: "user" | "model"; text: string };
30
+
31
+ export type JsonCompletion = {
32
+ system: string;
33
+ turns: readonly Turn[];
34
+ maxOutputTokens: number;
35
+ };
36
+
37
+ type ProviderDefaults = {
38
+ apiKeyEnv: string;
39
+ baseUrl: string | undefined;
40
+ model: string | undefined;
41
+ };
42
+
43
+ export const providerDefaults = (id: ProviderId): ProviderDefaults => {
44
+ switch (id) {
45
+ case "gemini":
46
+ return {
47
+ apiKeyEnv: "GEMINI_API_KEY",
48
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
49
+ model: "gemini-3.7-flash",
50
+ };
51
+ case "openai":
52
+ return {
53
+ apiKeyEnv: "OPENAI_API_KEY",
54
+ baseUrl: "https://api.openai.com/v1",
55
+ model: undefined,
56
+ };
57
+ case "openai-compatible":
58
+ return {
59
+ apiKeyEnv: "OPENAI_API_KEY",
60
+ baseUrl: undefined,
61
+ model: undefined,
62
+ };
63
+ default:
64
+ return assertNever(id, "Unhandled provider");
65
+ }
66
+ };
67
+
68
+ export type ProviderOptions = {
69
+ id: ProviderId;
70
+ model: string | undefined;
71
+ baseUrl: string | undefined;
72
+ apiKeyEnv: string | undefined;
73
+ };
74
+
75
+ /**
76
+ * The key is read from the environment and never accepted as an argument: a
77
+ * flag would land in shell history and in a CI log line.
78
+ */
79
+ export const resolveProvider = (
80
+ options: ProviderOptions,
81
+ env: Record<string, string | undefined>,
82
+ ): Provider => {
83
+ const defaults = providerDefaults(options.id);
84
+ const apiKeyEnv = options.apiKeyEnv ?? defaults.apiKeyEnv;
85
+ const apiKey = env[apiKeyEnv];
86
+
87
+ if (apiKey === undefined || apiKey === "")
88
+ throw new PrLensCliError(
89
+ "MISSING_API_KEY",
90
+ `${apiKeyEnv} is not set`,
91
+ `export ${apiKeyEnv} with your own key, or point --api-key-env at the variable that holds it`,
92
+ );
93
+
94
+ const model = options.model ?? defaults.model;
95
+ if (model === undefined)
96
+ throw new PrLensCliError(
97
+ "USAGE",
98
+ `--model is required for the ${options.id} provider`,
99
+ "the endpoint decides which model names exist, so there is no default worth guessing",
100
+ );
101
+
102
+ const baseUrl = options.baseUrl ?? defaults.baseUrl;
103
+ if (baseUrl === undefined)
104
+ throw new PrLensCliError(
105
+ "USAGE",
106
+ "--base-url is required for an openai-compatible provider",
107
+ "it is the endpoint that is compatible: point it at DeepSeek, OpenRouter, Ollama or your own server. For OpenAI itself, use --provider openai",
108
+ );
109
+
110
+ return { id: options.id, model, apiKey, baseUrl: baseUrl.replace(/\/+$/, "") };
111
+ };
112
+
113
+ export const completeJson = (provider: Provider, request: JsonCompletion): Promise<string> => {
114
+ switch (provider.id) {
115
+ case "gemini":
116
+ return geminiCompleteJson(provider, request);
117
+ case "openai":
118
+ return openAiCompleteJson(provider, request, "max_completion_tokens");
119
+ case "openai-compatible":
120
+ return openAiCompleteJson(provider, request, "max_tokens");
121
+ default:
122
+ return assertNever(provider.id, "Unhandled provider");
123
+ }
124
+ };
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ import { cutOff, postJson, unreadableResponse } from "./http.js";
3
+ import type { JsonCompletion, Provider } from "./index.js";
4
+
5
+ const Response = z.object({
6
+ choices: z
7
+ .array(
8
+ z.object({
9
+ message: z.object({ content: z.string().nullable().optional() }).optional(),
10
+ finish_reason: z.string().nullable().optional(),
11
+ }),
12
+ )
13
+ .optional(),
14
+ });
15
+
16
+ /**
17
+ * What an output limit is called on this endpoint.
18
+ *
19
+ * OpenAI renamed the field to `max_completion_tokens` and its newer models
20
+ * reject the old one; the servers that copied the API before that rename —
21
+ * DeepSeek, Ollama, llama.cpp and the rest — only know `max_tokens`. There is
22
+ * no spelling both accept, so the caller says which endpoint it is talking to
23
+ * rather than the request guessing from a hostname.
24
+ */
25
+ export type TokenLimitField = "max_tokens" | "max_completion_tokens";
26
+
27
+ /**
28
+ * The `/chat/completions` shape. The system prompt is the first message rather
29
+ * than a separate field.
30
+ */
31
+ export const openAiCompleteJson = async (
32
+ provider: Provider,
33
+ request: JsonCompletion,
34
+ tokenLimitField: TokenLimitField,
35
+ ): Promise<string> => {
36
+ const body = await postJson(
37
+ `${provider.baseUrl}/chat/completions`,
38
+ { authorization: `Bearer ${provider.apiKey}` },
39
+ {
40
+ model: provider.model,
41
+ temperature: 0,
42
+ [tokenLimitField]: request.maxOutputTokens,
43
+ response_format: { type: "json_object" },
44
+ messages: [
45
+ { role: "system", content: request.system },
46
+ ...request.turns.map((turn) => ({
47
+ role: turn.role === "model" ? "assistant" : "user",
48
+ content: turn.text,
49
+ })),
50
+ ],
51
+ },
52
+ );
53
+
54
+ const parsed = Response.safeParse(body);
55
+ if (!parsed.success)
56
+ throw unreadableResponse("the provider", "the response did not have the shape /chat/completions documents");
57
+
58
+ const choice = parsed.data.choices?.[0];
59
+ if (choice === undefined) throw unreadableResponse("the provider", "the response carried no choice");
60
+
61
+ if (choice.finish_reason === "length") throw cutOff("the provider");
62
+
63
+ const text = choice.message?.content ?? "";
64
+ if (text.trim() === "")
65
+ throw unreadableResponse("the provider", `finish reason: ${choice.finish_reason ?? "unknown"}`);
66
+
67
+ return text;
68
+ };
@@ -0,0 +1,102 @@
1
+ import {
2
+ applyPatch,
3
+ formatIssues,
4
+ graphSnapshotIssues,
5
+ PrLensSchemaError,
6
+ safeParseGraphDoc,
7
+ type Flow,
8
+ type GraphDoc,
9
+ type Parsed,
10
+ type PatchOp,
11
+ } from "@coldtea/pr-lens-schema";
12
+
13
+ export type StoredMapOptions = {
14
+ id: string;
15
+ sha: string;
16
+ generatedAt: string;
17
+ };
18
+
19
+ const withoutRemovedMessages = (flow: Flow): Flow["messages"] =>
20
+ flow.messages.filter((message) => message.delta !== "removed");
21
+
22
+ /**
23
+ * A pull-request document says what a change did; a stored map says what the
24
+ * system is once it has been done. So elements the change deletes go, and the
25
+ * rest stops being annotated.
26
+ *
27
+ * The deletions run through `applyPatch` rather than a filter, because
28
+ * removing a node also strands its edges, its flow steps, the drill-down views
29
+ * that pointed at it and the layout hints that named it — the contract already
30
+ * owns that cascade and there is no second version of it here.
31
+ */
32
+ const deletionOps = (graph: GraphDoc): PatchOp[] => {
33
+ const flowOps = graph.flows.flatMap<PatchOp>((flow) => {
34
+ const messages = withoutRemovedMessages(flow);
35
+ if (flow.delta === "removed" || messages.length === 0) return [{ op: "remove_flow", id: flow.id }];
36
+ if (messages.length === flow.messages.length) return [];
37
+ return [{ op: "update_flow", id: flow.id, patch: { messages } }];
38
+ });
39
+
40
+ return [
41
+ ...flowOps,
42
+ ...graph.edges
43
+ .filter((edge) => edge.delta === "removed")
44
+ .map<PatchOp>((edge) => ({ op: "remove_edge", id: edge.id })),
45
+ ...graph.nodes
46
+ .filter((node) => node.delta === "removed")
47
+ .map<PatchOp>((node) => ({ op: "remove_node", id: node.id })),
48
+ ...graph.lanes
49
+ .filter((lane) => lane.delta === "removed")
50
+ .map<PatchOp>((lane) => ({ op: "remove_lane", id: lane.id })),
51
+ ];
52
+ };
53
+
54
+ /**
55
+ * The map a repository commits: the merged state of the system, identified,
56
+ * stamped with the one commit it reflects, and carrying no annotations.
57
+ *
58
+ * Change statistics are dropped rather than carried over. A line count
59
+ * describes a diff, and this document no longer describes one.
60
+ */
61
+ export const toStoredMap = (graph: GraphDoc, options: StoredMapOptions): Parsed<GraphDoc> => {
62
+ const applied = applyPatch(graph, deletionOps(graph));
63
+ if (!applied.ok) return applied;
64
+
65
+ const merged = applied.value;
66
+ const head = { ...merged.provenance.head, sha: options.sha };
67
+
68
+ const candidate = {
69
+ ...merged,
70
+ id: options.id,
71
+ generatedAt: options.generatedAt,
72
+ provenance: { ...merged.provenance, base: head, head },
73
+ stats: undefined,
74
+ lanes: merged.lanes.map(({ delta: _delta, ...lane }) => lane),
75
+ nodes: merged.nodes.map((node) => ({ ...node, delta: "unchanged" as const })),
76
+ edges: merged.edges.map((edge) => ({ ...edge, delta: "unchanged" as const })),
77
+ flows: merged.flows.map((flow) => ({
78
+ ...flow,
79
+ delta: "unchanged" as const,
80
+ messages: withoutRemovedMessages(flow).map((message) => ({
81
+ ...message,
82
+ delta: "unchanged" as const,
83
+ })),
84
+ })),
85
+ };
86
+
87
+ const parsed = safeParseGraphDoc(candidate);
88
+ if (!parsed.ok) return parsed;
89
+
90
+ const issues = graphSnapshotIssues(parsed.value);
91
+ if (issues.length > 0)
92
+ return {
93
+ ok: false,
94
+ error: new PrLensSchemaError(
95
+ "NOT_A_SNAPSHOT",
96
+ `the exported document is not a stored map:\n${formatIssues(issues)}`,
97
+ issues,
98
+ ),
99
+ };
100
+
101
+ return parsed;
102
+ };