@reviewflow/sdk 0.0.0-stage → 6.22.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 +3328 -0
  6. package/build/index-node.mjs +3328 -0
  7. package/package.json +135 -3
  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,141 @@
1
+ /**
2
+ * `show` is git's plain diff. `hide` is `git diff -w`, GitHub's "Hide
3
+ * whitespace": a line whose only change is whitespace is not a change.
4
+ */
5
+ export type DiffWhitespace = "hide" | "show";
6
+
7
+ export const isDiffWhitespace = (value: unknown): value is DiffWhitespace =>
8
+ value === "hide" || value === "show";
9
+
10
+ /**
11
+ * Where a diff read is at, for a caller that shows progress: the objects are
12
+ * being fetched into the checkout, or the patch is being computed.
13
+ */
14
+ export type DiffStep = "diffing" | "fetching";
15
+
16
+ /**
17
+ * `whitespace`: every change of the file is whitespace, and whitespace is
18
+ * hidden. Listed rather than dropped, since `git diff -w` names no such file
19
+ * and the reader would not know it changed.
20
+ */
21
+ export type DiffOmittedReason = "binary" | "budget" | "tooLarge" | "whitespace";
22
+
23
+ export interface DiffOmittedFile {
24
+ path: string;
25
+ reason: DiffOmittedReason;
26
+ }
27
+
28
+ /**
29
+ * The files of one package: the nearest directory above them holding a
30
+ * manifest (`package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`,
31
+ * `Gemfile`…), the repo root excepted. A file in no package goes under
32
+ * its first directory, and one at the root under `""`.
33
+ */
34
+ export interface DiffGroup {
35
+ /** relative to the checkout; `""` is the root */
36
+ name: string;
37
+ /** as the patch names them, after any rename */
38
+ paths: string[];
39
+ }
40
+
41
+ export interface DiffReadingFile {
42
+ /** as the patch names it, after any rename */
43
+ path: string;
44
+ /** no other changed file of its package imports it, and it imports some */
45
+ entry: boolean;
46
+ /**
47
+ * a non-test TypeScript file no other changed file of its package imports:
48
+ * the entry points, and the files that import nothing changed either
49
+ */
50
+ root: boolean;
51
+ /** the tests and stories of this file, read below it */
52
+ attached: string[];
53
+ }
54
+
55
+ /**
56
+ * One package's changed files in reading order: from each entry point down
57
+ * through what it imports, depth first; then the files that are not
58
+ * TypeScript, in patch order; then the tests and stories covering no changed
59
+ * file.
60
+ */
61
+ export interface DiffReadingGroup {
62
+ /** the package, as `DiffGroup.name` */
63
+ name: string;
64
+ /** every changed file of the package once, an attached one under its subject only */
65
+ files: DiffReadingFile[];
66
+ }
67
+
68
+ /**
69
+ * `GET /api/prs/reading-order?repo=<owner/repo>&number=<n>&head=<sha>&base=<sha>`
70
+ * — the files of the same range as `/api/prs/diff`, ordered for reading:
71
+ * packages from the one depending on the others down, files from the entry
72
+ * points down. Read from the registered checkout at `head` (relative
73
+ * TypeScript imports only); answered with a `DiffRefusal` without one.
74
+ */
75
+ export interface DiffReadingOrderResponse {
76
+ groups: DiffReadingGroup[];
77
+ }
78
+
79
+ /**
80
+ * `GET /api/prs/diff?repo=<owner/repo>&number=<n>&head=<sha>&base=<sha>&whitespace=<hide|show>`
81
+ * — the pull request's changes: head against its merge base with the base tip,
82
+ * which is what GitHub shows. The two shas are the detail's, so the diff is of
83
+ * the pull request the screen shows.
84
+ *
85
+ * Read from **local git** when the repository is registered — the commits are
86
+ * fetched by sha into its checkout when missing, writing no ref — with size
87
+ * bounds (binary, too large and over-budget files are left out). From **GitHub's API** otherwise, or when git cannot: a
88
+ * repository with no configured checkout, a fetch refused. GitHub refuses a diff past
89
+ * 300 files or 20,000 lines, which git does not.
90
+ */
91
+ export interface PullRequestDiffResponse {
92
+ source: "git" | "github";
93
+ /** why git was not the source, verbatim; null when it was, or no checkout exists */
94
+ fallbackReason: string | null;
95
+ /**
96
+ * What the patch did with whitespace, which is not always what was asked:
97
+ * GitHub's compare endpoint cannot hide it, so its answer is always `show`.
98
+ */
99
+ whitespace: DiffWhitespace;
100
+ /** a unified diff, `a/` and `b/` prefixes, the omitted files left out */
101
+ patch: string;
102
+ /**
103
+ * The commits the patch's two sides are read at (the merge base and the
104
+ * head), for `prFile`; null from GitHub, whose files are not read, so the
105
+ * unchanged lines stay folded.
106
+ */
107
+ sides: { old: string; new: string } | null;
108
+ /** binary, too large or over budget: left out of `patch` (git only) */
109
+ omitted: DiffOmittedFile[];
110
+ /**
111
+ * Every file in `patch`, by package when the repository is registered (its
112
+ * checkout says where the manifests are), by first directory otherwise.
113
+ */
114
+ groups: DiffGroup[];
115
+ }
116
+
117
+ /**
118
+ * `GET /api/prs/file?repo=<owner/repo>&number=<n>&sha=<sha>&path=<path>` — one
119
+ * file of the registered checkout at a commit, so the diff can unfold the lines
120
+ * the patch left out. Local git only; a 400 without a checkout.
121
+ */
122
+ export interface PullRequestFileContentsResponse {
123
+ contents: string;
124
+ }
125
+
126
+ /**
127
+ * Why a diff was not read at all: the repository has no checkout the reader
128
+ * may use. Not an error: the user is shown `configPath` and the exact line to
129
+ * add to it.
130
+ *
131
+ * - `notConfigured`: `ownerRepo` is not in the config file.
132
+ * - `invalidPath`: it is, but the path is not a checkout of that repository
133
+ * (`detail` says what was found).
134
+ */
135
+ export interface DiffRefusal {
136
+ reason: "invalidPath" | "notConfigured";
137
+ ownerRepo: string;
138
+ /** the file to edit */
139
+ configPath: string;
140
+ detail: string;
141
+ }
@@ -0,0 +1,163 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ renameSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+
14
+ export interface EditableFile {
15
+ /** the text on disk; empty when the file does not exist */
16
+ source: string;
17
+ exists: boolean;
18
+ }
19
+
20
+ /**
21
+ * What a caller's `validate` answers: `{ error }` refuses the save, `{ warnings }`
22
+ * lets it through and travels back with the saved file.
23
+ */
24
+ export type FileValidation = { error: string } | { warnings: string[] };
25
+
26
+ export type ValidateEditableFile = (
27
+ source: string,
28
+ ) => FileValidation | Promise<FileValidation>;
29
+
30
+ export interface SaveEditableFileParams {
31
+ path: string;
32
+ source: string;
33
+ /**
34
+ * What the editor opened on. `undefined` skips the check. A string is
35
+ * refused when the file has other text now, a missing file counting as the
36
+ * empty text so an editor opened on a file that did not exist can save it.
37
+ * `null` means the file must not exist yet (a creation).
38
+ */
39
+ expectedSource?: string | null | undefined;
40
+ validate: ValidateEditableFile;
41
+ /**
42
+ * End a non-empty source with a newline before it is validated and written.
43
+ * Default true: a text file ends with one, and the editor then reopens on
44
+ * the text that was saved.
45
+ */
46
+ trailingNewline?: boolean;
47
+ }
48
+
49
+ export type SaveEditableFileResult =
50
+ /** nothing was written; `source` is what the disk holds now, for the UI to offer a reload */
51
+ | { status: "conflict"; source: string; exists: boolean }
52
+ /** nothing was written */
53
+ | { status: "invalid"; error: string }
54
+ /** `source` is what is on disk now, which may differ from the input by its final newline */
55
+ | { status: "saved"; source: string; warnings: string[] };
56
+
57
+ const isMissing = (error: unknown): boolean =>
58
+ (error as NodeJS.ErrnoException).code === "ENOENT";
59
+
60
+ const readCurrent = (path: string): string | null => {
61
+ try {
62
+ return readFileSync(path, "utf8");
63
+ } catch (error) {
64
+ if (isMissing(error)) return null;
65
+ throw error;
66
+ }
67
+ };
68
+
69
+ /**
70
+ * The file as it is on disk right now. A missing file is a state, not an
71
+ * error: `{ source: "", exists: false }`. Always read from disk, never from a
72
+ * cache: a file edited in place by another tool must not make the next save
73
+ * refuse because an editor opened on a stale copy.
74
+ */
75
+ export const readEditableFile = (path: string): EditableFile => {
76
+ const source = readCurrent(path);
77
+ return source === null
78
+ ? { source: "", exists: false }
79
+ : { source, exists: true };
80
+ };
81
+
82
+ const validationOf = async (
83
+ validate: ValidateEditableFile,
84
+ source: string,
85
+ ): Promise<FileValidation> => {
86
+ try {
87
+ return await validate(source);
88
+ } catch (error) {
89
+ return { error: error instanceof Error ? error.message : String(error) };
90
+ }
91
+ };
92
+
93
+ /**
94
+ * Writes one text file. Every refusal comes before the write:
95
+ *
96
+ * - **A source `validate` refuses is not written**, whatever is on disk: only
97
+ * what is about to be written is checked, so a broken file can be fixed.
98
+ * - **A file that changed since the editor opened it is refused**, with the
99
+ * text now on disk. The same file is edited by hand or by another tool, and
100
+ * the last writer winning would lose the other edit without either side
101
+ * seeing it.
102
+ *
103
+ * Written beside the target and renamed over it, so a crash leaves the previous
104
+ * file rather than a truncated one; missing parent directories are created and
105
+ * the mode of an existing file is kept. A symlink is written through to its
106
+ * target: renaming over the link would replace it with a plain file and
107
+ * quietly detach it from the repository that holds the real one.
108
+ */
109
+ export const saveEditableFile = async ({
110
+ path,
111
+ source,
112
+ expectedSource,
113
+ validate,
114
+ trailingNewline = true,
115
+ }: SaveEditableFileParams): Promise<SaveEditableFileResult> => {
116
+ const text =
117
+ trailingNewline && source !== "" && !source.endsWith("\n")
118
+ ? `${source}\n`
119
+ : source;
120
+
121
+ const validation = await validationOf(validate, text);
122
+ if ("error" in validation) {
123
+ return { status: "invalid", error: validation.error };
124
+ }
125
+
126
+ const current = readCurrent(path);
127
+ if (expectedSource !== undefined) {
128
+ const conflicts =
129
+ expectedSource === null
130
+ ? current !== null
131
+ : (current ?? "") !== expectedSource;
132
+ if (conflicts) {
133
+ return {
134
+ status: "conflict",
135
+ source: current ?? "",
136
+ exists: current !== null,
137
+ };
138
+ }
139
+ }
140
+
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ const target = current === null ? path : realpathSync(path);
143
+ const temporary = join(
144
+ dirname(target),
145
+ `.${basename(target)}.${String(process.pid)}.${randomBytes(4).toString("hex")}.tmp`,
146
+ );
147
+ try {
148
+ writeFileSync(temporary, text);
149
+ // the permission bits of the existing file, without its file-type bits
150
+ // oxlint-disable-next-line no-bitwise
151
+ if (current !== null) chmodSync(temporary, statSync(target).mode & 0o7777);
152
+ renameSync(temporary, target);
153
+ } catch (error) {
154
+ try {
155
+ unlinkSync(temporary);
156
+ } catch {
157
+ // the temporary file was never created
158
+ }
159
+ throw error;
160
+ }
161
+
162
+ return { status: "saved", source: text, warnings: validation.warnings };
163
+ };
package/src/files.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Node-only surface: a text file edited in place, read from disk and saved
3
+ * atomically behind a validation and a changed-since-opened check. Import it
4
+ * as `@reviewflow/sdk/files`; the root entry stays browser-safe.
5
+ */
6
+ export { readEditableFile, saveEditableFile } from "./files/editableFile.ts";
7
+ export type {
8
+ EditableFile,
9
+ FileValidation,
10
+ SaveEditableFileParams,
11
+ SaveEditableFileResult,
12
+ ValidateEditableFile,
13
+ } from "./files/editableFile.ts";
@@ -0,0 +1,134 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { DiffGroup } from "../diff/types.ts";
4
+ import { git } from "./run.ts";
5
+
6
+ /**
7
+ * A monorepo's changes read by package: `apps/resident` and `.claude` rather
8
+ * than one flat list. The package is the nearest directory holding a manifest
9
+ * of any ecosystem — what every workspace tool agrees on — rather than one
10
+ * matched against each tool's workspace globs, which would mean parsing as
11
+ * many formats to answer the same question. The root's own manifest does not
12
+ * count, or every file would be in one group.
13
+ *
14
+ * Fixed names only: a manifest named after its package (`*.gemspec`,
15
+ * `*.csproj`) would need a directory listing per level instead of a stat.
16
+ *
17
+ * The caller says where the manifests are, as an `isPackage(directory)`
18
+ * predicate: `packagesAtTree` reads them from a commit's tree (a pull request's
19
+ * head, whatever the checkout has out), `packagesOnDisk` from a working tree.
20
+ * A file deleted along with its whole package falls back to its first
21
+ * directory: there is no manifest left to find.
22
+ */
23
+
24
+ const manifestNames = [
25
+ // javascript
26
+ "package.json",
27
+ // python
28
+ "pyproject.toml",
29
+ "setup.py",
30
+ // ruby
31
+ "Gemfile",
32
+ // rust
33
+ "Cargo.toml",
34
+ // go
35
+ "go.mod",
36
+ // php
37
+ "composer.json",
38
+ // jvm
39
+ "pom.xml",
40
+ "build.gradle",
41
+ "build.gradle.kts",
42
+ // elixir
43
+ "mix.exs",
44
+ // dart
45
+ "pubspec.yaml",
46
+ // swift
47
+ "Package.swift",
48
+ ];
49
+
50
+ const manifests = new Set(manifestNames);
51
+
52
+ export const isManifestPath = (path: string): boolean =>
53
+ manifests.has(path.slice(path.lastIndexOf("/") + 1));
54
+
55
+ export const packageOf = (
56
+ path: string,
57
+ isPackage: (directory: string) => boolean,
58
+ ): string => {
59
+ const directories = path.split("/").slice(0, -1);
60
+ for (let depth = directories.length; depth >= 1; depth--) {
61
+ const directory = directories.slice(0, depth).join("/");
62
+ if (isPackage(directory)) return directory;
63
+ }
64
+ return directories[0] ?? "";
65
+ };
66
+
67
+ export type IsPackage = (directory: string) => boolean;
68
+
69
+ /** No manifest known: every file goes under its first directory. */
70
+ export const noPackages: IsPackage = () => false;
71
+
72
+ /** Every file at `sha`. The commit must be in the checkout. */
73
+ export const listTree = async (
74
+ checkout: string,
75
+ sha: string,
76
+ ): Promise<string[]> => {
77
+ const listing = await git(
78
+ checkout,
79
+ ["ls-tree", "-r", "--name-only", "-z", sha],
80
+ {
81
+ maxBuffer: 256 * 1024 * 1024,
82
+ },
83
+ );
84
+ return listing.split("\0").filter((path) => path !== "");
85
+ };
86
+
87
+ /** The directories holding a manifest among `paths`. The root does not count. */
88
+ export const packagesInListing = (paths: readonly string[]): IsPackage => {
89
+ const directories = new Set<string>();
90
+ for (const path of paths) {
91
+ const slash = path.lastIndexOf("/");
92
+ if (slash === -1) continue;
93
+ if (isManifestPath(path)) directories.add(path.slice(0, slash));
94
+ }
95
+ return (directory) => directories.has(directory);
96
+ };
97
+
98
+ /** The directories holding a manifest at `sha`, from one `git ls-tree`. */
99
+ export const packagesAtTree = async (
100
+ checkout: string,
101
+ sha: string,
102
+ ): Promise<IsPackage> => packagesInListing(await listTree(checkout, sha));
103
+
104
+ /** For a caller whose files are in a working tree, uncommitted ones included. */
105
+ export const packagesOnDisk = (worktree: string): IsPackage => {
106
+ const known = new Map<string, boolean>();
107
+ return (directory) => {
108
+ const cached = known.get(directory);
109
+ if (cached !== undefined) return cached;
110
+ const found = manifestNames.some((manifest) =>
111
+ existsSync(join(worktree, directory, manifest)),
112
+ );
113
+ known.set(directory, found);
114
+ return found;
115
+ };
116
+ };
117
+
118
+ export const groupDiffPaths = (
119
+ isPackage: IsPackage,
120
+ paths: string[],
121
+ ): DiffGroup[] => {
122
+ const groups = new Map<string, string[]>();
123
+ for (const path of paths) {
124
+ const name = packageOf(path, isPackage);
125
+ const group = groups.get(name);
126
+ if (group === undefined) groups.set(name, [path]);
127
+ else group.push(path);
128
+ }
129
+ // The root first: its files (a lockfile, the root manifest, a README) are
130
+ // what the rest of the change is read against.
131
+ return [...groups]
132
+ .toSorted(([left], [right]) => Number(left !== "") - Number(right !== ""))
133
+ .map(([name, groupPaths]) => ({ name, paths: groupPaths }));
134
+ };
@@ -0,0 +1,96 @@
1
+ import { resolveRelative, resolveSubpathImport } from "../diff/readingOrder.ts";
2
+ import { catFiles } from "./run.ts";
3
+
4
+ export type Manifest = Record<string, unknown>;
5
+
6
+ /** The `package.json` holding `path`, the root's included: whose `imports` apply. */
7
+ export const manifestDirectoryOf = (
8
+ path: string,
9
+ manifests: ReadonlyMap<string, Manifest>,
10
+ ): string | null => {
11
+ const directories = path.split("/").slice(0, -1);
12
+ for (let depth = directories.length; depth >= 0; depth--) {
13
+ const directory = directories.slice(0, depth).join("/");
14
+ if (manifests.has(directory)) return directory;
15
+ }
16
+ return null;
17
+ };
18
+
19
+ export interface ResolveLocalParams {
20
+ /** the file holding the specifier */
21
+ from: string;
22
+ specifier: string;
23
+ exists: (path: string) => boolean;
24
+ manifests: ReadonlyMap<string, Manifest>;
25
+ }
26
+
27
+ /**
28
+ * The file a relative or `#` (`imports` of the nearest `package.json`)
29
+ * specifier names, null when it names none or is neither.
30
+ */
31
+ export const resolveLocalSpecifier = ({
32
+ from,
33
+ specifier,
34
+ exists,
35
+ manifests,
36
+ }: ResolveLocalParams): string | null => {
37
+ if (!specifier.startsWith("#")) {
38
+ return resolveRelative(from, specifier, exists);
39
+ }
40
+ const directory = manifestDirectoryOf(from, manifests);
41
+ if (directory === null) return null;
42
+ return resolveSubpathImport(
43
+ specifier,
44
+ { directory, imports: manifests.get(directory)?.imports },
45
+ exists,
46
+ );
47
+ };
48
+
49
+ const dependencyFields = [
50
+ "dependencies",
51
+ "devDependencies",
52
+ "peerDependencies",
53
+ ] as const;
54
+
55
+ /** The packages a manifest depends on, of any kind. */
56
+ export const dependencyNamesOf = (manifest: Manifest): string[] =>
57
+ dependencyFields.flatMap((field) => {
58
+ const dependencies = manifest[field];
59
+ return typeof dependencies === "object" && dependencies !== null
60
+ ? Object.keys(dependencies)
61
+ : [];
62
+ });
63
+
64
+ const parseManifest = (source: string): Manifest | null => {
65
+ try {
66
+ const parsed: unknown = JSON.parse(source);
67
+ return typeof parsed === "object" && parsed !== null
68
+ ? (parsed as Manifest)
69
+ : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ };
74
+
75
+ const directoryOfManifest = (path: string): string =>
76
+ path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
77
+
78
+ /** Every `package.json` at `head`, by directory; one that does not parse is left out. */
79
+ export const readManifests = async (
80
+ checkout: string,
81
+ head: string,
82
+ tree: readonly string[],
83
+ ): Promise<Map<string, Manifest>> => {
84
+ const manifestPaths = tree.filter(
85
+ (path) =>
86
+ (path === "package.json" || path.endsWith("/package.json")) &&
87
+ !path.split("/").includes("node_modules"),
88
+ );
89
+ const contents = await catFiles(checkout, head, manifestPaths);
90
+ const manifests = new Map<string, Manifest>();
91
+ for (const [path, source] of contents) {
92
+ const manifest = parseManifest(source);
93
+ if (manifest !== null) manifests.set(directoryOfManifest(path), manifest);
94
+ }
95
+ return manifests;
96
+ };