@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,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";