1/**
2 * GitCafe's REST bodies, and their conversion into the neutral `Provider*` shapes. Every
3 * conversion takes the repository's host, so a staging repository's links stay on staging.
4 *
5 * @module source-control-gitcafe/server/gitCafePullRequestJson
6 */
7import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
8import * as DateTime from "effect/DateTime";
9import * as Schema from "effect/Schema";
10import {
11 IsoDateTime,
12 NonNegativeInt,
13 PositiveInt,
14 TrimmedNonEmptyString,
15 type PullRequestActor,
16 type PullRequestCheck,
17 type PullRequestComment,
18 type PullRequestReaction,
19 type PullRequestReactionContent,
20 type PullRequestReviewThread,
21 type PullRequestViewerPermissions,
22} from "@t3tools/contracts";
23import type {
24 ProviderChangeRequest,
25 ProviderChangeRequestActivity,
26 ProviderChangeRequestStack,
27 ProviderDiffSlice,
28} from "@t3tools/source-control-core/server/PullRequestProvider";
29
30const Actor = Schema.Union([
31 Schema.Struct({
32 kind: Schema.Literal("local"),
33 actorId: Schema.String,
34 handle: TrimmedNonEmptyString,
35 displayName: Schema.NullOr(Schema.String),
36 avatarUrl: Schema.NullOr(Schema.String),
37 }),
38 Schema.Struct({
39 kind: Schema.Literal("github"),
40 actorId: Schema.String,
41 login: TrimmedNonEmptyString,
42 avatarUrl: Schema.NullOr(Schema.String),
43 linkedProfile: Schema.optional(Schema.NullOr(Schema.Struct({ handle: TrimmedNonEmptyString }))),
44 }),
45 Schema.Struct({ kind: Schema.Literal("unavailable"), actorId: Schema.String }),
46]);
47type Actor = typeof Actor.Type;
48
49const PullState = Schema.Literals(["open", "closed", "merged"]);
50
51export const GitCafePull = Schema.Struct({
52 id: TrimmedNonEmptyString,
53 number: PositiveInt,
54 title: TrimmedNonEmptyString,
55 state: PullState,
56 draft: Schema.Boolean,
57 sourceBranch: Schema.String,
58 targetBranch: Schema.String,
59 headOid: Schema.NullOr(Schema.String),
60 author: Actor,
61 createdAt: IsoDateTime,
62 updatedAt: IsoDateTime,
63 version: NonNegativeInt,
64 reviewers: Schema.optional(Schema.Array(Schema.Struct({ actor: Actor }))),
65 labels: Schema.optional(
66 Schema.Array(
67 Schema.Struct({ name: TrimmedNonEmptyString, color: Schema.NullOr(Schema.String) }),
68 ),
69 ),
70});
71export type GitCafePull = typeof GitCafePull.Type;
72
73export const GitCafePulls = Schema.Struct({
74 items: Schema.Array(GitCafePull),
75 next: Schema.NullOr(Schema.String),
76});
77
78export const GitCafePullDetail = Schema.Struct({
79 ...GitCafePull.fields,
80 description: Schema.NullOr(Schema.String),
81 closedAt: Schema.NullOr(IsoDateTime),
82 mergedAt: Schema.NullOr(IsoDateTime),
83 sourceRepo: Schema.NullOr(Schema.Struct({ owner: Schema.String, name: Schema.String })),
84 /** The base GitCafe last compared the pull request against, which a provider merge fences on. */
85 observedBaseOid: Schema.optional(Schema.NullOr(Schema.String)),
86 mergeRoute: Schema.optional(Schema.Literals(["native", "provider", "unsupported"])),
87 lockedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
88 capabilities: Schema.Struct({
89 comment: Schema.Boolean,
90 review: Schema.Boolean,
91 merge: Schema.Boolean,
92 edit: Schema.Boolean,
93 moderate: Schema.Boolean,
94 }),
95});
96export type GitCafePullDetail = typeof GitCafePullDetail.Type;
97
98const CommentCapabilities = Schema.Struct({
99 edit: Schema.Boolean,
100 hide: Schema.Boolean,
101 unhide: Schema.Boolean,
102 delete: Schema.Boolean,
103 resolve: Schema.Boolean,
104 unresolve: Schema.Boolean,
105});
106
107const GitCafeComment = Schema.Struct({
108 id: TrimmedNonEmptyString,
109 threadId: TrimmedNonEmptyString,
110 /** What a resolution or rewrite fences on; absent from older answers. */
111 version: Schema.optional(NonNegativeInt),
112 author: Actor,
113 body: Schema.NullOr(Schema.String),
114 createdAt: IsoDateTime,
115 path: Schema.NullOr(Schema.String),
116 // The API allows any integer here; only a positive line anchors a review thread.
117 line: Schema.NullOr(Schema.Int),
118 side: Schema.NullOr(Schema.Literals(["left", "right"])),
119 commitOid: Schema.NullOr(Schema.String),
120 resolvedAt: Schema.NullOr(IsoDateTime),
121 capabilities: Schema.optional(CommentCapabilities),
122});
123type GitCafeComment = typeof GitCafeComment.Type;
124
125export const GitCafeComments = Schema.Struct({
126 items: Schema.Array(GitCafeComment),
127 next: Schema.NullOr(Schema.String),
128});
129
130export const GitCafeReviews = Schema.Struct({
131 items: Schema.Array(
132 Schema.Struct({
133 id: TrimmedNonEmptyString,
134 author: Actor,
135 body: Schema.NullOr(Schema.String),
136 verdict: Schema.Literals(["approve", "request_changes", "comment"]),
137 dismissedAt: Schema.NullOr(IsoDateTime),
138 createdAt: IsoDateTime,
139 }),
140 ),
141 next: Schema.NullOr(Schema.String),
142});
143
144export const GitCafeCommits = Schema.Struct({
145 items: Schema.Array(
146 Schema.Struct({ oid: TrimmedNonEmptyString, summary: Schema.String, time: Schema.Finite }),
147 ),
148 truncated: Schema.Boolean,
149 next: Schema.NullOr(Schema.String),
150 headOid: Schema.String,
151});
152
153export const GitCafeReactions = Schema.Struct({
154 items: Schema.Array(
155 Schema.Struct({
156 subject: Schema.Struct({
157 kind: Schema.Literals(["issue", "issue_comment", "pull_request", "pull_request_comment"]),
158 id: TrimmedNonEmptyString,
159 }),
160 emoji: Schema.Union([
161 Schema.Struct({ kind: Schema.Literal("unicode"), value: TrimmedNonEmptyString }),
162 Schema.Struct({ kind: Schema.Literal("custom"), id: TrimmedNonEmptyString }),
163 ]),
164 count: PositiveInt,
165 viewerReactionId: Schema.NullOr(Schema.String),
166 reactors: Schema.Array(Actor),
167 }),
168 ),
169 next: Schema.NullOr(Schema.String),
170});
171
172const GitCafeStack = Schema.Struct({
173 id: TrimmedNonEmptyString,
174 number: PositiveInt,
175 /** What a land or restack fences on. */
176 revision: PositiveInt,
177 landingBase: Schema.String,
178 members: Schema.Array(
179 Schema.Struct({
180 pullRequestNumber: PositiveInt,
181 title: Schema.String,
182 state: PullState,
183 draft: Schema.Boolean,
184 sourceBranch: Schema.String,
185 headOid: Schema.optional(Schema.String),
186 position: PositiveInt,
187 }),
188 ),
189});
190export type GitCafeStack = typeof GitCafeStack.Type;
191
192export const GitCafeStackEnvelope = Schema.Struct({ stack: Schema.NullOr(GitCafeStack) });
193
194const GitCafeDiffFile = Schema.Struct({
195 path: TrimmedNonEmptyString,
196 oldPath: Schema.optional(Schema.NullOr(Schema.String)),
197 status: Schema.optional(
198 Schema.Literals([
199 "added",
200 "deleted",
201 "modified",
202 "typeChanged",
203 "conflicted",
204 "renamed",
205 "copied",
206 ]),
207 ),
208 additions: Schema.optional(NonNegativeInt),
209 deletions: Schema.optional(NonNegativeInt),
210 binary: Schema.optional(Schema.Boolean),
211 tooLarge: Schema.optional(Schema.Boolean),
212 hunksOmitted: Schema.optional(Schema.Boolean),
213 isSubmodule: Schema.optional(Schema.Boolean),
214 oldOid: Schema.optional(Schema.String),
215 newOid: Schema.optional(Schema.String),
216 // Structural pages (`/changes`, `/compare`) list files without hunks; the batched reads fill them.
217 hunks: Schema.optional(
218 Schema.Array(
219 Schema.Struct({
220 oldStart: NonNegativeInt,
221 oldLines: NonNegativeInt,
222 newStart: NonNegativeInt,
223 newLines: NonNegativeInt,
224 lines: Schema.Array(Schema.Struct({ origin: Schema.String, content: Schema.String })),
225 }),
226 ),
227 ),
228});
229export type GitCafeDiffFile = typeof GitCafeDiffFile.Type;
230export const GitCafeDiffFiles = Schema.Array(GitCafeDiffFile);
231
232export const GitCafeDiff = Schema.Struct({
233 items: GitCafeDiffFiles,
234 truncated: Schema.optional(Schema.Boolean),
235});
236
237export const GitCafeChecks = Schema.Struct({
238 items: Schema.Array(
239 Schema.Struct({
240 name: TrimmedNonEmptyString,
241 status: Schema.Literals(["queued", "in_progress", "completed"]),
242 conclusion: Schema.NullOr(Schema.String),
243 summary: Schema.NullOr(Schema.String),
244 detailsUrl: Schema.NullOr(Schema.String),
245 }),
246 ),
247 next: Schema.NullOr(Schema.String),
248});
249
250interface RepositoryOnHost {
251 readonly repository: string;
252 readonly host: string;
253}
254
255function toActor(actor: Actor, host: string): PullRequestActor | null {
256 if (actor.kind === "unavailable") return null;
257 return {
258 login: actor.kind === "local" ? actor.handle : actor.login,
259 name: actor.kind === "local" ? actor.displayName : null,
260 // GitCafe serves its own avatars by path, so a relative one is read against the host.
261 avatarUrl: actor.avatarUrl === null ? null : new URL(actor.avatarUrl, `https://${host}`).href,
262 };
263}
264
265/** The reviewers GitCafe has asked, as the detail's reviewer list shows them. */
266export function toReviewers(
267 pull: Pick<GitCafePull, "reviewers">,
268 host: string,
269): ReadonlyArray<PullRequestActor> {
270 return (pull.reviewers ?? []).flatMap(({ actor }) => {
271 const reviewer = toActor(actor, host);
272 return reviewer === null ? [] : [reviewer];
273 });
274}
275
276/** A linked GitHub reviewer answers to both logins, so either one finds the review request. */
277function reviewerLogins(actor: Actor, host: string): ReadonlyArray<string> {
278 const reviewer = toActor(actor, host);
279 if (reviewer === null) return [];
280 return actor.kind === "github" && actor.linkedProfile
281 ? [reviewer.login, actor.linkedProfile.handle]
282 : [reviewer.login];
283}
284
285export function toChangeRequest(
286 pull: GitCafePull,
287 target: RepositoryOnHost,
288): ProviderChangeRequest {
289 return {
290 number: pull.number,
291 title: pull.title,
292 url: `https://${target.host}/${target.repository}/pulls/${pull.number}`,
293 author: toActor(pull.author, target.host),
294 headBranch: pull.sourceBranch,
295 baseBranch: pull.targetBranch,
296 state: pull.state,
297 isDraft: pull.draft,
298 mergeability: "unknown",
299 additions: 0,
300 deletions: 0,
301 createdAt: pull.createdAt,
302 updatedAt: pull.updatedAt,
303 reviewRequestLogins: (pull.reviewers ?? []).flatMap(({ actor }) =>
304 reviewerLogins(actor, target.host),
305 ),
306 labels: pull.labels ?? [],
307 };
308}
309
310/** What GitCafe's own capabilities let this viewer do with the pull request. */
311export function toViewerPermissions(detail: GitCafePullDetail): PullRequestViewerPermissions {
312 const { comment, edit, review, merge, moderate } = detail.capabilities;
313 // A locked conversation keeps its composer for moderators only.
314 const open = comment && (detail.lockedAt == null || moderate);
315 return {
316 actions: [
317 // A merged pull request is settled; a closed one can only be reopened.
318 ...(edit && detail.state === "open"
319 ? ([detail.draft ? "ready" : "draft", "close"] as const)
320 : edit && detail.state === "closed"
321 ? (["reopen"] as const)
322 : []),
323 ...(merge && detail.state === "open" && !detail.draft ? (["merge"] as const) : []),
324 ],
325 stackRebase: merge,
326 comment: open,
327 // Each thread's root narrows this further with its own `canResolve`.
328 resolve: open,
329 verdicts: review ? ["approve", "request-changes", "comment"] : [],
330 requestReviewers: false,
331 editChangeRequest: edit,
332 // GitCafe's only branch update moves a whole stack.
333 updateMethods: [],
334 };
335}
336
337const REACTIONS: Readonly<Record<string, PullRequestReactionContent>> = {
338 "👍": "thumbs-up",
339 "👎": "thumbs-down",
340 "😄": "laugh",
341 "🎉": "hooray",
342 "😕": "confused",
343 "❤": "heart",
344 "🚀": "rocket",
345 "👀": "eyes",
346};
347
348/** The emoji GitCafe takes for one of the shared reactions. */
349export const REACTION_EMOJI: Readonly<Record<PullRequestReactionContent, string>> = {
350 "thumbs-up": "👍",
351 "thumbs-down": "👎",
352 laugh: "😄",
353 hooray: "🎉",
354 confused: "😕",
355 heart: "❤️",
356 rocket: "🚀",
357 eyes: "👀",
358};
359
360/** The shared reaction an emoji stands for; custom and unlisted ones stand for none. */
361export function reactionContent(emoji: string): PullRequestReactionContent | undefined {
362 return REACTIONS[emoji.replaceAll("\uFE0F", "")];
363}
364
365function toReactions(data: typeof GitCafeReactions.Type, host: string) {
366 const reactions: Array<PullRequestReaction> = [];
367 const byComment = new Map<string, Array<PullRequestReaction>>();
368 for (const item of data.items) {
369 const content = item.emoji.kind === "unicode" ? reactionContent(item.emoji.value) : undefined;
370 if (content === undefined) continue;
371 const reaction: PullRequestReaction = {
372 content,
373 count: item.count,
374 actors: item.reactors.flatMap((actor) => {
375 const reactor = toActor(actor, host);
376 return reactor === null ? [] : [reactor.login];
377 }),
378 viewerHasReacted: item.viewerReactionId !== null,
379 };
380 if (item.subject.kind === "pull_request") reactions.push(reaction);
381 else if (item.subject.kind === "pull_request_comment")
382 byComment.set(item.subject.id, [...(byComment.get(item.subject.id) ?? []), reaction]);
383 }
384 return { reactions, byComment };
385}
386
387const REVIEW_STATES = {
388 approve: "approved",
389 request_changes: "changes_requested",
390 comment: "commented",
391} as const;
392
393/** GitCafe persists a thread's identity apart from its line, so threads group by `threadId`. */
394export function toActivity(input: {
395 readonly comments: typeof GitCafeComments.Type;
396 readonly reviews: typeof GitCafeReviews.Type;
397 readonly commits: typeof GitCafeCommits.Type;
398 readonly reactions?: typeof GitCafeReactions.Type;
399 readonly headOid?: string;
400 readonly host: string;
401}): ProviderChangeRequestActivity {
402 const { host } = input;
403 const reactions = input.reactions === undefined ? undefined : toReactions(input.reactions, host);
404 const remark = (comment: GitCafeComment) => ({
405 id: comment.id,
406 author: toActor(comment.author, host),
407 body: comment.body ?? "",
408 createdAt: comment.createdAt,
409 url: null,
410 ...(comment.capabilities === undefined ? {} : { canEdit: comment.capabilities.edit }),
411 ...(reactions === undefined ? {} : { reactions: reactions.byComment.get(comment.id) ?? [] }),
412 });
413 // Stable, so remarks written in the same instant keep GitCafe's own order.
414 const ordered = input.comments.items.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
415 const groups = new Map<string, Array<GitCafeComment>>();
416 for (const comment of ordered) {
417 if (!comment.path || comment.line === null || comment.line < 1 || comment.side === null)
418 continue;
419 groups.set(comment.threadId, [...(groups.get(comment.threadId) ?? []), comment]);
420 }
421 const reviewThreads = [...groups.values()].map((group): PullRequestReviewThread => {
422 const first = group[0]!;
423 const latest = group.at(-1)!;
424 const root = group.find((comment) => comment.id === comment.threadId);
425 return {
426 id: first.threadId,
427 path: first.path!,
428 line: first.line,
429 side: first.side!,
430 isResolved: root?.resolvedAt != null,
431 isOutdated:
432 input.headOid !== undefined &&
433 latest.commitOid !== null &&
434 latest.commitOid !== input.headOid,
435 comments: group.map(remark),
436 ...(root?.capabilities === undefined
437 ? {}
438 : {
439 canResolve:
440 root.resolvedAt === null ? root.capabilities.resolve : root.capabilities.unresolve,
441 }),
442 };
443 });
444 const comments: Array<PullRequestComment> = [
445 ...ordered.map((comment) => ({
446 ...remark(comment),
447 kind: comment.path === null ? ("issue-comment" as const) : ("review-comment" as const),
448 path: comment.path,
449 reviewState: null,
450 })),
451 ...input.reviews.items.map((review) => ({
452 id: review.id,
453 kind: "review" as const,
454 author: toActor(review.author, host),
455 body: review.body ?? "",
456 createdAt: review.createdAt,
457 url: null,
458 path: null,
459 reviewState: review.dismissedAt !== null ? "dismissed" : REVIEW_STATES[review.verdict],
460 })),
461 ];
462 return {
463 comments: comments.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)),
464 commentCount: input.comments.items.length + input.reviews.items.length,
465 commentsTruncated: input.comments.next !== null || input.reviews.next !== null,
466 reviewThreads,
467 commits: input.commits.items.map((commit) => ({
468 oid: commit.oid,
469 messageHeadline: commit.summary,
470 committedDate: DateTime.formatIso(DateTime.makeUnsafe(commit.time * 1_000)),
471 })),
472 ...(reactions === undefined ? {} : { reactions: reactions.reactions }),
473 };
474}
475
476export function toStack(stack: GitCafeStack, target: RepositoryOnHost): ProviderChangeRequestStack {
477 return {
478 id: stack.id,
479 number: stack.number,
480 url: `https://${target.host}/${target.repository}/stacks/${stack.number}`,
481 base: stack.landingBase,
482 layers: stack.members
483 .toSorted((a, b) => a.position - b.position)
484 .map((member) => ({
485 number: member.pullRequestNumber,
486 title: member.title,
487 headBranch: member.sourceBranch,
488 state: member.state,
489 isDraft: member.draft,
490 ...(member.headOid === undefined ? {} : { headSha: member.headOid }),
491 })),
492 };
493}
494
495/** Converts GitCafe's structured hunks into a unified patch, keeping omitted files' counts. */
496export function toDiff(diff: typeof GitCafeDiff.Type): ProviderDiffSlice {
497 const chunks: Array<string> = [];
498 const omittedFileStats: Array<{ path: string; additions: number; deletions: number }> = [];
499 for (const file of diff.items) {
500 const oldPath = file.oldPath ?? file.path;
501 const a = quoteGitPatchPath(`a/${oldPath}`);
502 const b = quoteGitPatchPath(`b/${file.path}`);
503 const before = file.status === "added" ? "/dev/null" : a;
504 const after = file.status === "deleted" ? "/dev/null" : b;
505 const mode = file.isSubmodule ? "160000" : "100644";
506 const lines = [`diff --git ${a} ${b}`];
507 if (file.status === "added") lines.push(`new file mode ${mode}`);
508 if (file.status === "deleted") lines.push(`deleted file mode ${mode}`);
509 if (file.status === "renamed" || file.status === "copied") {
510 const action = file.status === "renamed" ? "rename" : "copy";
511 lines.push(
512 `${action} from ${quoteGitPatchPath(oldPath)}`,
513 `${action} to ${quoteGitPatchPath(file.path)}`,
514 );
515 }
516 if (file.binary) {
517 lines.push(`Binary files ${before} and ${after} differ`);
518 } else if (file.tooLarge || file.hunksOmitted) {
519 omittedFileStats.push({
520 path: file.path,
521 additions: file.additions ?? 0,
522 deletions: file.deletions ?? 0,
523 });
524 } else if (file.isSubmodule) {
525 const hasOld = file.status !== "added" && file.oldOid !== undefined;
526 const hasNew = file.status !== "deleted" && file.newOid !== undefined;
527 lines.push(`--- ${before}`, `+++ ${after}`);
528 lines.push(`@@ -${hasOld ? "1" : "0,0"} +${hasNew ? "1" : "0,0"} @@`);
529 if (hasOld) lines.push(`-Subproject commit ${file.oldOid}`);
530 if (hasNew) lines.push(`+Subproject commit ${file.newOid}`);
531 } else {
532 const hunks = file.hunks ?? [];
533 if (hunks.length > 0) lines.push(`--- ${before}`, `+++ ${after}`);
534 for (const hunk of hunks) {
535 lines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`);
536 for (const line of hunk.lines) {
537 // GitCafe marks a missing final newline with libgit2's own origins.
538 if (["=", ">", "<"].includes(line.origin)) lines.push("\\ No newline at end of file");
539 else lines.push(`${line.origin}${line.content.replace(/\n$/u, "")}`);
540 }
541 }
542 }
543 chunks.push(lines.join("\n"));
544 }
545 return {
546 patch: chunks.length === 0 ? "" : `${chunks.join("\n")}\n`,
547 truncated: diff.truncated === true || omittedFileStats.length > 0,
548 nextCursor: null,
549 ...(omittedFileStats.length === 0 ? {} : { omittedFileStats }),
550 };
551}
552
553function checkStatus(check: (typeof GitCafeChecks.Type.items)[number]): PullRequestCheck["status"] {
554 if (check.status !== "completed") return "pending";
555 switch (check.conclusion) {
556 case "success":
557 return "success";
558 case "neutral":
559 return "neutral";
560 case "skipped":
561 return "skipped";
562 case "cancelled":
563 return "cancelled";
564 case "action_required":
565 return "action-required";
566 default:
567 return "failure";
568 }
569}
570
571export function toChecks(checks: typeof GitCafeChecks.Type): ReadonlyArray<PullRequestCheck> {
572 return checks.items.map((check) => ({
573 name: check.name,
574 status: checkStatus(check),
575 description: check.summary,
576 url: check.detailsUrl,
577 }));
578}