1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Schema from "effect/Schema";
5import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
6import {
7 NonNegativeInt,
8 TrimmedNonEmptyString,
9 type PullRequestCapabilities,
10 type PullRequestStackHead,
11} from "@t3tools/contracts";
12import {
13 PullRequestProviderError,
14 type ProviderRepositoryRef,
15 type PullRequestProviderApi,
16} from "@t3tools/source-control-core/server/PullRequestProvider";
17
18import * as GitCafeApi from "./GitCafeApi.ts";
19import * as GitCafeHosts from "./gitCafeHosts.ts";
20import * as Json from "./gitCafePullRequestJson.ts";
21import { definition } from "../client/definition.ts";
22
23const CAPABILITIES: PullRequestCapabilities = {
24 diff: true,
25 comment: true,
26 actions: [...definition.changeRequestActions],
27 mergeMethods: ["merge", "squash", "rebase"],
28 // Only through a stack: GitCafe's one branch update restacks every layer above it.
29 updateMethods: ["rebase"],
30 search: true,
31 stacks: true,
32 stackActions: true,
33 reactions: true,
34 labels: false,
35 // Line comments go through GitCafe's review drafts, which arrive with revision fencing.
36 review: {
37 inlineComment: false,
38 reply: true,
39 resolve: true,
40 verdicts: ["approve", "request-changes", "comment"],
41 },
42 reviewers: { request: false, listCandidates: false },
43 edit: { changeRequest: true, comment: true },
44};
45
46const PAGE_SIZE = 100;
47/** Conversation pages read before the list is reported as truncated. */
48const MAX_PAGES = 10;
49/** GitCafe's cap on paths per `/diff-files` read, and so one diff page. */
50const DIFF_FILES_BATCH = 64;
51/**
52 * Line totals cost one hunk read per batch, so a pull request larger than this reports none
53 * rather than reading every file.
54 */
55const LINE_STATS_MAX_FILES = DIFF_FILES_BATCH * 4;
56/** Structural `/changes` pages, 500 files each, read to count a pull request's files. */
57const MAX_CHANGE_PAGES = 20;
58
59const chunk = <A>(items: ReadonlyArray<A>, size: number): Array<ReadonlyArray<A>> =>
60 Array.from({ length: Math.ceil(items.length / size) }, (_, index) =>
61 items.slice(index * size, (index + 1) * size),
62 );
63
64/** Files whose hunks GitCafe can return; binary and oversized ones are listed as they are. */
65const detailablePaths = (files: ReadonlyArray<Json.GitCafeDiffFile>) =>
66 files.filter((file) => !file.binary && !file.tooLarge).map((file) => file.path);
67
68/**
69 * A strategy GitCafe lists, the viewer may use, and no blocker names. A blocker without
70 * strategies (changes requested, a lock) still leaves Merge offered, so pressing it shows
71 * GitCafe's own reason.
72 */
73const mergeStrategyOpen = (status: typeof Status.Type, strategy: string) =>
74 status.merge.permitted !== false &&
75 status.merge.strategies.includes(strategy) &&
76 !(status.merge.blockers ?? []).some((blocker) =>
77 (blocker.blockedStrategies ?? []).includes(strategy),
78 );
79
80const Id = TrimmedNonEmptyString.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/u));
81const isId = Schema.is(Id);
82const Principal = Schema.Struct({ handle: TrimmedNonEmptyString });
83const Written = Schema.Struct({ id: Id });
84const Versioned = Schema.Struct({ version: NonNegativeInt });
85const CommentDetail = Schema.Struct({ id: Id, version: NonNegativeInt });
86const Status = Schema.Struct({
87 merge: Schema.Struct({
88 conflicts: Schema.Literals(["unknown", "conflicting"]),
89 fastForward: Schema.NullOr(Schema.Boolean),
90 strategies: Schema.Array(Schema.String),
91 permitted: Schema.optional(Schema.Boolean),
92 blockers: Schema.optional(
93 Schema.Array(
94 Schema.Struct({ blockedStrategies: Schema.optional(Schema.Array(Schema.String)) }),
95 ),
96 ),
97 }),
98 checks: Schema.Struct({
99 pending: NonNegativeInt,
100 failing: NonNegativeInt,
101 total: NonNegativeInt,
102 }),
103});
104const FilterOptions = Schema.Struct({
105 actors: Schema.Array(
106 Schema.Struct({ actorId: TrimmedNonEmptyString, handle: TrimmedNonEmptyString }),
107 ),
108});
109const encodeActorIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
110const BranchCommit = Schema.Struct({ oid: TrimmedNonEmptyString });
111const Snapshot = {
112 version: NonNegativeInt,
113 headOid: TrimmedNonEmptyString,
114 comparisonBaseOid: TrimmedNonEmptyString,
115};
116const Changes = Schema.Struct({
117 ...Snapshot,
118 items: Json.GitCafeDiffFiles,
119 next: Schema.NullOr(TrimmedNonEmptyString),
120});
121const DiffFiles = Schema.Struct({ ...Snapshot, items: Json.GitCafeDiffFiles });
122const DiffCursor = Schema.Struct({
123 version: NonNegativeInt,
124 headOid: TrimmedNonEmptyString,
125 baseOid: TrimmedNonEmptyString,
126 after: TrimmedNonEmptyString,
127});
128const encodeDiffCursor = Schema.encodeSync(Schema.fromJsonString(DiffCursor));
129const decodeDiffCursor = Schema.decodeEffect(Schema.fromJsonString(DiffCursor));
130const MergeOutcome = Schema.Struct({
131 id: Schema.String,
132 state: Schema.Literals(["accepted", "completed", "failed"]),
133 reason: Schema.optional(Schema.String),
134});
135const StackOutcome = Schema.Struct({ id: Schema.String, state: Schema.String });
136/** Where a stack operation stopped short; anything else is underway or done. */
137const FAILED_STACK_STATES = new Set(["failed", "stopped", "cancelled", "reconciliation_required"]);
138
139type PullRef = ProviderRepositoryRef & { readonly number: number };
140type Paged<A> = { readonly items: ReadonlyArray<A>; readonly next: string | null };
141
142/** The heads a reader saw are still every unmerged layer the action moves. */
143function stackHeadsCurrent(
144 stack: Json.GitCafeStack,
145 input: { readonly number: number; readonly merge: boolean },
146 expected: ReadonlyArray<PullRequestStackHead>,
147): boolean {
148 const members = stack.members.toSorted((a, b) => a.position - b.position);
149 const index = members.findIndex((member) => member.pullRequestNumber === input.number);
150 if (index === -1) return false;
151 const moved = (input.merge ? members.slice(0, index + 1) : members).filter(
152 (member) => member.state !== "merged",
153 );
154 return (
155 moved.length === expected.length &&
156 moved.every(
157 (member) =>
158 member.headOid !== undefined &&
159 expected.some(
160 (head) => head.number === member.pullRequestNumber && head.headSha === member.headOid,
161 ),
162 )
163 );
164}
165
166/** Lays the detailed files over the structural page, keeping its order and anything omitted. */
167const withDetails = (
168 files: ReadonlyArray<Json.GitCafeDiffFile>,
169 details: ReadonlyArray<Json.GitCafeDiffFile>,
170) => {
171 const byPath = new Map(details.map((file) => [file.path, file]));
172 return files.map((file) => byPath.get(file.path) ?? file);
173};
174
175/** @public Service construction is part of the canonical Effect module API. */
176export const make = Effect.gen(function* () {
177 const api = yield* GitCafeApi.GitCafeApi;
178
179 const failure = (operation: string, detail: string, cause?: unknown) =>
180 new PullRequestProviderError({
181 provider: SourceControlProviderKind.make("gitcafe"),
182 operation,
183 reason: "failed",
184 detail,
185 ...(cause === undefined ? {} : { cause }),
186 });
187
188 /** The repository's API root, refused before anything is sent for a host GitCafe is not on. */
189 const repositoryPath = (
190 operation: string,
191 input: ProviderRepositoryRef,
192 ): Effect.Effect<string, PullRequestProviderError> =>
193 GitCafeHosts.isGitCafeHost(input.host) &&
194 GitCafeHosts.gitCafeRepositoryPath(input.repository) === input.repository
195 ? Effect.succeed(`/repos/${input.repository}`)
196 : Effect.fail(
197 failure(
198 operation,
199 `GitCafe serves owner/name repositories on ${GitCafeHosts.GITCAFE_HOSTS.join(" and ")}.`,
200 ),
201 );
202
203 const send = (
204 operation: string,
205 input: Pick<ProviderRepositoryRef, "host">,
206 request: Omit<GitCafeApi.GitCafeApiRequest, "operation" | "host">,
207 ) =>
208 api.request({ ...request, host: input.host, operation }).pipe(
209 Effect.mapError(
210 (error) =>
211 new PullRequestProviderError({
212 provider: SourceControlProviderKind.make("gitcafe"),
213 operation,
214 reason: error.reason,
215 detail: error.detail,
216 cause: error,
217 }),
218 ),
219 );
220
221 const read = <S extends Schema.Codec<unknown, unknown, never, never>>(
222 operation: string,
223 input: Pick<ProviderRepositoryRef, "host">,
224 request: Omit<GitCafeApi.GitCafeApiRequest, "operation" | "host">,
225 schema: S,
226 ) =>
227 send(operation, input, request).pipe(
228 Effect.flatMap((raw) =>
229 Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
230 Effect.mapError((cause) =>
231 failure(operation, "GitCafe returned an unreadable response.", cause),
232 ),
233 ),
234 ),
235 );
236
237 const pullPath = (operation: string, input: PullRef) =>
238 repositoryPath(operation, input).pipe(Effect.map((base) => `${base}/pulls/${input.number}`));
239
240 const readPull = (operation: string, input: PullRef) =>
241 pullPath(operation, input).pipe(
242 Effect.flatMap((path) => read(operation, input, { path }, Json.GitCafePullDetail)),
243 );
244
245 /** Every page of a cursor list, up to `MAX_PAGES`; `next` stays set when more remained. */
246 const readPages = <S extends Schema.Codec<Paged<unknown>, unknown, never, never>>(
247 operation: string,
248 input: Pick<ProviderRepositoryRef, "host">,
249 path: string,
250 schema: S,
251 ) =>
252 Effect.gen(function* () {
253 const items: Array<S["Type"]["items"][number]> = [];
254 let next: string | null = null;
255 for (let page = 0; page < MAX_PAGES; page++) {
256 const query = new URLSearchParams({ limit: String(PAGE_SIZE) });
257 if (next !== null) query.set("after", next);
258 const batch: S["Type"] = yield* read(
259 operation,
260 input,
261 { path: `${path}?${query}` },
262 schema,
263 );
264 items.push(...batch.items);
265 const previous: string | null = next;
266 next = batch.next;
267 if (next === null || next === previous || batch.items.length === 0) break;
268 }
269 return { items, next };
270 });
271
272 const readComments = (operation: string, input: PullRef) =>
273 pullPath(operation, input).pipe(
274 Effect.flatMap((path) =>
275 readPages(operation, input, `${path}/comments`, Json.GitCafeComments),
276 ),
277 );
278
279 /**
280 * The stack a pull request belongs to, with each unmerged layer's head. GitCafe's stack lists no
281 * heads, and they are what a land or restack is fenced on, so each open layer's pull is read.
282 */
283 const readStack = Effect.fn("GitCafePullRequestProvider.readStack")(function* (
284 operation: string,
285 input: PullRef,
286 ) {
287 const path = yield* pullPath(operation, input);
288 const { stack } = yield* read(
289 operation,
290 input,
291 { path: `${path}/stack` },
292 Json.GitCafeStackEnvelope,
293 );
294 if (stack === null) return null;
295 const members = yield* Effect.forEach(
296 stack.members,
297 (member) =>
298 member.state === "merged"
299 ? Effect.succeed(member)
300 : readPull(operation, { ...input, number: member.pullRequestNumber }).pipe(
301 Effect.map((pull) =>
302 pull.headOid == null ? member : { ...member, headOid: pull.headOid },
303 ),
304 ),
305 { concurrency: 4 },
306 );
307 return { ...stack, members };
308 });
309
310 /** One `/diff-files` read, refused if GitCafe answers from another revision than the page. */
311 const readDiffFiles = Effect.fn("GitCafePullRequestProvider.readDiffFiles")(function* (
312 input: PullRef,
313 revision: { readonly version: number; readonly headOid: string; readonly baseOid: string },
314 paths: ReadonlyArray<string>,
315 ) {
316 if (paths.length === 0) return [];
317 const path = yield* pullPath("getDiff", input);
318 const files = yield* read(
319 "getDiff",
320 input,
321 {
322 method: "POST",
323 path: `${path}/diff-files`,
324 body: { paths, expectedVersion: revision.version },
325 },
326 DiffFiles,
327 );
328 if (
329 files.version !== revision.version ||
330 files.headOid !== revision.headOid ||
331 files.comparisonBaseOid !== revision.baseOid
332 )
333 return yield* failure(
334 "getDiff",
335 "GitCafe answered from a different revision of the pull request. Refresh the diff.",
336 );
337 return files.items;
338 });
339
340 const runStackAction = Effect.fn("GitCafePullRequestProvider.runStackAction")(function* (
341 input: Parameters<PullRequestProviderApi["runAction"]>[0] & { readonly stackNumber: number },
342 ) {
343 const land = input.action === "merge";
344 const operation = land ? "landStackThrough" : "restackStack";
345 if (!land && input.action !== "update-branch")
346 return yield* failure(operation, `GitCafe cannot ${input.action} a stack.`);
347 const stack = yield* readStack(operation, input);
348 if (
349 stack === null ||
350 stack.number !== input.stackNumber ||
351 input.expectedStackHeads === undefined ||
352 !stackHeadsCurrent(stack, { number: input.number, merge: land }, input.expectedStackHeads)
353 )
354 return yield* failure(
355 operation,
356 "The stack changed since it was read. Refresh it and try again.",
357 );
358 const base = yield* repositoryPath(operation, input);
359 const outcome = yield* read(
360 operation,
361 input,
362 {
363 method: "POST",
364 path: `${base}/pulls/stacks/${stack.number}/${land ? "land-through" : "restack"}`,
365 body: land
366 ? {
367 expectedRevision: stack.revision,
368 requestId: yield* randomUuidV4,
369 throughPullRequestNumber: input.number,
370 strategy: input.mergeMethod ?? "merge",
371 }
372 : { expectedRevision: stack.revision, requestId: yield* randomUuidV4 },
373 },
374 StackOutcome,
375 );
376 if (FAILED_STACK_STATES.has(outcome.state))
377 return yield* failure(operation, `GitCafe's stack operation ${outcome.state}.`);
378 });
379
380 /**
381 * Added and deleted line totals and the file count. `/changes` lists files without counting
382 * lines, so the counts come from batched hunk reads, and a pull request past the cap reports
383 * none rather than paying for every file.
384 */
385 const readLineStats = Effect.fn("GitCafePullRequestProvider.readLineStats")(function* (
386 input: PullRef,
387 version: number,
388 ) {
389 const path = yield* pullPath("getChangeRequest", input);
390 const files: Array<Json.GitCafeDiffFile> = [];
391 let revision: { version: number; headOid: string; baseOid: string } | undefined;
392 let after: string | null = null;
393 for (let page = 0; page < MAX_CHANGE_PAGES; page++) {
394 const query = new URLSearchParams({ expectedVersion: String(version), limit: "500" });
395 if (after !== null) query.set("after", after);
396 const batch: typeof Changes.Type = yield* read(
397 "getChangeRequest",
398 input,
399 { path: `${path}/changes?${query}` },
400 Changes,
401 );
402 revision ??= {
403 version: batch.version,
404 headOid: batch.headOid,
405 baseOid: batch.comparisonBaseOid,
406 };
407 files.push(...batch.items);
408 const previous: string | null = after;
409 after = batch.next;
410 if (after === null || after === previous || batch.items.length === 0) break;
411 }
412 // A hunk read that loses a race with a push leaves the counts at zero rather than dropping
413 // the file count with them.
414 const counted =
415 files.some((file) => file.additions !== undefined) ||
416 files.length > LINE_STATS_MAX_FILES ||
417 revision === undefined
418 ? files
419 : (yield* Effect.forEach(
420 chunk(detailablePaths(files), DIFF_FILES_BATCH),
421 (paths) => readDiffFiles(input, revision, paths),
422 { concurrency: 4 },
423 ).pipe(Effect.orElseSucceed(() => [[]]))).flat();
424 return {
425 changedFiles: files.length,
426 additions: counted.reduce((total, file) => total + (file.additions ?? 0), 0),
427 deletions: counted.reduce((total, file) => total + (file.deletions ?? 0), 0),
428 };
429 });
430
431 const merge = Effect.fn("GitCafePullRequestProvider.merge")(function* (
432 input: Parameters<PullRequestProviderApi["runAction"]>[0],
433 ) {
434 const pull = yield* readPull("merge", input);
435 if (pull.state === "merged") return;
436 if (pull.mergeRoute === "unsupported")
437 return yield* failure("merge", "This GitCafe pull request has no supported merge route.");
438 const base = yield* repositoryPath("merge", input);
439 // GitCafe observes the base only for merges it routes to the provider; a native merge is
440 // fenced on the target branch's current commit.
441 const baseOid =
442 pull.mergeRoute === "provider"
443 ? pull.observedBaseOid
444 : (yield* read(
445 "merge",
446 input,
447 {
448 path: `${base}/commit?ref=${encodeURIComponent(`refs/heads/${pull.targetBranch}`)}`,
449 },
450 BranchCommit,
451 )).oid;
452 if (pull.headOid === null || baseOid == null)
453 return yield* failure(
454 "merge",
455 "GitCafe has not reported the pull request's head and base yet. Refresh and try again.",
456 );
457 const path = yield* pullPath("merge", input);
458 const outcome = yield* read(
459 "merge",
460 input,
461 {
462 method: "POST",
463 path: `${path}/merge`,
464 body: {
465 requestId: yield* randomUuidV4,
466 expectedVersion: pull.version,
467 headOid: pull.headOid,
468 baseOid,
469 strategy: input.mergeMethod ?? "merge",
470 },
471 },
472 MergeOutcome,
473 );
474 // An accepted merge is GitCafe's to finish; the pull request's state reports when it has.
475 if (outcome.state === "failed")
476 return yield* failure("merge", outcome.reason ?? "GitCafe could not merge the pull request.");
477 });
478
479 const provider: PullRequestProviderApi = {
480 kind: SourceControlProviderKind.make("gitcafe"),
481 capabilities: CAPABILITIES,
482 getViewer: (input) =>
483 read(
484 "getViewer",
485 { host: input.host ?? "git.cafe" },
486 { path: "/auth/principal" },
487 Principal,
488 ).pipe(Effect.map((principal) => principal.handle)),
489 listChangeRequests: Effect.fn("GitCafePullRequestProvider.listChangeRequests")(
490 function* (input) {
491 const base = yield* repositoryPath("listChangeRequests", input);
492 const involved = input.involvement === "authored" || input.involvement === "reviewing";
493 let actorId: string | undefined;
494 if (involved) {
495 // A host narrows as far as it can: when the actor list can't be read, the listing comes
496 // back unnarrowed and the page's own involvement filter does the rest. A refused token
497 // or a rate limit still fails, since the service acts on those for the whole host.
498 const options = yield* read(
499 "listChangeRequests",
500 input,
501 { path: `${base}/filter-options` },
502 FilterOptions,
503 ).pipe(
504 Effect.map(Option.some),
505 Effect.catchTags({
506 PullRequestProviderError: (error) =>
507 error.reason === "unauthenticated" || error.reason === "rate-limited"
508 ? Effect.fail(error)
509 : Effect.succeed(Option.none()),
510 }),
511 );
512 if (Option.isSome(options)) {
513 actorId = options.value.actors.find(
514 (actor) => actor.handle.toLowerCase() === input.viewer.toLowerCase(),
515 )?.actorId;
516 // A viewer GitCafe knows no actor for has authored and been asked to review nothing.
517 if (actorId === undefined) return { items: [], truncated: false, continues: false };
518 }
519 }
520 const limit = Math.max(1, input.limit);
521 const items: Array<Json.GitCafePull> = [];
522 let next: string | null = null;
523 do {
524 const query = new URLSearchParams({
525 limit: String(Math.min(PAGE_SIZE, limit - items.length)),
526 sort: "newest",
527 });
528 if (input.state !== "all") query.set("state", input.state);
529 if (input.query?.trim()) query.set("q", input.query.trim());
530 if (actorId !== undefined)
531 query.set(
532 input.involvement === "reviewing" ? "reviewers" : "authors",
533 encodeActorIds([actorId]),
534 );
535 if (next !== null) query.set("after", next);
536 const page: typeof Json.GitCafePulls.Type = yield* read(
537 "listChangeRequests",
538 input,
539 { path: `${base}/pulls?${query}` },
540 Json.GitCafePulls,
541 );
542 items.push(...page.items);
543 next = page.items.length === 0 ? null : page.next;
544 } while (next !== null && items.length < limit);
545 return {
546 items: items.slice(0, limit).map((pull) => {
547 const item = Json.toChangeRequest(pull, input);
548 // GitCafe filtered by reviewer, so the viewer is one even where the pull omits it. An
549 // unnarrowed listing (no actor to filter by) says nothing about who was asked.
550 return input.involvement === "reviewing" && actorId !== undefined
551 ? {
552 ...item,
553 reviewRequestLogins: [...new Set([...item.reviewRequestLogins, input.viewer])],
554 }
555 : item;
556 }),
557 truncated: next !== null,
558 continues: false,
559 };
560 },
561 ),
562 getChangeRequestSummary: (input) =>
563 readPull("getChangeRequestSummary", input).pipe(
564 Effect.map((pull) => ({
565 ...Json.toChangeRequest(pull, input),
566 closedAt: pull.closedAt,
567 mergedAt: pull.mergedAt,
568 })),
569 ),
570 getChangeRequest: Effect.fn("GitCafePullRequestProvider.getChangeRequest")(function* (input) {
571 const pull = yield* readPull("getChangeRequest", input);
572 const path = yield* pullPath("getChangeRequest", input);
573 const base = yield* repositoryPath("getChangeRequest", input);
574 const [status, checks, changes] = yield* Effect.all(
575 [
576 read("getChangeRequest", input, { path: `${path}/status` }, Status),
577 pull.headOid === null
578 ? Effect.succeed(null)
579 : readPages(
580 "getChangeRequest",
581 input,
582 `${base}/commits/${encodeURIComponent(pull.headOid)}/checks`,
583 Json.GitCafeChecks,
584 ),
585 pull.headOid === null
586 ? Effect.succeed(null)
587 : readLineStats(input, pull.version).pipe(Effect.orElseSucceed(() => null)),
588 ],
589 { concurrency: 3 },
590 );
591 const permissions = Json.toViewerPermissions(pull);
592 return {
593 ...Json.toChangeRequest(pull, input),
594 headSha: pull.headOid,
595 body: pull.description ?? "",
596 ...(pull.sourceRepo === null
597 ? {}
598 : { headRepositoryNameWithOwner: `${pull.sourceRepo.owner}/${pull.sourceRepo.name}` }),
599 additions: changes?.additions ?? 0,
600 deletions: changes?.deletions ?? 0,
601 changedFiles: changes?.changedFiles ?? 0,
602 closedAt: pull.closedAt,
603 mergedAt: pull.mergedAt,
604 reviewers: Json.toReviewers(pull, input.host),
605 checks: checks === null ? [] : Json.toChecks(checks),
606 ...(status.checks.total === 0
607 ? {}
608 : {
609 checksState:
610 status.checks.failing > 0
611 ? ("failing" as const)
612 : status.checks.pending > 0
613 ? ("pending" as const)
614 : ("passing" as const),
615 }),
616 mergeability:
617 status.merge.conflicts === "conflicting"
618 ? ("conflicting" as const)
619 : ("unknown" as const),
620 baseComparison:
621 status.merge.fastForward === null
622 ? ("unknown" as const)
623 : status.merge.fastForward
624 ? ("up-to-date" as const)
625 : ("behind" as const),
626 mergeCapabilities: {
627 merge: mergeStrategyOpen(status, "merge"),
628 squash: mergeStrategyOpen(status, "squash"),
629 rebase: mergeStrategyOpen(status, "rebase"),
630 },
631 viewerPermissions:
632 status.merge.permitted === false
633 ? { ...permissions, actions: permissions.actions.filter((a) => a !== "merge") }
634 : permissions,
635 };
636 }),
637 getChangeRequestStack: (input) =>
638 readStack("getChangeRequestStack", input).pipe(
639 Effect.map((stack) => (stack === null ? null : Json.toStack(stack, input))),
640 ),
641 getChangeRequestActivity: Effect.fn("GitCafePullRequestProvider.getChangeRequestActivity")(
642 function* (input) {
643 const pull = yield* readPull("getChangeRequestActivity", input);
644 const path = yield* pullPath("getChangeRequestActivity", input);
645 const operation = "getChangeRequestActivity";
646 const [comments, reviews, commits, reactions] = yield* Effect.all(
647 [
648 readComments(operation, input),
649 readPages(operation, input, `${path}/reviews`, Json.GitCafeReviews),
650 pull.headOid === null
651 ? Effect.succeed({ items: [], next: null })
652 : readPages(operation, input, `${path}/commits`, Json.GitCafeCommits),
653 // Reactions decorate the conversation; a failed read leaves them out, not it.
654 readPages(operation, input, `${path}/reactions/`, Json.GitCafeReactions).pipe(
655 Effect.orElseSucceed(() => undefined),
656 ),
657 ],
658 { concurrency: 4 },
659 );
660 return Json.toActivity({
661 comments,
662 reviews,
663 commits: { ...commits, truncated: commits.next !== null, headOid: pull.headOid ?? "" },
664 host: input.host,
665 ...(pull.headOid === null ? {} : { headOid: pull.headOid }),
666 ...(reactions === undefined ? {} : { reactions }),
667 });
668 },
669 ),
670 getViewerPermissions: (input) =>
671 readPull("getViewerPermissions", input).pipe(Effect.map(Json.toViewerPermissions)),
672 getDiff: Effect.fn("GitCafePullRequestProvider.getDiff")(function* (input) {
673 if (input.commit !== undefined)
674 return yield* failure("getDiff", "GitCafe commit diffs are not supported yet.");
675 const cursor =
676 input.cursor === undefined
677 ? null
678 : yield* decodeDiffCursor(input.cursor).pipe(
679 Effect.mapError((cause) => failure("getDiff", "Invalid GitCafe diff cursor.", cause)),
680 );
681 const version = cursor?.version ?? (yield* readPull("getDiff", input)).version;
682 const path = yield* pullPath("getDiff", input);
683 const query = new URLSearchParams({
684 expectedVersion: String(version),
685 limit: String(DIFF_FILES_BATCH),
686 });
687 if (cursor !== null) query.set("after", cursor.after);
688 const page = yield* read("getDiff", input, { path: `${path}/changes?${query}` }, Changes);
689 if (
690 page.version !== version ||
691 (cursor !== null &&
692 (cursor.headOid !== page.headOid || cursor.baseOid !== page.comparisonBaseOid))
693 )
694 return yield* failure(
695 "getDiff",
696 "GitCafe answered from a different revision of the pull request. Refresh the diff.",
697 );
698 const revision = { version, headOid: page.headOid, baseOid: page.comparisonBaseOid };
699 const details = yield* readDiffFiles(input, revision, detailablePaths(page.items));
700 return {
701 ...Json.toDiff({ items: withDetails(page.items, details) }),
702 nextCursor: page.next === null ? null : encodeDiffCursor({ ...revision, after: page.next }),
703 };
704 }),
705 runAction: Effect.fn("GitCafePullRequestProvider.runAction")(function* (input) {
706 if (input.stackNumber !== undefined)
707 return yield* runStackAction({ ...input, stackNumber: input.stackNumber });
708 switch (input.action) {
709 case "merge":
710 return yield* merge(input);
711 case "ready":
712 case "draft":
713 case "close":
714 case "reopen": {
715 const pull = yield* readPull(input.action, input);
716 const path = yield* pullPath(input.action, input);
717 yield* send(input.action, input, {
718 method: "POST",
719 path: `${path}/${input.action}`,
720 body: { expectedVersion: pull.version },
721 });
722 return;
723 }
724 default:
725 return yield* failure(input.action, `GitCafe cannot ${input.action} from T3 Code.`);
726 }
727 }),
728 updateChangeRequest: Effect.fn("GitCafePullRequestProvider.updateChangeRequest")(
729 function* (input) {
730 const pull = yield* readPull("updateChangeRequest", input);
731 const path = yield* pullPath("updateChangeRequest", input);
732 yield* read(
733 "updateChangeRequest",
734 input,
735 {
736 method: "PATCH",
737 path,
738 body: {
739 expectedVersion: pull.version,
740 ...(input.title === undefined ? {} : { title: input.title }),
741 ...(input.body === undefined ? {} : { description: input.body }),
742 },
743 },
744 Versioned,
745 );
746 },
747 ),
748 comment: (input) =>
749 pullPath("comment", input).pipe(
750 Effect.flatMap((path) =>
751 read(
752 "comment",
753 input,
754 { method: "POST", path: `${path}/comments/`, body: { body: input.body } },
755 Written,
756 ),
757 ),
758 Effect.asVoid,
759 ),
760 updateComment: Effect.fn("GitCafePullRequestProvider.updateComment")(function* (input) {
761 if (!isId(input.commentId))
762 return yield* failure("updateComment", "GitCafe comment ids are opaque slugs.");
763 const path = `${yield* pullPath("updateComment", input)}/comments/${input.commentId}`;
764 const detail = yield* read("updateComment", input, { path: `${path}/detail` }, CommentDetail);
765 yield* read(
766 "updateComment",
767 input,
768 { method: "PATCH", path, body: { body: input.body, expectedVersion: detail.version } },
769 Written,
770 );
771 }),
772 submitReview: Effect.fn("GitCafePullRequestProvider.submitReview")(function* (input) {
773 if (input.comments.length > 0)
774 return yield* failure("submitReview", "GitCafe line comments are not supported yet.");
775 const pull = yield* readPull("submitReview", input);
776 if (pull.headOid === null)
777 return yield* failure("submitReview", "GitCafe has not reported the pull request's head.");
778 const path = yield* pullPath("submitReview", input);
779 yield* read(
780 "submitReview",
781 input,
782 {
783 method: "POST",
784 path: `${path}/reviews`,
785 body: {
786 verdict: input.verdict === "request-changes" ? "request_changes" : input.verdict,
787 ...(input.body.length === 0 ? {} : { body: input.body }),
788 commitOid: pull.headOid,
789 expectedVersion: pull.version,
790 requestId: yield* randomUuidV4,
791 },
792 },
793 Written,
794 );
795 }),
796 listReviewerCandidates: () =>
797 Effect.fail(
798 failure("listReviewerCandidates", "GitCafe reviewer requests are not supported."),
799 ),
800 setReviewerRequest: () =>
801 Effect.fail(failure("setReviewerRequest", "GitCafe reviewer requests are not supported.")),
802 replyToThread: Effect.fn("GitCafePullRequestProvider.replyToThread")(function* (input) {
803 if (!isId(input.threadId))
804 return yield* failure("replyToThread", "GitCafe thread ids are opaque slugs.");
805 const path = yield* pullPath("replyToThread", input);
806 yield* read(
807 "replyToThread",
808 input,
809 {
810 method: "POST",
811 path: `${path}/comments/${input.threadId}/replies`,
812 body: { body: input.body },
813 },
814 Written,
815 );
816 }),
817 setReaction: Effect.fn("GitCafePullRequestProvider.setReaction")(function* (input) {
818 const path = `${yield* pullPath("setReaction", input)}/reactions/`;
819 const subject =
820 input.subjectId === undefined
821 ? { kind: "pull_request" as const, id: (yield* readPull("setReaction", input)).id }
822 : { kind: "pull_request_comment" as const, id: input.subjectId };
823 const reactions = yield* readPages("setReaction", input, path, Json.GitCafeReactions);
824 const mine = reactions.items.find(
825 (item) =>
826 item.subject.kind === subject.kind &&
827 item.subject.id === subject.id &&
828 item.emoji.kind === "unicode" &&
829 Json.reactionContent(item.emoji.value) === input.content,
830 )?.viewerReactionId;
831 if (input.reacted === (mine != null)) return;
832 if (input.reacted) {
833 yield* send("setReaction", input, {
834 method: "POST",
835 path,
836 body: { subject, emoji: { kind: "unicode", value: Json.REACTION_EMOJI[input.content] } },
837 });
838 return;
839 }
840 if (mine == null) return;
841 if (!isId(mine))
842 return yield* failure("setReaction", "GitCafe returned an unreadable reaction id.");
843 yield* send("setReaction", input, { method: "DELETE", path: `${path}${mine}` });
844 }),
845 setThreadResolution: Effect.fn("GitCafePullRequestProvider.setThreadResolution")(
846 function* (input) {
847 const comments = yield* readComments("setThreadResolution", input);
848 const root = comments.items.find(
849 (comment) => comment.id === input.threadId && comment.threadId === input.threadId,
850 );
851 if (root === undefined || root.version === undefined || !isId(root.id))
852 return yield* failure("setThreadResolution", "GitCafe did not return the thread root.");
853 if (input.resolved === (root.resolvedAt !== null)) return;
854 const path = yield* pullPath("setThreadResolution", input);
855 yield* read(
856 "setThreadResolution",
857 input,
858 {
859 method: "POST",
860 path: `${path}/comments/${root.id}/${input.resolved ? "resolve" : "unresolve"}`,
861 body: { expectedVersion: root.version },
862 },
863 Written,
864 );
865 },
866 ),
867 };
868 return provider;
869});