@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,790 @@
1
+ import type {
2
+ EditPullRequestRequest,
3
+ PullRequestDiffSide,
4
+ PullRequestDraftComment,
5
+ PullRequestEdit,
6
+ PullRequestMergeCommitMessage,
7
+ PullRequestMergeMethod,
8
+ PullRequestReactionContent,
9
+ PullRequestReviewEvent,
10
+ PullRequestSuggestionRef,
11
+ } from "../api/pullRequest.ts";
12
+ import { isPullRequestReactionContent } from "../utils/pullRequestReaction.ts";
13
+ import { commitSuggestions } from "./applySuggestions.ts";
14
+ import { splitOwnerRepo } from "./prQuery.ts";
15
+ import type { GitHubTransport } from "./transport.ts";
16
+
17
+ const isStringArray = (value: unknown): value is string[] =>
18
+ Array.isArray(value) && value.every((item) => typeof item === "string");
19
+
20
+ const isNonEmptyString = (value: unknown): value is string =>
21
+ typeof value === "string" && value.trim() !== "";
22
+
23
+ const isPositiveInteger = (value: unknown): value is number =>
24
+ typeof value === "number" && Number.isInteger(value) && value > 0;
25
+
26
+ const isSha = (value: unknown): value is string =>
27
+ typeof value === "string" && /^[\da-f]{40}$/i.test(value);
28
+
29
+ const isSide = (value: unknown): value is PullRequestDiffSide =>
30
+ value === "LEFT" || value === "RIGHT";
31
+
32
+ const mergeMethods: PullRequestMergeMethod[] = ["merge", "rebase", "squash"];
33
+
34
+ const reviewEvents: PullRequestReviewEvent[] = [
35
+ "approve",
36
+ "comment",
37
+ "requestChanges",
38
+ ];
39
+
40
+ const editCommentTargets: Extract<
41
+ PullRequestEdit,
42
+ { kind: "editComment" }
43
+ >["target"][] = ["issueComment", "review", "reviewComment"];
44
+
45
+ const parseMergeCommitMessage = (
46
+ value: unknown,
47
+ ): PullRequestMergeCommitMessage | string => {
48
+ if (typeof value !== "object" || value === null) {
49
+ return "a commit message must be an object";
50
+ }
51
+ const message = value as Record<string, unknown>;
52
+ if (!isNonEmptyString(message.title)) return "a commit needs a title";
53
+ if (typeof message.body !== "string") return "a commit body must be a string";
54
+ return { title: message.title, body: message.body };
55
+ };
56
+
57
+ /** A range needs both ends' sides; a single line needs neither. */
58
+ const parseDraftComment = (
59
+ value: unknown,
60
+ ): PullRequestDraftComment | string => {
61
+ if (typeof value !== "object" || value === null) {
62
+ return "a comment must be an object";
63
+ }
64
+ const comment = value as Record<string, unknown>;
65
+ if (!isNonEmptyString(comment.path)) return "a comment needs a path";
66
+ if (!isNonEmptyString(comment.body)) return "a comment cannot be empty";
67
+ if (!isSide(comment.side)) return "a comment side must be LEFT or RIGHT";
68
+ if (!isPositiveInteger(comment.line)) {
69
+ return "a comment line must be a positive integer";
70
+ }
71
+ const startLine = comment.startLine ?? null;
72
+ const startSide = comment.startSide ?? null;
73
+ if (startLine === null) {
74
+ if (startSide !== null) return "startSide needs a startLine";
75
+ } else {
76
+ if (!isPositiveInteger(startLine)) {
77
+ return "startLine must be a positive integer";
78
+ }
79
+ if (startLine > comment.line) return "startLine cannot follow line";
80
+ if (!isSide(startSide)) return "a range needs startSide, LEFT or RIGHT";
81
+ }
82
+ return {
83
+ path: comment.path,
84
+ side: comment.side,
85
+ line: comment.line,
86
+ startLine,
87
+ startSide,
88
+ body: comment.body,
89
+ };
90
+ };
91
+
92
+ const parseReview = (
93
+ edit: Record<string, unknown>,
94
+ ): PullRequestEdit | string => {
95
+ if (!reviewEvents.includes(edit.event as PullRequestReviewEvent)) {
96
+ return "a review event must be approve, comment or requestChanges";
97
+ }
98
+ const event = edit.event as PullRequestReviewEvent;
99
+ if (typeof edit.body !== "string") return "a review body must be a string";
100
+ if (!isSha(edit.commitId)) return "a review needs the head commit sha";
101
+ if (!Array.isArray(edit.comments)) return "a review takes a comments array";
102
+ const comments: PullRequestDraftComment[] = [];
103
+ for (const raw of edit.comments) {
104
+ const parsed = parseDraftComment(raw);
105
+ if (typeof parsed === "string") return parsed;
106
+ comments.push(parsed);
107
+ }
108
+ if (event === "requestChanges" && edit.body.trim() === "") {
109
+ return "requesting changes needs a body";
110
+ }
111
+ if (event === "comment" && edit.body.trim() === "" && comments.length === 0) {
112
+ return "a comment review needs a body or a comment";
113
+ }
114
+ return {
115
+ kind: "review",
116
+ event,
117
+ body: edit.body,
118
+ commitId: edit.commitId,
119
+ comments,
120
+ };
121
+ };
122
+
123
+ const parseSuggestionRef = (
124
+ value: unknown,
125
+ ): PullRequestSuggestionRef | string => {
126
+ if (typeof value !== "object" || value === null) {
127
+ return "a suggestion must be an object";
128
+ }
129
+ const ref = value as Record<string, unknown>;
130
+ if (!isNonEmptyString(ref.threadId)) return "a suggestion needs its threadId";
131
+ if (!isNonEmptyString(ref.commentId)) {
132
+ return "a suggestion needs its commentId";
133
+ }
134
+ return { threadId: ref.threadId, commentId: ref.commentId };
135
+ };
136
+
137
+ const parseApplySuggestions = (
138
+ edit: Record<string, unknown>,
139
+ ): PullRequestEdit | string => {
140
+ if (!isSha(edit.expectedHeadSha)) {
141
+ return "committing suggestions needs the head sha the screen read";
142
+ }
143
+ if (!Array.isArray(edit.suggestions) || edit.suggestions.length === 0) {
144
+ return "suggestions must be a non-empty array";
145
+ }
146
+ const suggestions: PullRequestSuggestionRef[] = [];
147
+ for (const raw of edit.suggestions) {
148
+ const parsed = parseSuggestionRef(raw);
149
+ if (typeof parsed === "string") return parsed;
150
+ suggestions.push(parsed);
151
+ }
152
+ if (!isNonEmptyString(edit.headline)) return "a commit needs a headline";
153
+ if (typeof edit.body !== "string") return "a commit body must be a string";
154
+ return {
155
+ kind: "applySuggestions",
156
+ expectedHeadSha: edit.expectedHeadSha,
157
+ suggestions,
158
+ headline: edit.headline.trim(),
159
+ body: edit.body,
160
+ };
161
+ };
162
+
163
+ /** The edit off the wire, or why it is refused. */
164
+ export const parsePullRequestEdit = (
165
+ value: unknown,
166
+ ): PullRequestEdit | string => {
167
+ if (typeof value !== "object" || value === null) return "edit is required";
168
+ const edit = value as Record<string, unknown>;
169
+ switch (edit.kind) {
170
+ case "title": {
171
+ if (typeof edit.title !== "string" || edit.title.trim() === "") {
172
+ return "a title cannot be empty";
173
+ }
174
+ return { kind: "title", title: edit.title.trim() };
175
+ }
176
+ case "labels": {
177
+ if (!isStringArray(edit.add) || !isStringArray(edit.remove)) {
178
+ return "labels take add and remove, arrays of names";
179
+ }
180
+ return { kind: "labels", add: edit.add, remove: edit.remove };
181
+ }
182
+ case "reviewers": {
183
+ if (
184
+ !isStringArray(edit.add) ||
185
+ !isStringArray(edit.remove) ||
186
+ !isStringArray(edit.addTeams) ||
187
+ !isStringArray(edit.removeTeams)
188
+ ) {
189
+ return "reviewers take add, remove, addTeams and removeTeams, arrays of logins and team slugs";
190
+ }
191
+ return {
192
+ kind: "reviewers",
193
+ add: edit.add,
194
+ remove: edit.remove,
195
+ addTeams: edit.addTeams,
196
+ removeTeams: edit.removeTeams,
197
+ };
198
+ }
199
+ case "draft": {
200
+ if (typeof edit.draft !== "boolean") return "draft must be a boolean";
201
+ return { kind: "draft", draft: edit.draft };
202
+ }
203
+ case "assignees": {
204
+ if (!isStringArray(edit.add) || !isStringArray(edit.remove)) {
205
+ return "assignees take add and remove, arrays of logins";
206
+ }
207
+ return { kind: "assignees", add: edit.add, remove: edit.remove };
208
+ }
209
+ case "body": {
210
+ if (typeof edit.body !== "string") return "body must be a string";
211
+ return { kind: "body", body: edit.body };
212
+ }
213
+ case "state": {
214
+ if (edit.state !== "closed" && edit.state !== "open") {
215
+ return "state must be closed or open";
216
+ }
217
+ return { kind: "state", state: edit.state };
218
+ }
219
+ case "merge": {
220
+ if (!mergeMethods.includes(edit.method as PullRequestMergeMethod)) {
221
+ return "a merge method must be merge, rebase or squash";
222
+ }
223
+ if (!isSha(edit.expectedHeadSha)) {
224
+ return "a merge needs the head sha the screen read";
225
+ }
226
+ const merge = {
227
+ kind: "merge",
228
+ method: edit.method as PullRequestMergeMethod,
229
+ expectedHeadSha: edit.expectedHeadSha,
230
+ } as const;
231
+ if (edit.commitMessage === undefined) return merge;
232
+ if (merge.method === "rebase") return "a rebase writes no commit message";
233
+ const commitMessage = parseMergeCommitMessage(edit.commitMessage);
234
+ if (typeof commitMessage === "string") return commitMessage;
235
+ return { ...merge, commitMessage };
236
+ }
237
+ case "updateBranch": {
238
+ if (!isSha(edit.expectedHeadSha)) {
239
+ return "updating the branch needs the head sha the screen read";
240
+ }
241
+ return { kind: "updateBranch", expectedHeadSha: edit.expectedHeadSha };
242
+ }
243
+ case "comment": {
244
+ if (!isNonEmptyString(edit.body)) return "a comment cannot be empty";
245
+ return { kind: "comment", body: edit.body };
246
+ }
247
+ case "editComment": {
248
+ if (
249
+ !editCommentTargets.includes(
250
+ edit.target as (typeof editCommentTargets)[number],
251
+ )
252
+ ) {
253
+ return "editComment target must be issueComment, review or reviewComment";
254
+ }
255
+ if (!isPositiveInteger(edit.databaseId)) {
256
+ return "editComment needs the comment's databaseId";
257
+ }
258
+ if (!isNonEmptyString(edit.body)) return "a comment cannot be empty";
259
+ return {
260
+ kind: "editComment",
261
+ target: edit.target as (typeof editCommentTargets)[number],
262
+ databaseId: edit.databaseId,
263
+ body: edit.body,
264
+ };
265
+ }
266
+ case "reply": {
267
+ if (!isPositiveInteger(edit.inReplyTo)) {
268
+ return "a reply needs the databaseId of a comment of the thread";
269
+ }
270
+ if (!isNonEmptyString(edit.body)) return "a reply cannot be empty";
271
+ return { kind: "reply", inReplyTo: edit.inReplyTo, body: edit.body };
272
+ }
273
+ case "reaction": {
274
+ if (!isNonEmptyString(edit.subjectId)) {
275
+ return "a reaction needs the id of what it reacts to";
276
+ }
277
+ if (!isPullRequestReactionContent(edit.content)) {
278
+ return "unknown reaction content";
279
+ }
280
+ if (typeof edit.reacted !== "boolean") return "reacted must be a boolean";
281
+ return {
282
+ kind: "reaction",
283
+ subjectId: edit.subjectId,
284
+ content: edit.content,
285
+ reacted: edit.reacted,
286
+ };
287
+ }
288
+ case "resolveThread": {
289
+ if (!isNonEmptyString(edit.threadId)) return "a thread id is required";
290
+ if (typeof edit.resolved !== "boolean") {
291
+ return "resolved must be a boolean";
292
+ }
293
+ return {
294
+ kind: "resolveThread",
295
+ threadId: edit.threadId,
296
+ resolved: edit.resolved,
297
+ };
298
+ }
299
+ case "viewed": {
300
+ if (!isNonEmptyString(edit.path)) return "a file path is required";
301
+ if (typeof edit.viewed !== "boolean") return "viewed must be a boolean";
302
+ return { kind: "viewed", path: edit.path, viewed: edit.viewed };
303
+ }
304
+ case "review": {
305
+ return parseReview(edit);
306
+ }
307
+ case "applySuggestions": {
308
+ return parseApplySuggestions(edit);
309
+ }
310
+ case "inlineComment": {
311
+ if (!isSha(edit.commitId)) return "a comment needs the head commit sha";
312
+ const comment = parseDraftComment(edit.comment);
313
+ if (typeof comment === "string") return comment;
314
+ return { kind: "inlineComment", commitId: edit.commitId, comment };
315
+ }
316
+ default: {
317
+ return "unknown edit kind";
318
+ }
319
+ }
320
+ };
321
+
322
+ const pullRequestIdQuery = `query PullRequestId($owner: String!, $name: String!, $number: Int!) {
323
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } }
324
+ }`;
325
+
326
+ const readyForReviewMutation = `mutation ReadyForReview($id: ID!) {
327
+ markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } }
328
+ }`;
329
+
330
+ const convertToDraftMutation = `mutation ConvertToDraft($id: ID!) {
331
+ convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { isDraft } }
332
+ }`;
333
+
334
+ const resolveThreadMutation = `mutation ResolveThread($threadId: ID!) {
335
+ resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
336
+ }`;
337
+
338
+ const unresolveThreadMutation = `mutation UnresolveThread($threadId: ID!) {
339
+ unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } }
340
+ }`;
341
+
342
+ const addReactionMutation = `mutation AddReaction($subjectId: ID!, $content: ReactionContent!) {
343
+ addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
344
+ }`;
345
+
346
+ const removeReactionMutation = `mutation RemoveReaction($subjectId: ID!, $content: ReactionContent!) {
347
+ removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } }
348
+ }`;
349
+
350
+ const reactionContentNames: Record<PullRequestReactionContent, string> = {
351
+ confused: "CONFUSED",
352
+ eyes: "EYES",
353
+ heart: "HEART",
354
+ hooray: "HOORAY",
355
+ laugh: "LAUGH",
356
+ rocket: "ROCKET",
357
+ thumbsDown: "THUMBS_DOWN",
358
+ thumbsUp: "THUMBS_UP",
359
+ };
360
+
361
+ const markViewedMutation = `mutation MarkViewed($id: ID!, $path: String!) {
362
+ markFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
363
+ }`;
364
+
365
+ const unmarkViewedMutation = `mutation UnmarkViewed($id: ID!, $path: String!) {
366
+ unmarkFileAsViewed(input: { pullRequestId: $id, path: $path }) { clientMutationId }
367
+ }`;
368
+
369
+ /** Where a failure past the write is reported; nothing is logged without one. */
370
+ export interface EditLogContext extends Record<string, unknown> {
371
+ /** the thrown value itself, for a caller that serializes its stack */
372
+ error: unknown;
373
+ }
374
+
375
+ export interface EditLogger {
376
+ warn: (message: string, context: EditLogContext) => void;
377
+ error: (message: string, context: EditLogContext) => void;
378
+ }
379
+
380
+ export interface EditPullRequestOptions {
381
+ logger?: EditLogger;
382
+ /** called by `applyPullRequestEdit` once a viewed mark was written */
383
+ onFileViewed?: (path: string, viewed: boolean) => void;
384
+ }
385
+
386
+ interface RepoRef {
387
+ owner: string;
388
+ name: string;
389
+ }
390
+
391
+ /** GraphQL names a pull request by node id, which REST callers do not have. */
392
+ const lookupPullRequestId = async (
393
+ transport: GitHubTransport,
394
+ { owner, name, number }: RepoRef & { number: number },
395
+ ): Promise<string> => {
396
+ const lookup = await transport.graphql<{
397
+ repository: { pullRequest: { id: string } | null } | null;
398
+ }>(pullRequestIdQuery, { owner, name, number });
399
+ const id = lookup.data?.repository?.pullRequest?.id;
400
+ if (id === undefined) {
401
+ throw new Error(
402
+ `no pull request #${String(number)} in ${owner}/${name}: ${lookup.errors.join("; ") || "not found"}`,
403
+ );
404
+ }
405
+ return id;
406
+ };
407
+
408
+ const pullRequestHeadQuery = `query PullRequestHead($owner: String!, $name: String!, $number: Int!) {
409
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { headRefOid } }
410
+ }`;
411
+
412
+ /**
413
+ * GitHub takes a verdict on any commit of the pull request, so an approval
414
+ * sent on a head since replaced would approve code the reviewer never saw. A
415
+ * comment review stays possible: its comments are anchored on the commit they
416
+ * were written on.
417
+ */
418
+ const assertVerdictOnHead = async (
419
+ transport: GitHubTransport,
420
+ {
421
+ owner,
422
+ name,
423
+ number,
424
+ commitId,
425
+ }: RepoRef & { number: number; commitId: string },
426
+ ): Promise<void> => {
427
+ const lookup = await transport.graphql<{
428
+ repository: { pullRequest: { headRefOid: string } | null } | null;
429
+ }>(pullRequestHeadQuery, { owner, name, number });
430
+ const head = lookup.data?.repository?.pullRequest?.headRefOid;
431
+ if (head === undefined) {
432
+ throw new Error(
433
+ `no pull request #${String(number)} in ${owner}/${name}: ${lookup.errors.join("; ") || "not found"}`,
434
+ );
435
+ }
436
+ if (head !== commitId) {
437
+ throw new Error(
438
+ `the pull request moved on to ${head.slice(0, 7)} since ${commitId.slice(0, 7)}, the commit reviewed: only a comment review can be sent on it`,
439
+ );
440
+ }
441
+ };
442
+
443
+ const runMutation = async (
444
+ transport: GitHubTransport,
445
+ query: string,
446
+ variables: Record<string, unknown>,
447
+ what: string,
448
+ ): Promise<void> => {
449
+ const { errors } = await transport.graphql(query, variables);
450
+ if (errors.length > 0) {
451
+ throw new Error(`GitHub refused ${what}: ${errors.join("; ")}`);
452
+ }
453
+ };
454
+
455
+ /** REST has no way to change the draft state: a write through GraphQL, as threads, viewed marks and reactions are. */
456
+ const setDraft = async (
457
+ transport: GitHubTransport,
458
+ { owner, name, number, draft }: RepoRef & { number: number; draft: boolean },
459
+ ): Promise<void> => {
460
+ const id = await lookupPullRequestId(transport, { owner, name, number });
461
+ await runMutation(
462
+ transport,
463
+ draft ? convertToDraftMutation : readyForReviewMutation,
464
+ { id },
465
+ "the draft change",
466
+ );
467
+ };
468
+
469
+ const setViewed = async (
470
+ transport: GitHubTransport,
471
+ {
472
+ owner,
473
+ name,
474
+ number,
475
+ path,
476
+ viewed,
477
+ }: RepoRef & {
478
+ number: number;
479
+ path: string;
480
+ viewed: boolean;
481
+ },
482
+ ): Promise<void> => {
483
+ const id = await lookupPullRequestId(transport, { owner, name, number });
484
+ await runMutation(
485
+ transport,
486
+ viewed ? markViewedMutation : unmarkViewedMutation,
487
+ { id, path },
488
+ "the viewed mark",
489
+ );
490
+ };
491
+
492
+ /** GitHub's REST names the sides and lines of a comment in snake_case. */
493
+ const restComment = (comment: PullRequestDraftComment) => ({
494
+ path: comment.path,
495
+ body: comment.body,
496
+ line: comment.line,
497
+ side: comment.side,
498
+ ...(comment.startLine === null || comment.startSide === null
499
+ ? {}
500
+ : {
501
+ start_line: comment.startLine,
502
+ start_side: comment.startSide,
503
+ }),
504
+ });
505
+
506
+ const reviewEventNames: Record<PullRequestReviewEvent, string> = {
507
+ approve: "APPROVE",
508
+ comment: "COMMENT",
509
+ requestChanges: "REQUEST_CHANGES",
510
+ };
511
+
512
+ /** Past the commit: a refusal here would describe a commit that did not happen. */
513
+ const resolveCommittedThreads = async (
514
+ transport: GitHubTransport,
515
+ threadIds: string[],
516
+ logger: EditLogger | undefined,
517
+ ): Promise<string | null> => {
518
+ const failures: string[] = [];
519
+ for (const threadId of threadIds) {
520
+ try {
521
+ await runMutation(
522
+ transport,
523
+ resolveThreadMutation,
524
+ { threadId },
525
+ "resolving the thread",
526
+ );
527
+ } catch (error) {
528
+ logger?.warn("thread of a committed suggestion left unresolved", {
529
+ threadId,
530
+ error,
531
+ });
532
+ failures.push(error instanceof Error ? error.message : String(error));
533
+ }
534
+ }
535
+ if (failures.length === 0) return null;
536
+ return `The suggestions were committed, but ${String(failures.length)} thread${failures.length === 1 ? "" : "s"} stayed unresolved: ${failures.join("; ")}`;
537
+ };
538
+
539
+ /**
540
+ * Writes one change under the operator's `gh` login. Labels and reviewers are
541
+ * an addition and a removal; when the addition passes and the removal is
542
+ * refused, the error says so and the screen, reading the pull request again,
543
+ * shows the half that landed. Answers what failed past the write, which
544
+ * landed: see `EditPullRequestResponse.followUpError`.
545
+ */
546
+ export const editPullRequest = async (
547
+ transport: GitHubTransport,
548
+ { ownerRepo, number, edit }: EditPullRequestRequest,
549
+ options: EditPullRequestOptions = {},
550
+ ): Promise<string | null> => {
551
+ const split = splitOwnerRepo(ownerRepo);
552
+ if (split === null) throw new Error(`${ownerRepo} is not owner/repo`);
553
+ const repoPath = `repos/${encodeURIComponent(split.owner)}/${encodeURIComponent(split.name)}`;
554
+ const pullPath = `${repoPath}/pulls/${String(number)}`;
555
+ const issuePath = `${repoPath}/issues/${String(number)}`;
556
+
557
+ switch (edit.kind) {
558
+ case "title": {
559
+ await transport.rest({
560
+ method: "PATCH",
561
+ path: pullPath,
562
+ body: { title: edit.title },
563
+ });
564
+ return null;
565
+ }
566
+ case "labels": {
567
+ if (edit.add.length > 0) {
568
+ await transport.rest({
569
+ method: "POST",
570
+ path: `${issuePath}/labels`,
571
+ body: { labels: edit.add },
572
+ });
573
+ }
574
+ for (const label of edit.remove) {
575
+ await transport.rest({
576
+ method: "DELETE",
577
+ path: `${issuePath}/labels/${encodeURIComponent(label)}`,
578
+ });
579
+ }
580
+ return null;
581
+ }
582
+ case "reviewers": {
583
+ if (edit.add.length > 0 || edit.addTeams.length > 0) {
584
+ await transport.rest({
585
+ method: "POST",
586
+ path: `${pullPath}/requested_reviewers`,
587
+ body: { reviewers: edit.add, team_reviewers: edit.addTeams },
588
+ });
589
+ }
590
+ if (edit.remove.length > 0 || edit.removeTeams.length > 0) {
591
+ await transport.rest({
592
+ method: "DELETE",
593
+ path: `${pullPath}/requested_reviewers`,
594
+ body: { reviewers: edit.remove, team_reviewers: edit.removeTeams },
595
+ });
596
+ }
597
+ return null;
598
+ }
599
+ case "draft": {
600
+ await setDraft(transport, { ...split, number, draft: edit.draft });
601
+ return null;
602
+ }
603
+ case "body": {
604
+ await transport.rest({
605
+ method: "PATCH",
606
+ path: pullPath,
607
+ body: { body: edit.body },
608
+ });
609
+ return null;
610
+ }
611
+ case "assignees": {
612
+ if (edit.add.length > 0) {
613
+ await transport.rest({
614
+ method: "POST",
615
+ path: `${issuePath}/assignees`,
616
+ body: { assignees: edit.add },
617
+ });
618
+ }
619
+ if (edit.remove.length > 0) {
620
+ await transport.rest({
621
+ method: "DELETE",
622
+ path: `${issuePath}/assignees`,
623
+ body: { assignees: edit.remove },
624
+ });
625
+ }
626
+ return null;
627
+ }
628
+ case "state": {
629
+ await transport.rest({
630
+ method: "PATCH",
631
+ path: pullPath,
632
+ body: { state: edit.state },
633
+ });
634
+ return null;
635
+ }
636
+ case "merge": {
637
+ await transport.rest({
638
+ method: "PUT",
639
+ path: `${pullPath}/merge`,
640
+ body: {
641
+ merge_method: edit.method,
642
+ sha: edit.expectedHeadSha,
643
+ ...(edit.commitMessage === undefined
644
+ ? {}
645
+ : {
646
+ commit_title: edit.commitMessage.title,
647
+ commit_message: edit.commitMessage.body,
648
+ }),
649
+ },
650
+ });
651
+ return null;
652
+ }
653
+ case "updateBranch": {
654
+ await transport.rest({
655
+ method: "PUT",
656
+ path: `${pullPath}/update-branch`,
657
+ body: { expected_head_sha: edit.expectedHeadSha },
658
+ });
659
+ return null;
660
+ }
661
+ case "comment": {
662
+ await transport.rest({
663
+ method: "POST",
664
+ path: `${issuePath}/comments`,
665
+ body: { body: edit.body },
666
+ });
667
+ return null;
668
+ }
669
+ case "editComment": {
670
+ const id = String(edit.databaseId);
671
+ if (edit.target === "review") {
672
+ await transport.rest({
673
+ method: "PUT",
674
+ path: `${pullPath}/reviews/${id}`,
675
+ body: { body: edit.body },
676
+ });
677
+ return null;
678
+ }
679
+ await transport.rest({
680
+ method: "PATCH",
681
+ path:
682
+ edit.target === "issueComment"
683
+ ? `${repoPath}/issues/comments/${id}`
684
+ : `${repoPath}/pulls/comments/${id}`,
685
+ body: { body: edit.body },
686
+ });
687
+ return null;
688
+ }
689
+ case "reply": {
690
+ await transport.rest({
691
+ method: "POST",
692
+ path: `${pullPath}/comments/${String(edit.inReplyTo)}/replies`,
693
+ body: { body: edit.body },
694
+ });
695
+ return null;
696
+ }
697
+ case "reaction": {
698
+ await runMutation(
699
+ transport,
700
+ edit.reacted ? addReactionMutation : removeReactionMutation,
701
+ {
702
+ subjectId: edit.subjectId,
703
+ content: reactionContentNames[edit.content],
704
+ },
705
+ "the reaction",
706
+ );
707
+ return null;
708
+ }
709
+ case "resolveThread": {
710
+ await runMutation(
711
+ transport,
712
+ edit.resolved ? resolveThreadMutation : unresolveThreadMutation,
713
+ { threadId: edit.threadId },
714
+ "the thread change",
715
+ );
716
+ return null;
717
+ }
718
+ case "viewed": {
719
+ await setViewed(transport, {
720
+ ...split,
721
+ number,
722
+ path: edit.path,
723
+ viewed: edit.viewed,
724
+ });
725
+ return null;
726
+ }
727
+ case "review": {
728
+ if (edit.event !== "comment") {
729
+ await assertVerdictOnHead(transport, {
730
+ ...split,
731
+ number,
732
+ commitId: edit.commitId,
733
+ });
734
+ }
735
+ await transport.rest({
736
+ method: "POST",
737
+ path: `${pullPath}/reviews`,
738
+ body: {
739
+ commit_id: edit.commitId,
740
+ ...(edit.body === "" ? {} : { body: edit.body }),
741
+ event: reviewEventNames[edit.event],
742
+ comments: edit.comments.map(restComment),
743
+ },
744
+ });
745
+ return null;
746
+ }
747
+ case "applySuggestions": {
748
+ return resolveCommittedThreads(
749
+ transport,
750
+ await commitSuggestions(transport, { ownerRepo, number, edit }),
751
+ options.logger,
752
+ );
753
+ }
754
+ case "inlineComment": {
755
+ await transport.rest({
756
+ method: "POST",
757
+ path: `${pullPath}/comments`,
758
+ body: { commit_id: edit.commitId, ...restComment(edit.comment) },
759
+ });
760
+ return null;
761
+ }
762
+ default: {
763
+ throw new Error("unknown edit");
764
+ }
765
+ }
766
+ };
767
+
768
+ /**
769
+ * `editPullRequest`, then `onFileViewed` told of a viewed mark the edit is
770
+ * known to have changed, so a caller holding a copy of the last read can bring
771
+ * it up to date. Past GitHub's answer nothing fails the request: a callback
772
+ * that throws is logged and ignored.
773
+ */
774
+ export const applyPullRequestEdit = async (
775
+ transport: GitHubTransport,
776
+ request: EditPullRequestRequest,
777
+ options: EditPullRequestOptions = {},
778
+ ): Promise<string | null> => {
779
+ const followUpError = await editPullRequest(transport, request, options);
780
+ const { edit } = request;
781
+ if (edit.kind !== "viewed") return followUpError;
782
+ try {
783
+ options.onFileViewed?.(edit.path, edit.viewed);
784
+ } catch (error) {
785
+ options.logger?.error("viewed mark not applied to the last read", {
786
+ error,
787
+ });
788
+ }
789
+ return followUpError;
790
+ };