@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.
- 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 +3328 -0
- package/build/index-node.mjs +3328 -0
- package/package.json +135 -3
- 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,65 @@
|
|
|
1
|
+
import type { PullRequestRef } from "../api/pullRequest.ts";
|
|
2
|
+
import type { GitHubTransport } from "./transport.ts";
|
|
3
|
+
|
|
4
|
+
export const pullRequestHeadQuery = `query PullRequestHead($owner: String!, $name: String!, $number: Int!) {
|
|
5
|
+
repository(owner: $owner, name: $name) {
|
|
6
|
+
pullRequest(number: $number) {
|
|
7
|
+
state
|
|
8
|
+
headRefName
|
|
9
|
+
isCrossRepository
|
|
10
|
+
headRepositoryOwner { login }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}`;
|
|
14
|
+
|
|
15
|
+
export interface PullRequestHeadData {
|
|
16
|
+
repository: {
|
|
17
|
+
pullRequest: {
|
|
18
|
+
state: "CLOSED" | "MERGED" | "OPEN";
|
|
19
|
+
headRefName: string;
|
|
20
|
+
isCrossRepository: boolean;
|
|
21
|
+
headRepositoryOwner: { login: string } | null;
|
|
22
|
+
} | null;
|
|
23
|
+
} | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type GhPullRequestState = NonNullable<
|
|
27
|
+
NonNullable<PullRequestHeadData["repository"]>["pullRequest"]
|
|
28
|
+
>["state"];
|
|
29
|
+
|
|
30
|
+
export interface PullRequestHead {
|
|
31
|
+
state: "closed" | "merged" | "open";
|
|
32
|
+
branch: string;
|
|
33
|
+
/** the head repository's owner when it is a fork, null when the branch is on origin */
|
|
34
|
+
fork: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const headStates: Record<GhPullRequestState, PullRequestHead["state"]> = {
|
|
38
|
+
CLOSED: "closed",
|
|
39
|
+
MERGED: "merged",
|
|
40
|
+
OPEN: "open",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Raises with GitHub's reason when the pull request is not one the token can read. */
|
|
44
|
+
export const fetchPullRequestHead = async (
|
|
45
|
+
transport: GitHubTransport,
|
|
46
|
+
{ ownerRepo, number }: PullRequestRef,
|
|
47
|
+
): Promise<PullRequestHead> => {
|
|
48
|
+
const [owner = "", name = ""] = ownerRepo.split("/");
|
|
49
|
+
const { data, errors } = await transport.graphql<PullRequestHeadData>(
|
|
50
|
+
pullRequestHeadQuery,
|
|
51
|
+
{ owner, name, number },
|
|
52
|
+
);
|
|
53
|
+
const pullRequest = data?.repository?.pullRequest ?? null;
|
|
54
|
+
if (pullRequest === null) {
|
|
55
|
+
const reason = errors.length > 0 ? errors.join("; ") : "not found";
|
|
56
|
+
throw new Error(`${ownerRepo}#${String(number)}: ${reason}`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
state: headStates[pullRequest.state],
|
|
60
|
+
branch: pullRequest.headRefName,
|
|
61
|
+
fork: pullRequest.isCrossRepository
|
|
62
|
+
? (pullRequest.headRepositoryOwner?.login ?? "a deleted fork")
|
|
63
|
+
: null,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestRevision,
|
|
3
|
+
PullRequestRevisionsResponse,
|
|
4
|
+
} from "../api/pullRequest.ts";
|
|
5
|
+
import { diffLines } from "./lineDiff.ts";
|
|
6
|
+
import type { GitHubTransport } from "./transport.ts";
|
|
7
|
+
|
|
8
|
+
/** GitHub's own page lists a whole history; past this, the screen links to it. */
|
|
9
|
+
const revisionsPage = 50;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `userContentEdits` lists the newest first and ends on the original, when
|
|
13
|
+
* GitHub kept it (`includesCreatedEdit`). Despite its name, an edit's `diff` is
|
|
14
|
+
* the whole text of that version, not a change.
|
|
15
|
+
*/
|
|
16
|
+
export const pullRequestRevisionsQuery = `query PullRequestRevisions($id: ID!) {
|
|
17
|
+
node(id: $id) {
|
|
18
|
+
... on Comment {
|
|
19
|
+
includesCreatedEdit
|
|
20
|
+
userContentEdits(first: ${String(revisionsPage)}) {
|
|
21
|
+
totalCount
|
|
22
|
+
nodes {
|
|
23
|
+
editedAt
|
|
24
|
+
deletedAt
|
|
25
|
+
editor { login avatarUrl }
|
|
26
|
+
deletedBy { login }
|
|
27
|
+
diff
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}`;
|
|
33
|
+
|
|
34
|
+
interface GhActor {
|
|
35
|
+
login: string;
|
|
36
|
+
avatarUrl?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface GhContentEdit {
|
|
40
|
+
editedAt: string;
|
|
41
|
+
deletedAt: string | null;
|
|
42
|
+
editor: GhActor | null;
|
|
43
|
+
deletedBy: { login: string } | null;
|
|
44
|
+
diff: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface PullRequestRevisionsData {
|
|
48
|
+
node: {
|
|
49
|
+
includesCreatedEdit?: boolean;
|
|
50
|
+
userContentEdits?: {
|
|
51
|
+
totalCount: number;
|
|
52
|
+
nodes: (GhContentEdit | null)[] | null;
|
|
53
|
+
} | null;
|
|
54
|
+
} | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A body typed on GitHub's page carries `\r\n`, one sent through the API `\n`. */
|
|
58
|
+
const normalizeNewlines = (text: string | null): string | null =>
|
|
59
|
+
text?.replaceAll("\r\n", "\n") ?? null;
|
|
60
|
+
|
|
61
|
+
export const parsePullRequestRevisions = ({
|
|
62
|
+
data,
|
|
63
|
+
errors,
|
|
64
|
+
subjectId,
|
|
65
|
+
}: {
|
|
66
|
+
data: PullRequestRevisionsData | null;
|
|
67
|
+
errors: string[];
|
|
68
|
+
subjectId: string;
|
|
69
|
+
}): PullRequestRevisionsResponse => {
|
|
70
|
+
const edits = data?.node?.userContentEdits ?? null;
|
|
71
|
+
if (edits === null) {
|
|
72
|
+
const reason = errors.length > 0 ? errors.join("; ") : "not found";
|
|
73
|
+
throw new Error(`no edit history for ${subjectId}: ${reason}`);
|
|
74
|
+
}
|
|
75
|
+
// GitHub's index, before its unresolved (null) entries are dropped: the
|
|
76
|
+
// original is the last of `totalCount`, whatever the page lost
|
|
77
|
+
const nodes = (edits.nodes ?? []).flatMap((node, rawIndex) =>
|
|
78
|
+
node === null ? [] : [{ ...node, rawIndex }],
|
|
79
|
+
);
|
|
80
|
+
const bodies = nodes.map((node) =>
|
|
81
|
+
node.deletedAt === null ? normalizeNewlines(node.diff) : null,
|
|
82
|
+
);
|
|
83
|
+
const lastIndex = edits.totalCount - 1;
|
|
84
|
+
|
|
85
|
+
const revisions = nodes.map((node, index): PullRequestRevision => {
|
|
86
|
+
const body = bodies[index] ?? null;
|
|
87
|
+
const before = bodies.slice(index + 1).find((older) => older !== null);
|
|
88
|
+
return {
|
|
89
|
+
editedAt: node.editedAt,
|
|
90
|
+
editor:
|
|
91
|
+
node.editor === null
|
|
92
|
+
? null
|
|
93
|
+
: {
|
|
94
|
+
login: node.editor.login,
|
|
95
|
+
avatarUrl: node.editor.avatarUrl ?? null,
|
|
96
|
+
},
|
|
97
|
+
original:
|
|
98
|
+
data?.node?.includesCreatedEdit === true && node.rawIndex === lastIndex,
|
|
99
|
+
body,
|
|
100
|
+
deletedBy: node.deletedBy?.login ?? null,
|
|
101
|
+
changes:
|
|
102
|
+
body === null || before === undefined
|
|
103
|
+
? null
|
|
104
|
+
: diffLines({ before, after: body }),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return { revisions, revisionsTotal: edits.totalCount };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Raises with GitHub's reason when the id names nothing the token can read, or
|
|
113
|
+
* something that is not a comment.
|
|
114
|
+
*/
|
|
115
|
+
export const fetchPullRequestRevisions = async (
|
|
116
|
+
transport: GitHubTransport,
|
|
117
|
+
subjectId: string,
|
|
118
|
+
): Promise<PullRequestRevisionsResponse> => {
|
|
119
|
+
const { data, errors } = await transport.graphql<PullRequestRevisionsData>(
|
|
120
|
+
pullRequestRevisionsQuery,
|
|
121
|
+
{ id: subjectId },
|
|
122
|
+
);
|
|
123
|
+
return parsePullRequestRevisions({ data, errors, subjectId });
|
|
124
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How this package reaches GitHub, supplied by the app: the token, the HTTP
|
|
3
|
+
* client and the identity the requests go out under are the app's, so the
|
|
4
|
+
* same queries run from a local server with the user's `gh` login, or from
|
|
5
|
+
* anywhere `fetch` exists.
|
|
6
|
+
*
|
|
7
|
+
* `createGitHubTransport` builds the three calls from one raw `send`; an app
|
|
8
|
+
* only has to authenticate and carry the bytes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface HttpAnswer {
|
|
12
|
+
status: number;
|
|
13
|
+
body: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type HttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
|
|
17
|
+
|
|
18
|
+
export interface HttpRequest {
|
|
19
|
+
method: HttpMethod;
|
|
20
|
+
/** relative to the API root (`https://api.github.com/`), already encoded */
|
|
21
|
+
path: string;
|
|
22
|
+
accept: string;
|
|
23
|
+
/** JSON, already serialized */
|
|
24
|
+
body: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SendToGitHub = (request: HttpRequest) => Promise<HttpAnswer>;
|
|
28
|
+
|
|
29
|
+
export interface GhGraphqlResult<Data> {
|
|
30
|
+
/** null when the whole query failed; partial when some aliases resolved */
|
|
31
|
+
data: Data | null;
|
|
32
|
+
errors: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type GhRestMethod = "DELETE" | "PATCH" | "POST" | "PUT";
|
|
36
|
+
|
|
37
|
+
export interface GitHubTransport {
|
|
38
|
+
graphql: <Data>(
|
|
39
|
+
query: string,
|
|
40
|
+
variables?: Record<string, unknown>,
|
|
41
|
+
) => Promise<GhGraphqlResult<Data>>;
|
|
42
|
+
rest: <Data>(request: {
|
|
43
|
+
method: GhRestMethod;
|
|
44
|
+
/** relative to the API root, already encoded */
|
|
45
|
+
path: string;
|
|
46
|
+
body?: unknown;
|
|
47
|
+
}) => Promise<Data | null>;
|
|
48
|
+
text: (request: {
|
|
49
|
+
/** relative to the API root, already encoded */
|
|
50
|
+
path: string;
|
|
51
|
+
accept: string;
|
|
52
|
+
}) => Promise<string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const firstLine = (text: string): string =>
|
|
56
|
+
text.split("\n", 1)[0]?.trim() ?? "";
|
|
57
|
+
|
|
58
|
+
const isSuccess = (status: number): boolean => status >= 200 && status < 300;
|
|
59
|
+
|
|
60
|
+
const parseJson = <Value>(text: string): Value | null => {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text) as Value;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A GraphQL answer carrying an `errors` array next to usable `data` — one
|
|
70
|
+
* repository the token cannot see — is a partial answer, not a failed request.
|
|
71
|
+
* Only an answer with no `data` at all is raised: an expired token, a rate
|
|
72
|
+
* limit or a network failure, which the user has to act on.
|
|
73
|
+
*/
|
|
74
|
+
export const interpretGraphql = <Data>({
|
|
75
|
+
status,
|
|
76
|
+
body,
|
|
77
|
+
}: HttpAnswer): GhGraphqlResult<Data> => {
|
|
78
|
+
const parsed = parseJson<{
|
|
79
|
+
data?: Data | null;
|
|
80
|
+
errors?: { message?: string }[];
|
|
81
|
+
message?: string;
|
|
82
|
+
}>(body);
|
|
83
|
+
|
|
84
|
+
if (parsed === null || (!isSuccess(status) && parsed.data == null)) {
|
|
85
|
+
const detail = parsed?.message ?? firstLine(body);
|
|
86
|
+
throw new Error(
|
|
87
|
+
`GitHub graphql failed (HTTP ${String(status)})${detail === "" ? "" : `: ${detail}`}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
data: parsed.data ?? null,
|
|
93
|
+
errors: (parsed.errors ?? []).map(
|
|
94
|
+
({ message }) => message ?? "github returned an unnamed error",
|
|
95
|
+
),
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
interface GhRestError {
|
|
100
|
+
message?: string;
|
|
101
|
+
errors?: (string | { message?: string; code?: string; field?: string })[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const restErrorMessage = ({ status, body }: HttpAnswer): string => {
|
|
105
|
+
const parsed = parseJson<GhRestError>(body);
|
|
106
|
+
const details = (parsed?.errors ?? [])
|
|
107
|
+
.map((error) =>
|
|
108
|
+
typeof error === "string"
|
|
109
|
+
? error
|
|
110
|
+
: (error.message ??
|
|
111
|
+
[error.field, error.code].filter(Boolean).join(" ")),
|
|
112
|
+
)
|
|
113
|
+
.filter((detail) => detail !== "");
|
|
114
|
+
const headline =
|
|
115
|
+
parsed?.message ?? (firstLine(body) || `HTTP ${String(status)}`);
|
|
116
|
+
return [headline, ...details].filter((part) => part !== "").join(": ");
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A REST write's non-2xx status is the answer: a write GitHub refused changed
|
|
121
|
+
* nothing, so its message is raised as is. A text read (a unified diff, with
|
|
122
|
+
* `Accept: application/vnd.github.diff`) is refused past 300 files or 20,000
|
|
123
|
+
* lines with a 406, raised the same way.
|
|
124
|
+
*/
|
|
125
|
+
export const createGitHubTransport = (send: SendToGitHub): GitHubTransport => ({
|
|
126
|
+
graphql: async <Data>(
|
|
127
|
+
query: string,
|
|
128
|
+
variables?: Record<string, unknown>,
|
|
129
|
+
): Promise<GhGraphqlResult<Data>> =>
|
|
130
|
+
interpretGraphql<Data>(
|
|
131
|
+
await send({
|
|
132
|
+
method: "POST",
|
|
133
|
+
path: "graphql",
|
|
134
|
+
accept: "application/json",
|
|
135
|
+
body: JSON.stringify(
|
|
136
|
+
variables === undefined ? { query } : { query, variables },
|
|
137
|
+
),
|
|
138
|
+
}),
|
|
139
|
+
),
|
|
140
|
+
rest: async <Data>({
|
|
141
|
+
method,
|
|
142
|
+
path,
|
|
143
|
+
body,
|
|
144
|
+
}: {
|
|
145
|
+
method: GhRestMethod;
|
|
146
|
+
path: string;
|
|
147
|
+
body?: unknown;
|
|
148
|
+
}): Promise<Data | null> => {
|
|
149
|
+
const answer = await send({
|
|
150
|
+
method,
|
|
151
|
+
path,
|
|
152
|
+
accept: "application/vnd.github+json",
|
|
153
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
154
|
+
});
|
|
155
|
+
if (!isSuccess(answer.status)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`GitHub refused ${method} ${path}: ${restErrorMessage(answer)}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return parseJson<Data>(answer.body);
|
|
161
|
+
},
|
|
162
|
+
text: async ({ path, accept }) => {
|
|
163
|
+
const answer = await send({ method: "GET", path, accept, body: undefined });
|
|
164
|
+
if (!isSuccess(answer.status)) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`GitHub refused GET ${path}: ${restErrorMessage(answer)}`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return answer.body;
|
|
170
|
+
},
|
|
171
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe surface: nothing here may import node. Reading a diff from a
|
|
3
|
+
* local checkout is `@reviewflow/sdk/git`, which is node only.
|
|
4
|
+
*/
|
|
5
|
+
export * from "./diff/diffPaths.ts";
|
|
6
|
+
export type * from "./diff/impacts.ts";
|
|
7
|
+
export { isAttachablePath, subjectsOf } from "./diff/readingOrder.ts";
|
|
8
|
+
export * from "./diff/themes.ts";
|
|
9
|
+
export * from "./diff/types.ts";
|
|
10
|
+
export * from "./github/applySuggestions.ts";
|
|
11
|
+
export * from "./github/editPullRequest.ts";
|
|
12
|
+
export * from "./github/fetchCompareDiff.ts";
|
|
13
|
+
export * from "./github/lineDiff.ts";
|
|
14
|
+
export * from "./github/prQuery.ts";
|
|
15
|
+
export * from "./github/pullRequestDetail.ts";
|
|
16
|
+
export * from "./github/pullRequestHead.ts";
|
|
17
|
+
export * from "./github/pullRequestRevisions.ts";
|
|
18
|
+
export * from "./github/transport.ts";
|
|
19
|
+
export type * from "./api/pullRequest.ts";
|
|
20
|
+
export type * from "./types/entities.ts";
|
|
21
|
+
export type * from "./types/ids.ts";
|
|
22
|
+
export type * from "./types/reviewflow.ts";
|
|
23
|
+
export * from "./utils/pullRequestEdit.ts";
|
|
24
|
+
export * from "./utils/pullRequestPart.ts";
|
|
25
|
+
export * from "./utils/pullRequestReaction.ts";
|
|
26
|
+
export * from "./utils/pullRequestSuggestion.ts";
|
|
27
|
+
export * from "./reviewflow/changedParts.ts";
|
|
28
|
+
export * from "./reviewflow/checksFromReviewflow.ts";
|
|
29
|
+
export * from "./reviewflow/client.ts";
|
|
30
|
+
export * from "./reviewflow/stream.ts";
|
|
31
|
+
export * from "./reviewflow/board.ts";
|
|
32
|
+
export * from "./reviewflow/follow.ts";
|
|
33
|
+
export type * from "./reviewflow/status.ts";
|
|
34
|
+
export { insertCheckoutEntry } from "./config/insertCheckoutEntry.ts";
|
|
35
|
+
export type { InsertedCheckoutEntry } from "./config/insertCheckoutEntry.ts";
|