@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,399 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestDiffSide,
|
|
3
|
+
PullRequestEdit,
|
|
4
|
+
} from "../api/pullRequest.ts";
|
|
5
|
+
import type { PrState } from "../types/entities.ts";
|
|
6
|
+
import {
|
|
7
|
+
parseSuggestions,
|
|
8
|
+
suggestionCommitRefusal,
|
|
9
|
+
suggestionRangesOverlap,
|
|
10
|
+
} from "../utils/pullRequestSuggestion.ts";
|
|
11
|
+
import { splitOwnerRepo } from "./prQuery.ts";
|
|
12
|
+
import type { GitHubTransport } from "./transport.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* GitHub has no mutation applying a suggestion: its "Commit suggestion" is a
|
|
16
|
+
* web commit. `createCommitOnBranch` is the API's web commit — authored by the
|
|
17
|
+
* token's owner, signed by GitHub — and takes whole files, so each file is
|
|
18
|
+
* read at the expected head and rewritten here. Every check runs on GitHub's
|
|
19
|
+
* current state of the threads before the commit, since the screen's may be
|
|
20
|
+
* one read old.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ApplySuggestionsEdit = Extract<
|
|
24
|
+
PullRequestEdit,
|
|
25
|
+
{ kind: "applySuggestions" }
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
const suggestionsQuery = `query SuggestionsToApply($owner: String!, $name: String!, $number: Int!, $threadIds: [ID!]!, $commentIds: [ID!]!) {
|
|
29
|
+
viewer { login }
|
|
30
|
+
repository(owner: $owner, name: $name) {
|
|
31
|
+
pullRequest(number: $number) {
|
|
32
|
+
id state headRefOid headRefName viewerCanApplySuggestion
|
|
33
|
+
headRepository { nameWithOwner }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
threads: nodes(ids: $threadIds) {
|
|
37
|
+
... on PullRequestReviewThread {
|
|
38
|
+
id path line startLine diffSide startDiffSide isOutdated isResolved
|
|
39
|
+
pullRequest { id }
|
|
40
|
+
comments(first: 1) { nodes { id } }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
comments: nodes(ids: $commentIds) {
|
|
44
|
+
... on PullRequestReviewComment {
|
|
45
|
+
id body state
|
|
46
|
+
replyTo { id }
|
|
47
|
+
author { login ... on User { databaseId name } }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}`;
|
|
51
|
+
|
|
52
|
+
interface GhSuggestionThread {
|
|
53
|
+
id?: string;
|
|
54
|
+
path: string;
|
|
55
|
+
line: number | null;
|
|
56
|
+
startLine: number | null;
|
|
57
|
+
diffSide: string;
|
|
58
|
+
startDiffSide: string | null;
|
|
59
|
+
isOutdated: boolean;
|
|
60
|
+
isResolved: boolean;
|
|
61
|
+
pullRequest: { id: string };
|
|
62
|
+
comments: { nodes: ({ id: string } | null)[] | null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface GhSuggester {
|
|
66
|
+
login: string;
|
|
67
|
+
databaseId?: number;
|
|
68
|
+
name?: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface GhSuggestionComment {
|
|
72
|
+
id?: string;
|
|
73
|
+
body: string;
|
|
74
|
+
state: string;
|
|
75
|
+
replyTo: { id: string } | null;
|
|
76
|
+
author: GhSuggester | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SuggestionsAnswer {
|
|
80
|
+
viewer: { login: string };
|
|
81
|
+
repository: {
|
|
82
|
+
pullRequest: {
|
|
83
|
+
id: string;
|
|
84
|
+
state: string;
|
|
85
|
+
headRefOid: string;
|
|
86
|
+
headRefName: string;
|
|
87
|
+
viewerCanApplySuggestion: boolean;
|
|
88
|
+
headRepository: { nameWithOwner: string } | null;
|
|
89
|
+
} | null;
|
|
90
|
+
} | null;
|
|
91
|
+
threads: (GhSuggestionThread | null)[];
|
|
92
|
+
comments: (GhSuggestionComment | null)[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PlannedSuggestion {
|
|
96
|
+
threadId: string;
|
|
97
|
+
resolved: boolean;
|
|
98
|
+
path: string;
|
|
99
|
+
startLine: number | null;
|
|
100
|
+
line: number;
|
|
101
|
+
lines: string[];
|
|
102
|
+
author: GhSuggester | null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SuggestionsPlan {
|
|
106
|
+
repositoryNameWithOwner: string;
|
|
107
|
+
branchName: string;
|
|
108
|
+
viewerLogin: string;
|
|
109
|
+
suggestions: PlannedSuggestion[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toPrState = (state: string): PrState => {
|
|
113
|
+
if (state === "MERGED") return "merged";
|
|
114
|
+
if (state === "CLOSED") return "closed";
|
|
115
|
+
return "open";
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const toSide = (side: string | null): PullRequestDiffSide | null => {
|
|
119
|
+
if (side === null) return null;
|
|
120
|
+
return side === "LEFT" ? "LEFT" : "RIGHT";
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Each suggestion as GitHub holds it, or why the batch is refused. */
|
|
124
|
+
export const planSuggestions = (
|
|
125
|
+
answer: SuggestionsAnswer,
|
|
126
|
+
edit: ApplySuggestionsEdit,
|
|
127
|
+
): SuggestionsPlan => {
|
|
128
|
+
const pullRequest = answer.repository?.pullRequest;
|
|
129
|
+
if (pullRequest === null || pullRequest === undefined) {
|
|
130
|
+
throw new Error("the pull request is not found");
|
|
131
|
+
}
|
|
132
|
+
if (pullRequest.headRefOid !== edit.expectedHeadSha) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`the pull request moved on to ${pullRequest.headRefOid.slice(0, 7)} since ${edit.expectedHeadSha.slice(0, 7)}: read it again before committing`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (pullRequest.headRepository === null) {
|
|
138
|
+
throw new Error("the head branch's repository no longer exists");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const suggestions: PlannedSuggestion[] = [];
|
|
142
|
+
edit.suggestions.forEach((ref, index) => {
|
|
143
|
+
const thread = answer.threads[index];
|
|
144
|
+
const comment = answer.comments[index];
|
|
145
|
+
if (thread?.id !== ref.threadId) {
|
|
146
|
+
throw new Error(`no review thread ${ref.threadId}`);
|
|
147
|
+
}
|
|
148
|
+
if (thread.pullRequest.id !== pullRequest.id) {
|
|
149
|
+
throw new Error(`thread ${ref.threadId} is not of this pull request`);
|
|
150
|
+
}
|
|
151
|
+
if (comment?.id !== ref.commentId) {
|
|
152
|
+
throw new Error(`no review comment ${ref.commentId}`);
|
|
153
|
+
}
|
|
154
|
+
const root = comment.replyTo?.id ?? comment.id;
|
|
155
|
+
if (root !== thread.comments.nodes?.[0]?.id) {
|
|
156
|
+
throw new Error(`comment ${ref.commentId} is not in that thread`);
|
|
157
|
+
}
|
|
158
|
+
if (comment.state === "PENDING") {
|
|
159
|
+
throw new Error("a suggestion in a pending review cannot be committed");
|
|
160
|
+
}
|
|
161
|
+
const parsed = parseSuggestions(comment.body);
|
|
162
|
+
const refusal = suggestionCommitRefusal({
|
|
163
|
+
state: toPrState(pullRequest.state),
|
|
164
|
+
viewerCanApplySuggestion: pullRequest.viewerCanApplySuggestion,
|
|
165
|
+
suggestionCount: parsed.length,
|
|
166
|
+
outdated: thread.isOutdated,
|
|
167
|
+
line: thread.line,
|
|
168
|
+
side: toSide(thread.diffSide) ?? "RIGHT",
|
|
169
|
+
startSide: toSide(thread.startDiffSide),
|
|
170
|
+
});
|
|
171
|
+
const [lines] = parsed;
|
|
172
|
+
if (refusal !== null || thread.line === null || lines === undefined) {
|
|
173
|
+
throw new Error(`${thread.path}: ${refusal ?? "nothing to commit"}`);
|
|
174
|
+
}
|
|
175
|
+
const planned: PlannedSuggestion = {
|
|
176
|
+
threadId: thread.id,
|
|
177
|
+
resolved: thread.isResolved,
|
|
178
|
+
path: thread.path,
|
|
179
|
+
startLine: thread.startLine,
|
|
180
|
+
line: thread.line,
|
|
181
|
+
lines,
|
|
182
|
+
author: comment.author,
|
|
183
|
+
};
|
|
184
|
+
if (suggestions.some((other) => suggestionRangesOverlap(other, planned))) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${planned.path}: two suggestions of the batch replace line ${String(planned.line)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
suggestions.push(planned);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
repositoryNameWithOwner: pullRequest.headRepository.nameWithOwner,
|
|
194
|
+
branchName: pullRequest.headRefName,
|
|
195
|
+
viewerLogin: answer.viewer.login,
|
|
196
|
+
suggestions,
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
type SuggestionLines = Pick<PlannedSuggestion, "line" | "lines" | "startLine">;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The file with each range replaced, bottom first so the line numbers above
|
|
204
|
+
* still hold. A file in CRLF keeps it on the lines put in.
|
|
205
|
+
*/
|
|
206
|
+
export const applySuggestionsToText = (
|
|
207
|
+
text: string,
|
|
208
|
+
suggestions: SuggestionLines[],
|
|
209
|
+
): string => {
|
|
210
|
+
const eol = text.includes("\r\n") ? "\r" : "";
|
|
211
|
+
const lines = text.split("\n");
|
|
212
|
+
const endsWithNewline = lines.at(-1) === "";
|
|
213
|
+
const lineCount = endsWithNewline ? lines.length - 1 : lines.length;
|
|
214
|
+
const bottomFirst = suggestions.toSorted((a, b) => b.line - a.line);
|
|
215
|
+
for (const { startLine, line, lines: replacement } of bottomFirst) {
|
|
216
|
+
if (line > lineCount) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`line ${String(line)} is past the end of the file, ${String(lineCount)} lines long`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const first = startLine ?? line;
|
|
222
|
+
const isLastLine = line === lineCount && !endsWithNewline;
|
|
223
|
+
const put = replacement.map((content, index) =>
|
|
224
|
+
isLastLine && index === replacement.length - 1 ? content : content + eol,
|
|
225
|
+
);
|
|
226
|
+
lines.splice(first - 1, line - first + 1, ...put);
|
|
227
|
+
}
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** GitHub's own trailer for a suggestion it commits, with the account's noreply address. */
|
|
232
|
+
export const coAuthorTrailers = (
|
|
233
|
+
authors: (GhSuggester | null)[],
|
|
234
|
+
viewerLogin: string,
|
|
235
|
+
): string[] => {
|
|
236
|
+
const seen = new Set<string>([viewerLogin]);
|
|
237
|
+
const trailers: string[] = [];
|
|
238
|
+
for (const author of authors) {
|
|
239
|
+
if (author?.databaseId === undefined || seen.has(author.login)) continue;
|
|
240
|
+
seen.add(author.login);
|
|
241
|
+
const name =
|
|
242
|
+
author.name === null || author.name === undefined || author.name === ""
|
|
243
|
+
? author.login
|
|
244
|
+
: author.name;
|
|
245
|
+
trailers.push(
|
|
246
|
+
`Co-authored-by: ${name} <${String(author.databaseId)}+${author.login}@users.noreply.github.com>`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return trailers;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const fileQuery = (count: number): string => {
|
|
253
|
+
const indexes = Array.from({ length: count }, (_, index) => index);
|
|
254
|
+
const variables = indexes
|
|
255
|
+
.map((index) => `$e${String(index)}: String!`)
|
|
256
|
+
.join(", ");
|
|
257
|
+
const fields = indexes
|
|
258
|
+
.map(
|
|
259
|
+
(index) =>
|
|
260
|
+
`f${String(index)}: object(expression: $e${String(index)}) { ... on Blob { text isBinary isTruncated } }`,
|
|
261
|
+
)
|
|
262
|
+
.join("\n ");
|
|
263
|
+
return `query SuggestedFiles($owner: String!, $name: String!, ${variables}) {
|
|
264
|
+
repository(owner: $owner, name: $name) {
|
|
265
|
+
${fields}
|
|
266
|
+
}
|
|
267
|
+
}`;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
interface GhBlob {
|
|
271
|
+
text: string | null;
|
|
272
|
+
isBinary: boolean | null;
|
|
273
|
+
isTruncated: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const readFiles = async (
|
|
277
|
+
transport: GitHubTransport,
|
|
278
|
+
nameWithOwner: string,
|
|
279
|
+
sha: string,
|
|
280
|
+
paths: string[],
|
|
281
|
+
): Promise<Map<string, string>> => {
|
|
282
|
+
const split = splitOwnerRepo(nameWithOwner);
|
|
283
|
+
if (split === null) throw new Error(`${nameWithOwner} is not owner/repo`);
|
|
284
|
+
const variables = Object.fromEntries(
|
|
285
|
+
paths.map((path, index) => [`e${String(index)}`, `${sha}:${path}`]),
|
|
286
|
+
);
|
|
287
|
+
const { data, errors } = await transport.graphql<{
|
|
288
|
+
repository: Record<string, GhBlob | null> | null;
|
|
289
|
+
}>(fileQuery(paths.length), { ...split, ...variables });
|
|
290
|
+
if (errors.length > 0) {
|
|
291
|
+
throw new Error(`GitHub refused reading the files: ${errors.join("; ")}`);
|
|
292
|
+
}
|
|
293
|
+
const files = new Map<string, string>();
|
|
294
|
+
paths.forEach((path, index) => {
|
|
295
|
+
const blob = data?.repository?.[`f${String(index)}`];
|
|
296
|
+
const text = blob?.text;
|
|
297
|
+
if (
|
|
298
|
+
text === null ||
|
|
299
|
+
text === undefined ||
|
|
300
|
+
blob?.isBinary === true ||
|
|
301
|
+
blob?.isTruncated === true
|
|
302
|
+
) {
|
|
303
|
+
throw new Error(`${path} cannot be read as text at ${sha.slice(0, 7)}`);
|
|
304
|
+
}
|
|
305
|
+
files.set(path, text);
|
|
306
|
+
});
|
|
307
|
+
return files;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const createCommitMutation = `mutation CommitSuggestions($input: CreateCommitOnBranchInput!) {
|
|
311
|
+
createCommitOnBranch(input: $input) { commit { oid } }
|
|
312
|
+
}`;
|
|
313
|
+
|
|
314
|
+
/** `Buffer` does not exist in the browser: UTF-8 bytes, then base64. */
|
|
315
|
+
const toBase64 = (text: string): string => {
|
|
316
|
+
let binary = "";
|
|
317
|
+
for (const byte of new TextEncoder().encode(text)) {
|
|
318
|
+
binary += String.fromCodePoint(byte);
|
|
319
|
+
}
|
|
320
|
+
return btoa(binary);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const messageBody = (body: string, trailers: string[]): string =>
|
|
324
|
+
[body.trim(), trailers.join("\n")].filter((part) => part !== "").join("\n\n");
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Commits the suggestions, as one commit on the head branch. Answers the
|
|
328
|
+
* threads left to resolve, as GitHub does once it commits a suggestion.
|
|
329
|
+
*/
|
|
330
|
+
export const commitSuggestions = async (
|
|
331
|
+
transport: GitHubTransport,
|
|
332
|
+
{
|
|
333
|
+
ownerRepo,
|
|
334
|
+
number,
|
|
335
|
+
edit,
|
|
336
|
+
}: {
|
|
337
|
+
ownerRepo: string;
|
|
338
|
+
number: number;
|
|
339
|
+
edit: ApplySuggestionsEdit;
|
|
340
|
+
},
|
|
341
|
+
): Promise<string[]> => {
|
|
342
|
+
const split = splitOwnerRepo(ownerRepo);
|
|
343
|
+
if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
|
|
344
|
+
const lookup = await transport.graphql<SuggestionsAnswer>(suggestionsQuery, {
|
|
345
|
+
...split,
|
|
346
|
+
number,
|
|
347
|
+
threadIds: edit.suggestions.map(({ threadId }) => threadId),
|
|
348
|
+
commentIds: edit.suggestions.map(({ commentId }) => commentId),
|
|
349
|
+
});
|
|
350
|
+
if (lookup.data === null) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`GitHub refused reading the suggestions: ${lookup.errors.join("; ") || "no answer"}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
const plan = planSuggestions(lookup.data, edit);
|
|
356
|
+
|
|
357
|
+
const paths = [...new Set(plan.suggestions.map(({ path }) => path))];
|
|
358
|
+
const files = await readFiles(
|
|
359
|
+
transport,
|
|
360
|
+
plan.repositoryNameWithOwner,
|
|
361
|
+
edit.expectedHeadSha,
|
|
362
|
+
paths,
|
|
363
|
+
);
|
|
364
|
+
const additions = paths.map((path) => {
|
|
365
|
+
const text = applySuggestionsToText(
|
|
366
|
+
files.get(path) ?? "",
|
|
367
|
+
plan.suggestions.filter((suggestion) => suggestion.path === path),
|
|
368
|
+
);
|
|
369
|
+
return { path, contents: toBase64(text) };
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const { errors } = await transport.graphql(createCommitMutation, {
|
|
373
|
+
input: {
|
|
374
|
+
branch: {
|
|
375
|
+
repositoryNameWithOwner: plan.repositoryNameWithOwner,
|
|
376
|
+
branchName: plan.branchName,
|
|
377
|
+
},
|
|
378
|
+
expectedHeadOid: edit.expectedHeadSha,
|
|
379
|
+
message: {
|
|
380
|
+
headline: edit.headline,
|
|
381
|
+
body: messageBody(
|
|
382
|
+
edit.body,
|
|
383
|
+
coAuthorTrailers(
|
|
384
|
+
plan.suggestions.map(({ author }) => author),
|
|
385
|
+
plan.viewerLogin,
|
|
386
|
+
),
|
|
387
|
+
),
|
|
388
|
+
},
|
|
389
|
+
fileChanges: { additions },
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
if (errors.length > 0) {
|
|
393
|
+
throw new Error(`GitHub refused the commit: ${errors.join("; ")}`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return plan.suggestions
|
|
397
|
+
.filter(({ resolved }) => !resolved)
|
|
398
|
+
.map(({ threadId }) => threadId);
|
|
399
|
+
};
|