1import { SourceControlProviderKind } from "@t3tools/contracts";
2import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage";
3import * as Effect from "effect/Effect";
4import type {
5 PullRequestActor,
6 PullRequestCapabilities,
7 PullRequestCheck,
8 PullRequestReaction,
9 PullRequestViewerPermissions,
10} from "@t3tools/contracts";
11
12import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
13import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
14import {
15 PullRequestProviderError,
16 type PullRequestProviderFailure,
17 type ProviderChangeRequestActivity,
18 type ProviderChangeRequestDetail,
19 type PullRequestProviderApi,
20 type ProviderRepositoryRef,
21} from "@t3tools/source-control-core/server/PullRequestProvider";
22import type { GitHubViewerAccess, GitHubWorkflowRunApproval } from "./gitHubPullRequestJson.ts";
23import { definition } from "../client/definition.ts";
24
25const CAPABILITIES: PullRequestCapabilities = {
26 diff: true,
27 comment: true,
28 actions: [...definition.changeRequestActions],
29 mergeMethods: ["merge", "squash", "rebase"],
30 deleteBranch: true,
31 updateMethods: ["merge", "rebase"],
32 search: true,
33 reactions: true,
34 viewedFiles: "host",
35 review: {
36 inlineComment: true,
37 reply: true,
38 resolve: true,
39 verdicts: ["comment", "approve", "request-changes"],
40 },
41 reviewers: { request: true, listCandidates: true },
42 edit: { changeRequest: true, comment: true },
43 stacks: true,
44 stackActions: true,
45 labels: true,
46};
47
48/**
49 * What the signed-in account may do here, from the three things GitHub says about it.
50 *
51 * Merging needs a role that can push, which is the one thing a stranger on an open-source
52 * repository never has. The other four actions go by `viewerCanUpdate`, because the author of a
53 * pull request may close it, reopen it and move it in and out of draft with no more than read
54 * access on the repository it was opened against.
55 *
56 * Commenting and reviewing are not gated at all: read access is enough to say something and
57 * enough to approve or ask for changes, which is what open-source review consists of. Resolving a
58 * conversation is the exception — GitHub allows it to whoever can write, and to the author of the
59 * pull request the conversation is on.
60 *
61 * Asking somebody else for a review needs write access, which is the one thing here an author
62 * cannot do on their own pull request: GitHub shows an outside contributor the reviewer control
63 * and refuses the request behind it.
64 */
65export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions {
66 return {
67 ...(access.canWrite ? { stackRebase: true } : {}),
68 actions: [
69 ...(access.canDeleteBranch === true ? (["delete-branch"] as const) : []),
70 // Arming a merge and taking the arming back are the merge, deferred: whoever may not
71 // merge here may not leave an instruction to merge later either.
72 ...(access.canWrite
73 ? ([
74 "merge",
75 "enable-auto-merge",
76 "disable-auto-merge",
77 "revert",
78 "approve-workflows",
79 ] as const)
80 : []),
81 ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []),
82 // Whether this viewer may update the branch is GitHub's own answer, read with the
83 // comparison; without it the action is offered to nobody rather than to everybody.
84 ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []),
85 ],
86 comment: true,
87 resolve: access.canWrite || access.didAuthor,
88 // Anyone may review a pull request they can see, except their own: GitHub refuses an author's
89 // approval and their request for changes ("Can not approve your own pull request"), and
90 // leaves them commenting, which is what an author has to say about their own change anyway.
91 verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts,
92 requestReviewers: access.canWrite,
93 ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}),
94 // Triage is the one role that labels without writing, which is what triage is for.
95 labels: access.canTriage,
96 };
97}
98
99/** The tags that mean GitHub is out of reach for this account, rather than one request failing. */
100export function gitHubProviderFailure(
101 error: GitHubPullRequestApi.GitHubPullRequestApiError,
102): PullRequestProviderFailure {
103 switch (error._tag) {
104 case "GitHubCliMissingError":
105 return { reason: "missing-tool" };
106 case "GitHubNotSignedInError":
107 case "GitHubHostDisabledError":
108 case "GitHubApiAuthenticationError":
109 return { reason: "unauthenticated" };
110 case "GitHubApiRateLimitError":
111 return {
112 reason: "rate-limited",
113 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
114 };
115 case "SourceControlRateLimitPausedError":
116 return { reason: "rate-limited", retryAt: error.retryAt };
117 case "GitHubApiNotFoundError":
118 return { reason: "not-found" };
119 default:
120 return { reason: "failed" };
121 }
122}
123
124/**
125 * `gh pr view --json` reports no avatar for anyone, so the ones the GraphQL read collected are
126 * applied here by login. An actor already carrying one keeps it.
127 *
128 * A login GitHub did not answer for falls back to the picture every GitHub install serves at
129 * `/<login>.png`. The lookup is one more request per repository and can be refused — a rate
130 * limit, a slow host — and a face that comes and goes between two loads of the same page reads
131 * as a bug in the page rather than as a request that failed quietly.
132 */
133function withAvatar(
134 actor: PullRequestActor | null,
135 avatarsByLogin: ReadonlyMap<string, string>,
136 host: string,
137 botLogins?: ReadonlySet<string>,
138): PullRequestActor | null {
139 if (actor === null) return actor;
140 if (botLogins?.has(actor.login)) actor = { ...actor, isBot: true };
141 if (actor.avatarUrl !== null) return actor;
142 const avatarUrl = avatarsByLogin.get(actor.login) ?? loginAvatarUrl(actor.login, host);
143 return avatarUrl === null ? actor : { ...actor, avatarUrl };
144}
145
146function withWorkflowApprovals(
147 checks: ReadonlyArray<PullRequestCheck>,
148 runs: ReadonlyArray<GitHubWorkflowRunApproval>,
149 unavailable: boolean,
150): ReadonlyArray<PullRequestCheck> {
151 const representedRunIds = new Set<number>();
152 for (const check of checks) {
153 if (check.status !== "action-required" || check.url === null) continue;
154 const id = check.url.match(/\/actions\/runs\/(\d+)(?:\/|$)/)?.[1];
155 if (id !== undefined) representedRunIds.add(Number(id));
156 }
157 const approvalChecks = runs
158 .filter((run) => !representedRunIds.has(run.id))
159 .map((run): PullRequestCheck => ({
160 name: run.name,
161 status: "action-required",
162 description: "A maintainer must approve this workflow before it can run.",
163 url: run.url,
164 }));
165 return [
166 ...checks,
167 ...approvalChecks,
168 ...(unavailable
169 ? [
170 {
171 name: "Workflow approval status",
172 status: "action-required" as const,
173 description: "GitHub could not determine whether workflows are awaiting approval.",
174 url: null,
175 },
176 ]
177 : []),
178 ];
179}
180
181/**
182 * Null for anything that is not a plain user login: an app posts as `dependabot[bot]`, which
183 * names no page, and a guessed URL that 404s is worse than the initials it would replace.
184 */
185export function loginAvatarUrl(login: string, host: string): string | null {
186 return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null;
187}
188
189/** True where markdown would render nothing: whitespace, or only HTML comments. */
190const rendersEmpty = (body: string): boolean =>
191 body.replace(/<!--[\s\S]*?-->/g, "").trim().length === 0;
192
193export const make = Effect.gen(function* () {
194 const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi;
195
196 const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) =>
197 new PullRequestProviderError({
198 provider: SourceControlProviderKind.make("github"),
199 operation,
200 ...gitHubProviderFailure(error),
201 detail: error.message,
202 cause: error,
203 });
204
205 const readChecks = (input: ProviderRepositoryRef & { readonly number: number }) =>
206 cli.getPullRequestDetail(input).pipe(
207 Effect.flatMap((pullRequest) =>
208 (pullRequest.state !== "open" || pullRequest.isCrossRepository !== true
209 ? Effect.succeed({
210 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
211 unavailable: false,
212 })
213 : pullRequest.headSha == null || pullRequest.headRepositoryOwner == null
214 ? Effect.succeed({
215 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
216 unavailable: true,
217 })
218 : cli
219 .listWorkflowRunsRequiringApproval({
220 ...input,
221 headSha: pullRequest.headSha,
222 headBranch: pullRequest.headBranch,
223 headRepositoryOwner: pullRequest.headRepositoryOwner,
224 isCrossRepository: true,
225 })
226 .pipe(
227 Effect.matchEffect({
228 onFailure: (error) =>
229 error._tag === "GitHubApiRateLimitError" ||
230 error._tag === "SourceControlRateLimitPausedError"
231 ? Effect.fail(error)
232 : Effect.succeed({
233 runs: [] as ReadonlyArray<GitHubWorkflowRunApproval>,
234 unavailable: true,
235 }),
236 onSuccess: (runs) => Effect.succeed({ runs, unavailable: false }),
237 }),
238 )
239 ).pipe(
240 Effect.map((workflowApprovals) => ({
241 ...pullRequest,
242 author: withAvatar(pullRequest.author, new Map(), input.host),
243 checks: withWorkflowApprovals(
244 pullRequest.checks,
245 workflowApprovals.runs,
246 workflowApprovals.unavailable,
247 ),
248 ...(workflowApprovals.unavailable
249 ? {}
250 : { workflowApprovalsRequired: workflowApprovals.runs.length }),
251 })),
252 ),
253 ),
254 );
255
256 const provider: PullRequestProviderApi = {
257 kind: SourceControlProviderKind.make("github"),
258 capabilities: CAPABILITIES,
259 mergeMessageRewrite: removeAgentCredits,
260 getRoutingIdentity: (input) =>
261 cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))),
262 withVerifiedCredential: (input, use) =>
263 cli
264 .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result))
265 .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)),
266
267 getViewer: (input) =>
268 cli
269 .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" })
270 .pipe(Effect.mapError(fail("getViewer"))),
271
272 listChangeRequests: (input) =>
273 cli
274 .listPullRequests({
275 cwd: input.cwd,
276 repository: input.repository,
277 host: input.host,
278 state: input.state,
279 involvement: input.involvement,
280 viewer: input.viewer,
281 limit: input.limit,
282 query: input.query,
283 cursor: input.cursor,
284 filters: input.filters,
285 })
286 .pipe(
287 Effect.mapError(fail("listChangeRequests")),
288 Effect.flatMap((page) =>
289 cli
290 .listActorAvatars({
291 cwd: input.cwd,
292 repository: input.repository,
293 host: input.host,
294 ids: [...new Set(page.items.flatMap((item) => item.authorId ?? []))],
295 })
296 // A listing without faces is still a listing, so a failed lookup falls back to
297 // the initials rather than taking the rows down with it.
298 .pipe(
299 Effect.orElseSucceed(() => new Map<string, string>()),
300 Effect.map((avatarsByLogin) => ({
301 ...page,
302 items: page.items.map((item) => ({
303 ...item,
304 author: withAvatar(item.author, avatarsByLogin, input.host),
305 })),
306 })),
307 ),
308 ),
309 ),
310
311 /**
312 * The same listing for a whole host in one search. The avatar lookup the per-repository read
313 * needs is not here: a search reports an author's picture itself, so a face costs no request
314 * of its own — `withAvatar` still stands behind it for the login GitHub answered nothing for.
315 */
316 listChangeRequestsAcross: (input) =>
317 cli
318 .searchPullRequests({
319 cwd: input.cwd,
320 host: input.host,
321 repositories: input.repositories,
322 state: input.state,
323 involvement: input.involvement,
324 viewer: input.viewer,
325 limit: input.limit,
326 query: input.query,
327 cursor: input.cursor,
328 filters: input.filters,
329 })
330 .pipe(
331 Effect.mapError(fail("listChangeRequestsAcross")),
332 Effect.map((batch) => ({
333 truncated: batch.truncated,
334 items: batch.items.map((item) => ({
335 ...item,
336 author: withAvatar(item.author, new Map<string, string>(), input.host),
337 })),
338 })),
339 ),
340
341 listChangeRequestStats: (input) =>
342 cli
343 .listPullRequestStats({
344 cwd: input.cwd,
345 host: input.host,
346 changeRequests: input.changeRequests,
347 })
348 .pipe(Effect.mapError(fail("listChangeRequestStats"))),
349
350 getChangeRequestSummary: (input) =>
351 cli.getPullRequestSummary(input).pipe(
352 // `gh pr view` names the author without an avatar; the login-shaped URL every user
353 // has stands in, without the second request the listing spends on it.
354 Effect.map((summary) => ({
355 ...summary,
356 ...(summary.author === undefined
357 ? {}
358 : { author: withAvatar(summary.author, new Map(), input.host) }),
359 })),
360 Effect.mapError(fail("getChangeRequestSummary")),
361 ),
362
363 getChangeRequestStack: (input) =>
364 cli.getPullRequestStack(input).pipe(Effect.mapError(fail("getChangeRequestStack"))),
365
366 getChangeRequestWatchFingerprint: (input) =>
367 cli
368 .getPullRequestWatchFingerprint(input)
369 .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))),
370
371 getChangeRequestPreview: (input) =>
372 cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))),
373
374 getChangeRequestChecks: (input) =>
375 cli.revalidateChecks(input, readChecks(input)).pipe(
376 Effect.map(({ state, checks }) => ({ state, checks })),
377 Effect.mapError(fail("getChangeRequestChecks")),
378 ),
379
380 getChangeRequest: (input) =>
381 readChecks(input).pipe(
382 Effect.map((pullRequest): ProviderChangeRequestDetail => ({
383 ...pullRequest,
384 author: withAvatar(pullRequest.author, new Map<string, string>(), input.host),
385 reviewers: [
386 ...pullRequest.reviewRequestLogins.map((login) => ({
387 login,
388 name: null,
389 avatarUrl: null,
390 })),
391 ...pullRequest.reviewRequestTeams,
392 ],
393 mergeCapabilities: pullRequest.viewerAccess.mergeCapabilities,
394 viewerPermissions: gitHubViewerPermissions({
395 ...pullRequest.viewerAccess,
396 canUpdateBranch: pullRequest.comparison?.viewerCanUpdate === true,
397 }),
398 baseComparison:
399 pullRequest.comparison === null || pullRequest.comparison.behindBy === null
400 ? "unknown"
401 : pullRequest.comparison.behindBy > 0
402 ? "behind"
403 : "up-to-date",
404 ...(pullRequest.comparison?.behindBy == null
405 ? {}
406 : { behindBy: pullRequest.comparison.behindBy }),
407 })),
408 Effect.mapError(fail("getChangeRequest")),
409 ),
410
411 getChangeRequestActivity: (input) =>
412 Effect.all(
413 [
414 cli.getPullRequestActivity(input),
415 // Line comments live on review threads, which `gh pr view --json` cannot reach. A
416 // GraphQL hiccup degrades to a truncated conversation rather than blanking activity.
417 cli.listReviewThreadComments(input).pipe(
418 Effect.orElseSucceed(() => ({
419 comments: [],
420 dismissalsByReviewId: new Map<string, string>(),
421 reactions: [],
422 reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(),
423 editedAtById: new Map<string, string>(),
424 reviewThreads: [],
425 commentCount: 0,
426 truncated: true,
427 reviewThreadsTruncated: true,
428 reviewers: [],
429 avatarsByLogin: new Map<string, string>(),
430 botLogins: new Set<string>(),
431 commitStats: new Map<
432 string,
433 { readonly additions: number; readonly deletions: number }
434 >(),
435 commits: [],
436 viewer: { canUpdate: true, didAuthor: false },
437 })),
438 ),
439 ],
440 { concurrency: 2 },
441 ).pipe(
442 Effect.mapError(fail("getChangeRequestActivity")),
443 Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({
444 author: withAvatar(
445 pullRequest.author,
446 reviewThreads.avatarsByLogin,
447 input.host,
448 reviewThreads.botLogins,
449 ),
450 reviewers: reviewThreads.reviewers,
451 reactions: reviewThreads.reactions,
452 commits: (reviewThreads.commits.length > 0
453 ? reviewThreads.commits
454 : pullRequest.commits
455 ).map((commit) => ({
456 ...commit,
457 ...reviewThreads.commitStats.get(commit.oid),
458 authors: commit.authors?.map(
459 (author) =>
460 withAvatar(
461 author,
462 reviewThreads.avatarsByLogin,
463 input.host,
464 reviewThreads.botLogins,
465 ) ?? author,
466 ),
467 })),
468 comments: [...pullRequest.comments, ...reviewThreads.comments]
469 .map((comment) => ({
470 ...comment,
471 // GitHub keeps the dismissal reason on the timeline event, not on the review,
472 // so a dismissed review with nothing visible of its own reads its words from
473 // there. "Visible" and not "empty": bot reviews often carry only an HTML
474 // marker comment, which markdown renders as nothing.
475 body:
476 comment.kind === "review" &&
477 comment.reviewState?.toUpperCase() === "DISMISSED" &&
478 rendersEmpty(comment.body)
479 ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body)
480 : comment.body,
481 author: withAvatar(
482 comment.author,
483 reviewThreads.avatarsByLogin,
484 input.host,
485 reviewThreads.botLogins,
486 ),
487 // A comment out of `gh pr view --json` carries none of its own: that read
488 // reports no reaction at all, so they arrive from the GraphQL page by node id.
489 reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [],
490 editedAt: reviewThreads.editedAtById.get(comment.id) ?? comment.editedAt ?? null,
491 }))
492 .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)),
493 // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two
494 // are always whole and only the thread walk can stop short of the host.
495 commentCount: pullRequest.comments.length + reviewThreads.commentCount,
496 commentsTruncated: reviewThreads.truncated,
497 reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated,
498 reviewThreads: reviewThreads.reviewThreads.map((thread) => ({
499 ...thread,
500 comments: thread.comments.map((comment) => ({
501 ...comment,
502 author: withAvatar(
503 comment.author,
504 reviewThreads.avatarsByLogin,
505 input.host,
506 reviewThreads.botLogins,
507 ),
508 })),
509 })),
510 })),
511 ),
512
513 getReviewThreadComments: (input) =>
514 cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))),
515
516 getViewerPermissions: (input) => {
517 const lightAccess = cli
518 .getViewerAccess({ ...input, allowReserve: true })
519 .pipe(Effect.map((access) => gitHubViewerPermissions(access)));
520 if (input.includeUpdateBranch === false) {
521 return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions")));
522 }
523 // The core detail already carries the viewer's access, the merge settings, and the base
524 // comparison, so one read usually answers what used to take three. When that heavier read
525 // fails, the light access read still answers, withholding only update-branch.
526 return cli.getPullRequestDetail(input).pipe(
527 Effect.provideService(SourceControlRateLimit.Interactive, true),
528 Effect.map((pullRequest) =>
529 gitHubViewerPermissions({
530 ...pullRequest.viewerAccess,
531 canUpdateBranch:
532 pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true,
533 }),
534 ),
535 Effect.catchIf(
536 (error) =>
537 error._tag !== "GitHubApiRateLimitError" &&
538 error._tag !== "SourceControlRateLimitPausedError",
539 () => lightAccess,
540 ),
541 Effect.mapError(fail("getViewerPermissions")),
542 );
543 },
544
545 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
546
547 getDiffFileContents: (input) =>
548 cli.getPullRequestDiffFileContents(input).pipe(Effect.mapError(fail("getDiffFileContents"))),
549
550 getFilesViewed: (input) =>
551 cli.getPullRequestFilesViewed(input).pipe(Effect.mapError(fail("getFilesViewed"))),
552
553 setFilesViewed: (input) =>
554 cli.setPullRequestFilesViewed(input).pipe(Effect.mapError(fail("setFilesViewed"))),
555
556 listReviewerCandidates: (input) =>
557 cli.listReviewerCandidates(input).pipe(Effect.mapError(fail("listReviewerCandidates"))),
558
559 setReviewerRequest: (input) =>
560 cli
561 .setReviewerRequest({
562 cwd: input.cwd,
563 repository: input.repository,
564 host: input.host,
565 number: input.number,
566 reviewers: input.reviewers,
567 requested: input.requested,
568 })
569 .pipe(Effect.mapError(fail("setReviewerRequest"))),
570
571 listLabelCandidates: (input) =>
572 cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))),
573
574 setLabels: (input) =>
575 cli
576 .setLabels({
577 cwd: input.cwd,
578 repository: input.repository,
579 host: input.host,
580 number: input.number,
581 labels: input.labels,
582 applied: input.applied,
583 })
584 .pipe(Effect.mapError(fail("setLabels"))),
585
586 runAction: (input) =>
587 cli
588 .runPullRequestAction({
589 cwd: input.cwd,
590 repository: input.repository,
591 host: input.host,
592 number: input.number,
593 action: input.action,
594 ...(input.removeAgentCreditsOnMerge === true ? { removeAgentCreditsOnMerge: true } : {}),
595 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
596 ...(input.expectedStackHeads === undefined
597 ? {}
598 : { expectedStackHeads: input.expectedStackHeads }),
599 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
600 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
601 })
602 .pipe(Effect.mapError(fail("runAction"))),
603
604 updateChangeRequest: (input) =>
605 cli
606 .updatePullRequest({
607 cwd: input.cwd,
608 repository: input.repository,
609 host: input.host,
610 number: input.number,
611 ...(input.title === undefined ? {} : { title: input.title }),
612 ...(input.body === undefined ? {} : { body: input.body }),
613 })
614 .pipe(Effect.mapError(fail("updateChangeRequest"))),
615
616 comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))),
617
618 updateComment: (input) =>
619 cli
620 .updateComment({
621 cwd: input.cwd,
622 repository: input.repository,
623 host: input.host,
624 number: input.number,
625 commentId: input.commentId,
626 kind: input.kind,
627 body: input.body,
628 })
629 .pipe(Effect.mapError(fail("updateComment"))),
630
631 submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))),
632
633 replyToThread: (input) =>
634 cli
635 .replyToReviewThread({
636 cwd: input.cwd,
637 repository: input.repository,
638 host: input.host,
639 threadId: input.threadId,
640 body: input.body,
641 })
642 .pipe(Effect.mapError(fail("replyToThread"))),
643
644 setReaction: (input) =>
645 cli
646 .setReaction({
647 cwd: input.cwd,
648 repository: input.repository,
649 host: input.host,
650 number: input.number,
651 ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }),
652 content: input.content,
653 reacted: input.reacted,
654 })
655 .pipe(Effect.mapError(fail("setReaction"))),
656
657 setThreadResolution: (input) =>
658 cli
659 .setReviewThreadResolution({
660 cwd: input.cwd,
661 repository: input.repository,
662 host: input.host,
663 threadId: input.threadId,
664 resolved: input.resolved,
665 })
666 .pipe(Effect.mapError(fail("setThreadResolution"))),
667 };
668
669 return provider;
670});