@reviewflow/sdk 0.0.0-stage → 6.21.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 (50) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +86 -2
  3. package/build/files-node.mjs +70 -0
  4. package/build/git-node.mjs +1792 -0
  5. package/build/index-browser.es.js +3338 -0
  6. package/build/index-node.mjs +3338 -0
  7. package/package.json +136 -4
  8. package/src/api/pullRequest.ts +617 -0
  9. package/src/config/insertCheckoutEntry.ts +54 -0
  10. package/src/diff/diffPaths.ts +78 -0
  11. package/src/diff/impacts.ts +47 -0
  12. package/src/diff/readingOrder.ts +428 -0
  13. package/src/diff/themes.ts +105 -0
  14. package/src/diff/types.ts +141 -0
  15. package/src/files/editableFile.ts +163 -0
  16. package/src/files.ts +13 -0
  17. package/src/git/groupDiffPaths.ts +134 -0
  18. package/src/git/manifests.ts +96 -0
  19. package/src/git/moduleBindings.ts +571 -0
  20. package/src/git/pullRequestDiff.ts +159 -0
  21. package/src/git/readBranchDiff.ts +359 -0
  22. package/src/git/readImpacts.ts +797 -0
  23. package/src/git/readingOrder.ts +190 -0
  24. package/src/git/run.ts +213 -0
  25. package/src/git.ts +38 -0
  26. package/src/github/applySuggestions.ts +399 -0
  27. package/src/github/editPullRequest.ts +790 -0
  28. package/src/github/fetchCompareDiff.ts +21 -0
  29. package/src/github/imageProxies.ts +43 -0
  30. package/src/github/lineDiff.ts +145 -0
  31. package/src/github/prQuery.ts +298 -0
  32. package/src/github/pullRequestDetail.ts +1347 -0
  33. package/src/github/pullRequestHead.ts +65 -0
  34. package/src/github/pullRequestRevisions.ts +124 -0
  35. package/src/github/transport.ts +171 -0
  36. package/src/index.ts +35 -0
  37. package/src/reviewflow/board.ts +333 -0
  38. package/src/reviewflow/changedParts.ts +80 -0
  39. package/src/reviewflow/checksFromReviewflow.ts +75 -0
  40. package/src/reviewflow/client.ts +589 -0
  41. package/src/reviewflow/follow.ts +345 -0
  42. package/src/reviewflow/status.ts +17 -0
  43. package/src/reviewflow/stream.ts +199 -0
  44. package/src/types/entities.ts +8 -0
  45. package/src/types/ids.ts +1 -0
  46. package/src/types/reviewflow.ts +32 -0
  47. package/src/utils/pullRequestEdit.ts +108 -0
  48. package/src/utils/pullRequestPart.ts +12 -0
  49. package/src/utils/pullRequestReaction.ts +19 -0
  50. package/src/utils/pullRequestSuggestion.ts +148 -0
@@ -0,0 +1,190 @@
1
+ import { parseSync } from "oxc-parser";
2
+ import { isTypescriptPath, orderForReading } from "../diff/readingOrder.ts";
3
+ import type { DiffReadingOrderResponse } from "../diff/types.ts";
4
+ import {
5
+ groupDiffPaths,
6
+ listTree,
7
+ packageOf,
8
+ packagesInListing,
9
+ } from "./groupDiffPaths.ts";
10
+ import type { IsPackage } from "./groupDiffPaths.ts";
11
+ import {
12
+ dependencyNamesOf,
13
+ readManifests,
14
+ resolveLocalSpecifier,
15
+ } from "./manifests.ts";
16
+ import type { Manifest } from "./manifests.ts";
17
+ import { ensureCommits, isSha } from "./pullRequestDiff.ts";
18
+ import { readNumstat } from "./readBranchDiff.ts";
19
+ import { catFiles, git } from "./run.ts";
20
+
21
+ /**
22
+ * How many unchanged files an import is followed through to reach a changed
23
+ * one: a barrel `index.ts` is one, a barrel re-exporting a barrel two.
24
+ */
25
+ const maxUnchangedHops = 2;
26
+
27
+ const stringLiteral = /^(["'`])(.*)\1$/s;
28
+
29
+ /**
30
+ * The modules a TypeScript file imports or re-exports, as written, in source
31
+ * order: `import type` included, a dynamic `import()` only when its argument
32
+ * is a plain string. A syntax error yields whatever was parsed before it.
33
+ */
34
+ export const importSpecifiers = (path: string, source: string): string[] => {
35
+ const { staticImports, staticExports, dynamicImports } = parseSync(
36
+ path,
37
+ source,
38
+ ).module;
39
+ const found: { start: number; specifier: string }[] = [
40
+ ...staticImports.map(({ start, moduleRequest }) => ({
41
+ start,
42
+ specifier: moduleRequest.value,
43
+ })),
44
+ ...staticExports.flatMap(({ start, entries }) =>
45
+ entries.flatMap(({ moduleRequest }) =>
46
+ moduleRequest === null
47
+ ? []
48
+ : [{ start, specifier: moduleRequest.value }],
49
+ ),
50
+ ),
51
+ ...dynamicImports.flatMap(({ start, moduleRequest }) => {
52
+ const literal = stringLiteral.exec(
53
+ source.slice(moduleRequest.start, moduleRequest.end),
54
+ );
55
+ const specifier = literal?.[2];
56
+ return specifier === undefined || specifier.includes("${")
57
+ ? []
58
+ : [{ start, specifier }];
59
+ }),
60
+ ];
61
+ return [
62
+ ...new Set(
63
+ found
64
+ .toSorted((left, right) => left.start - right.start)
65
+ .map(({ specifier }) => specifier),
66
+ ),
67
+ ];
68
+ };
69
+
70
+ interface ReadImportsParams {
71
+ checkout: string;
72
+ head: string;
73
+ changed: readonly string[];
74
+ exists: (path: string) => boolean;
75
+ isPackage: IsPackage;
76
+ manifests: ReadonlyMap<string, Manifest>;
77
+ }
78
+
79
+ /**
80
+ * The resolved imports within the package of the changed TypeScript files,
81
+ * and of the unchanged files of the same package they reach within
82
+ * `maxUnchangedHops`.
83
+ */
84
+ const readImports = async ({
85
+ checkout,
86
+ head,
87
+ changed,
88
+ exists,
89
+ isPackage,
90
+ manifests,
91
+ }: ReadImportsParams): Promise<Map<string, string[]>> => {
92
+ const changedSet = new Set(changed);
93
+ const imports = new Map<string, string[]>();
94
+ let toRead = changed.filter((path) => isTypescriptPath(path) && exists(path));
95
+ for (let hop = 0; hop <= maxUnchangedHops && toRead.length > 0; hop++) {
96
+ const contents = await catFiles(checkout, head, toRead);
97
+ const next = new Set<string>();
98
+ for (const path of toRead) {
99
+ const source = contents.get(path);
100
+ const resolved = (
101
+ source === undefined ? [] : importSpecifiers(path, source)
102
+ )
103
+ .map((specifier) =>
104
+ resolveLocalSpecifier({ from: path, specifier, exists, manifests }),
105
+ )
106
+ .filter((target) => target !== null);
107
+ imports.set(path, resolved);
108
+ const ownPackage = packageOf(path, isPackage);
109
+ for (const target of resolved) {
110
+ if (changedSet.has(target) || imports.has(target)) continue;
111
+ if (packageOf(target, isPackage) === ownPackage) next.add(target);
112
+ }
113
+ }
114
+ toRead = [...next];
115
+ }
116
+ return imports;
117
+ };
118
+
119
+ /**
120
+ * Which package directory depends on which: a dependency of any kind naming
121
+ * another package of the repository.
122
+ */
123
+ const packageDependencies = (
124
+ manifests: ReadonlyMap<string, Manifest>,
125
+ ): Map<string, Set<string>> => {
126
+ const directoryOfName = new Map<string, string>();
127
+ for (const [directory, { name }] of manifests) {
128
+ if (typeof name === "string") directoryOfName.set(name, directory);
129
+ }
130
+ return new Map(
131
+ [...manifests].map(([directory, manifest]) => [
132
+ directory,
133
+ new Set(
134
+ dependencyNamesOf(manifest).flatMap((name) => {
135
+ const dependency = directoryOfName.get(name);
136
+ return dependency === undefined || dependency === directory
137
+ ? []
138
+ : [dependency];
139
+ }),
140
+ ),
141
+ ]),
142
+ );
143
+ };
144
+
145
+ export interface ReadingOrderRequest {
146
+ /** the registered checkout, resolved by the caller */
147
+ checkout: string;
148
+ head: string;
149
+ base: string;
150
+ }
151
+
152
+ /**
153
+ * The files of the range `/api/prs/diff` reads (head against its merge base
154
+ * with base), with whitespace shown, ordered for reading. The commits are
155
+ * fetched by sha when the checkout lacks them, as the diff does.
156
+ */
157
+ export const readReadingOrder = async ({
158
+ checkout,
159
+ head,
160
+ base,
161
+ }: ReadingOrderRequest): Promise<DiffReadingOrderResponse> => {
162
+ if (!isSha(head) || !isSha(base)) {
163
+ throw new Error("head and base must be commit shas");
164
+ }
165
+ await ensureCommits(checkout, [head, base], undefined);
166
+ const mergeBaseOutput = await git(checkout, ["merge-base", base, head]);
167
+ const mergeBase = mergeBaseOutput.trim();
168
+ const numstat = await readNumstat(checkout, [mergeBase, head], []);
169
+ const changed = numstat.map(({ path }) => path);
170
+ const tree = await listTree(checkout, head);
171
+ const inTree = new Set(tree);
172
+ const exists = (path: string): boolean => inTree.has(path);
173
+ const isPackage = packagesInListing(tree);
174
+ const manifests = await readManifests(checkout, head, tree);
175
+ const imports = await readImports({
176
+ checkout,
177
+ head,
178
+ changed,
179
+ exists,
180
+ isPackage,
181
+ manifests,
182
+ });
183
+ return {
184
+ groups: orderForReading({
185
+ groups: groupDiffPaths(isPackage, changed),
186
+ imports,
187
+ dependsOn: packageDependencies(manifests),
188
+ }),
189
+ };
190
+ };
package/src/git/run.ts ADDED
@@ -0,0 +1,213 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { env } from "node:process";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ const timeoutMs = 10_000;
8
+ const networkTimeoutMs = 60_000;
9
+ const defaultMaxBuffer = 4 * 1024 * 1024;
10
+ const batchMaxBuffer = 256 * 1024 * 1024;
11
+
12
+ export interface GitOptions {
13
+ /** a fetch, pull or push: talks to a remote */
14
+ network?: boolean;
15
+ /** bytes of stdout past which git is killed; 4MB unless a caller has its own budget */
16
+ maxBuffer?: number;
17
+ /**
18
+ * Exit status 1 is an answer rather than a failure, as for
19
+ * `diff --no-index`, which exits 1 when the files differ.
20
+ */
21
+ exitOneIsAnswer?: boolean;
22
+ /** kills git when the caller no longer wants the answer */
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ /**
27
+ * With no terminal (a server, a background job) to answer a credential or passphrase
28
+ * prompt, so one would hang until the timeout and then report a kill rather
29
+ * than the reason. Refusing to prompt makes git fail at once with the reason on
30
+ * stderr, which is what the user reads. A user's own
31
+ * `GIT_SSH_COMMAND` is kept: it is how a custom agent or key is configured.
32
+ */
33
+ const networkEnv = (): NodeJS.ProcessEnv => ({
34
+ ...env,
35
+ GIT_TERMINAL_PROMPT: "0",
36
+ GIT_SSH_COMMAND: env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
37
+ });
38
+
39
+ export const gitFailedCode = "ERR_GIT_FAILED";
40
+
41
+ /**
42
+ * git's reason, which is not always its first line: a push opens with
43
+ * `To <remote>` and states the rejection on a ` ! [rejected]` line below, and a
44
+ * refused fast-forward prints its `hint:` block before the `fatal:`.
45
+ */
46
+ export const reasonLine = (stderr: string): string => {
47
+ const lines = stderr
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((line) => line !== "");
51
+ return (
52
+ lines.find((line) => line.startsWith("! ")) ??
53
+ lines.find((line) => /^(?:fatal|error):/.test(line)) ??
54
+ lines.find((line) => !line.startsWith("hint:")) ??
55
+ ""
56
+ );
57
+ };
58
+
59
+ /**
60
+ * A git killed by the timeout has written nothing to stderr, so without this
61
+ * the message names the command and no reason at all.
62
+ */
63
+ const failureReason = (error: unknown, timeout: number): string => {
64
+ if (typeof error !== "object" || error === null) return "";
65
+ if ("killed" in error && error.killed === true) {
66
+ return `killed after ${String(timeout / 1000)}s without finishing`;
67
+ }
68
+ return "stderr" in error ? reasonLine(String(error.stderr)) : "";
69
+ };
70
+
71
+ /** git exits 1 on some errors too, which print nothing to stdout */
72
+ const isExitOneAnswer = (error: unknown): error is { stdout: string } =>
73
+ typeof error === "object" &&
74
+ error !== null &&
75
+ "code" in error &&
76
+ error.code === 1 &&
77
+ "stdout" in error &&
78
+ typeof error.stdout === "string" &&
79
+ error.stdout !== "";
80
+
81
+ /**
82
+ * git reads are on-demand: nothing here caches, and every call names the repository it ran in so a failure is actionable.
83
+ *
84
+ * `execFile` with an argument array and no shell, which is what makes a branch
85
+ * name safe to pass through: nothing here is ever interpolated into a command
86
+ * line. *
87
+ * The message keeps only one stderr line on purpose (`reasonLine`): it is
88
+ * rendered to the user verbatim, and the rest is
89
+ * hints.
90
+ */
91
+ export const git = async (
92
+ cwd: string,
93
+ args: string[],
94
+ options: GitOptions = {},
95
+ ): Promise<string> => {
96
+ const timeout = options.network ? networkTimeoutMs : timeoutMs;
97
+ try {
98
+ const { stdout } = await execFileAsync("git", args, {
99
+ cwd,
100
+ timeout,
101
+ maxBuffer: options.maxBuffer ?? defaultMaxBuffer,
102
+ env: options.network ? networkEnv() : env,
103
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
104
+ });
105
+ return stdout;
106
+ } catch (error) {
107
+ if (options.exitOneIsAnswer && isExitOneAnswer(error)) return error.stdout;
108
+ const reason = failureReason(error, timeout);
109
+ throw Object.assign(
110
+ new Error(
111
+ `git ${args.join(" ")} failed in ${cwd}${reason ? `: ${reason}` : ""}`,
112
+ { cause: error },
113
+ ),
114
+ { code: gitFailedCode },
115
+ );
116
+ }
117
+ };
118
+
119
+ /** `<request> missing`: the request echoes the path, which may contain spaces */
120
+ const unansweredHeader = / (?:missing|ambiguous)$/;
121
+
122
+ const parseBatchOutput = (
123
+ output: Buffer,
124
+ paths: readonly string[],
125
+ ): Map<string, string> => {
126
+ const contents = new Map<string, string>();
127
+ let offset = 0;
128
+ for (const path of paths) {
129
+ const newline = output.indexOf("\n", offset);
130
+ if (newline === -1) break;
131
+ const header = output.toString("utf8", offset, newline);
132
+ offset = newline + 1;
133
+ if (unansweredHeader.test(header)) continue;
134
+ const [, type, sizeText] = header.split(" ");
135
+ const size = Number(sizeText);
136
+ // past an unreadable header every offset is wrong: stop rather than misattribute
137
+ if (!Number.isSafeInteger(size) || size < 0) break;
138
+ if (type === "blob") {
139
+ contents.set(path, output.toString("utf8", offset, offset + size));
140
+ }
141
+ offset += size + 1;
142
+ }
143
+ return contents;
144
+ };
145
+
146
+ /**
147
+ * Many files of one commit from a single `git cat-file --batch`, rather than a
148
+ * process per file. A path missing at `sha`, or naming no blob, is absent from
149
+ * the answer. The commit must be in the checkout.
150
+ */
151
+ export const catFiles = async (
152
+ cwd: string,
153
+ sha: string,
154
+ paths: readonly string[],
155
+ ): Promise<Map<string, string>> => {
156
+ // a newline ends a batch request: such a path cannot be asked
157
+ const asked = paths.filter((path) => !path.includes("\n"));
158
+ if (asked.length === 0) return new Map();
159
+ const args = ["cat-file", "--batch"];
160
+ return new Promise((resolve, reject) => {
161
+ const child = spawn("git", args, { cwd, env, timeout: timeoutMs });
162
+ const chunks: Buffer[] = [];
163
+ let size = 0;
164
+ let stderr = "";
165
+ child.stdout.on("data", (chunk: Buffer) => {
166
+ size += chunk.length;
167
+ if (size > batchMaxBuffer) {
168
+ child.kill();
169
+ return;
170
+ }
171
+ chunks.push(chunk);
172
+ });
173
+ child.stderr.on("data", (chunk: Buffer) => {
174
+ stderr += chunk.toString("utf8");
175
+ });
176
+ child.on("error", reject);
177
+ // git exiting before reading every request (not a repo, killed) breaks the
178
+ // pipe; `close` reports why, an unhandled EPIPE would crash the process
179
+ child.stdin.on("error", () => {});
180
+ child.on("close", (code, signal) => {
181
+ if (code === 0) {
182
+ resolve(parseBatchOutput(Buffer.concat(chunks), asked));
183
+ return;
184
+ }
185
+ const reason =
186
+ signal === null
187
+ ? reasonLine(stderr)
188
+ : `killed (${signal}) after reading ${String(size)} bytes`;
189
+ reject(
190
+ Object.assign(
191
+ new Error(
192
+ `git ${args.join(" ")} failed in ${cwd}${reason ? `: ${reason}` : ""}`,
193
+ ),
194
+ { code: gitFailedCode },
195
+ ),
196
+ );
197
+ });
198
+ child.stdin.end(asked.map((path) => `${sha}:${path}\n`).join(""));
199
+ });
200
+ };
201
+
202
+ /** For calls whose failure is an expected answer, e.g. "no remote". */
203
+ export const gitOrNull = async (
204
+ cwd: string,
205
+ args: string[],
206
+ options: GitOptions = {},
207
+ ): Promise<string | null> => {
208
+ try {
209
+ return await git(cwd, args, options);
210
+ } catch {
211
+ return null;
212
+ }
213
+ };
package/src/git.ts ADDED
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Node-only surface: reads a pull request diff from a local git checkout.
3
+ * Import it as `@reviewflow/sdk/git`; the root entry stays browser-safe.
4
+ */
5
+ export { git, gitFailedCode, gitOrNull, reasonLine } from "./git/run.ts";
6
+ export type { GitOptions } from "./git/run.ts";
7
+ export {
8
+ defaultDiffLimits,
9
+ parseNumstat,
10
+ readBranchDiff,
11
+ } from "./git/readBranchDiff.ts";
12
+ export type {
13
+ BranchDiff,
14
+ BranchDiffScope,
15
+ DiffLimits,
16
+ NumstatEntry,
17
+ } from "./git/readBranchDiff.ts";
18
+ export {
19
+ groupDiffPaths,
20
+ noPackages,
21
+ packagesAtTree,
22
+ packagesOnDisk,
23
+ } from "./git/groupDiffPaths.ts";
24
+ export type { IsPackage } from "./git/groupDiffPaths.ts";
25
+ export { readFileAtSha, readPullRequestDiff } from "./git/pullRequestDiff.ts";
26
+ export type { PullRequestDiffRequest } from "./git/pullRequestDiff.ts";
27
+ export { readReadingOrder } from "./git/readingOrder.ts";
28
+ export type { ReadingOrderRequest } from "./git/readingOrder.ts";
29
+ export {
30
+ createBindingsCache,
31
+ maxTypescriptFiles,
32
+ readImpacts,
33
+ } from "./git/readImpacts.ts";
34
+ export type {
35
+ BindingsCache,
36
+ ImpactsRequest,
37
+ ModuleSummary,
38
+ } from "./git/readImpacts.ts";