@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.
- package/LICENSE +15 -0
- package/README.md +86 -2
- package/build/files-node.mjs +70 -0
- package/build/git-node.mjs +1792 -0
- package/build/index-browser.es.js +3338 -0
- package/build/index-node.mjs +3338 -0
- package/package.json +136 -4
- package/src/api/pullRequest.ts +617 -0
- package/src/config/insertCheckoutEntry.ts +54 -0
- package/src/diff/diffPaths.ts +78 -0
- package/src/diff/impacts.ts +47 -0
- package/src/diff/readingOrder.ts +428 -0
- package/src/diff/themes.ts +105 -0
- package/src/diff/types.ts +141 -0
- package/src/files/editableFile.ts +163 -0
- package/src/files.ts +13 -0
- package/src/git/groupDiffPaths.ts +134 -0
- package/src/git/manifests.ts +96 -0
- package/src/git/moduleBindings.ts +571 -0
- package/src/git/pullRequestDiff.ts +159 -0
- package/src/git/readBranchDiff.ts +359 -0
- package/src/git/readImpacts.ts +797 -0
- package/src/git/readingOrder.ts +190 -0
- package/src/git/run.ts +213 -0
- package/src/git.ts +38 -0
- package/src/github/applySuggestions.ts +399 -0
- package/src/github/editPullRequest.ts +790 -0
- package/src/github/fetchCompareDiff.ts +21 -0
- package/src/github/imageProxies.ts +43 -0
- package/src/github/lineDiff.ts +145 -0
- package/src/github/prQuery.ts +298 -0
- package/src/github/pullRequestDetail.ts +1347 -0
- package/src/github/pullRequestHead.ts +65 -0
- package/src/github/pullRequestRevisions.ts +124 -0
- package/src/github/transport.ts +171 -0
- package/src/index.ts +35 -0
- package/src/reviewflow/board.ts +333 -0
- package/src/reviewflow/changedParts.ts +80 -0
- package/src/reviewflow/checksFromReviewflow.ts +75 -0
- package/src/reviewflow/client.ts +589 -0
- package/src/reviewflow/follow.ts +345 -0
- package/src/reviewflow/status.ts +17 -0
- package/src/reviewflow/stream.ts +199 -0
- package/src/types/entities.ts +8 -0
- package/src/types/ids.ts +1 -0
- package/src/types/reviewflow.ts +32 -0
- package/src/utils/pullRequestEdit.ts +108 -0
- package/src/utils/pullRequestPart.ts +12 -0
- package/src/utils/pullRequestReaction.ts +19 -0
- 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
|
+
};
|