1/**
2 * Pins `./gitCafePullRequestJson.ts`, pure decoding of GitCafe's REST JSON into the neutral
3 * `Provider*` shapes. Pass 2 creates these exports:
4 *
5 * Schemas (REST bodies as GitCafe sends them):
6 * - `GitCafePull` (list row), `GitCafePullDetail` (row + description, closedAt, mergedAt,
7 * sourceRepo, lockedAt?, required `capabilities: { comment, review, merge, edit, moderate }`),
8 * - `GitCafeComments`, `GitCafeReviews`, `GitCafeCommits`, `GitCafeReactions`
9 * (cursor envelopes `{ items, next }`; commits also `{ truncated, headOid }`),
10 * - `GitCafeStackEnvelope` (`{ stack: null | { id, number, revision, landingBase, members } }`,
11 * members with an optional `headOid`), `GitCafeDiff` (`{ items, truncated? }`),
12 * `GitCafeChecks` (`{ items, next }`).
13 *
14 * Conversions (all take the repository host, so staging stays on staging):
15 * - `toChangeRequest(pull, { repository, host }): ProviderChangeRequest`
16 * - `toViewerPermissions(detail): PullRequestViewerPermissions` — fills `editChangeRequest`.
17 * - `toActivity({ comments, reviews, commits, reactions?, headOid?, host }):
18 * ProviderChangeRequestActivity` — fills `canEdit` on both copies of a remark and `canResolve`
19 * on review threads, from each comment's `capabilities`.
20 * - `toStack(stack, { repository, host }): ProviderChangeRequestStack`
21 * - `toDiff(diff): ProviderDiffSlice`
22 * - `toChecks(checks): ReadonlyArray<PullRequestCheck>`
23 */
24import { assert, describe, it } from "@effect/vitest";
25import * as Schema from "effect/Schema";
26
27import * as GitCafePullRequestJson from "./gitCafePullRequestJson.ts";
28
29const decodePull = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePull);
30const decodePullDetail = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafePullDetail);
31const decodeComments = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeComments);
32const decodeReviews = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReviews);
33const decodeCommits = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeCommits);
34const decodeReactions = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeReactions);
35const decodeStackEnvelope = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeStackEnvelope);
36const decodeDiff = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeDiff);
37const decodeChecks = Schema.decodeUnknownSync(GitCafePullRequestJson.GitCafeChecks);
38
39const timestamp = "2026-09-12T12:00:00Z";
40const actor = {
41 kind: "local",
42 actorId: "act_one",
43 handle: "alice",
44 displayName: null,
45 avatarUrl: "/avatars/one",
46};
47const pull = {
48 id: "pr_one",
49 number: 7,
50 title: "Change",
51 state: "open",
52 draft: true,
53 sourceBranch: "feature",
54 targetBranch: "main",
55 headOid: "abcdef",
56 author: actor,
57 createdAt: timestamp,
58 updatedAt: timestamp,
59 version: 1,
60};
61const detail = {
62 ...pull,
63 draft: false,
64 description: "Body",
65 closedAt: null,
66 mergedAt: null,
67 sourceRepo: null,
68 capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true },
69};
70const lineComment = {
71 author: actor,
72 body: "Review",
73 path: "file.ts",
74 line: 3,
75 side: "right",
76 createdAt: timestamp,
77 commitOid: "abcdef",
78 resolvedAt: null,
79};
80const capabilities = (overrides: Record<string, boolean> = {}) => ({
81 edit: false,
82 hide: false,
83 unhide: false,
84 delete: false,
85 resolve: false,
86 unresolve: false,
87 ...overrides,
88});
89const noReviews = decodeReviews({ items: [], next: null });
90const noCommits = decodeCommits({ items: [], headOid: "abcdef", truncated: false, next: null });
91const activity = (
92 comments: ReadonlyArray<Record<string, unknown>>,
93 extra: { readonly reactions?: unknown; readonly host?: string } = {},
94) =>
95 GitCafePullRequestJson.toActivity({
96 comments: decodeComments({ items: comments, next: null }),
97 reviews: noReviews,
98 commits: noCommits,
99 headOid: "abcdef",
100 host: extra.host ?? "git.cafe",
101 ...(extra.reactions === undefined ? {} : { reactions: decodeReactions(extra.reactions) }),
102 });
103
104describe("toChangeRequest", () => {
105 it("reads draft state, the repository host's URLs and absolute avatars", () => {
106 const row = GitCafePullRequestJson.toChangeRequest(decodePull(pull), {
107 repository: "owner/repo",
108 host: "staging.git.cafe",
109 });
110 assert.strictEqual(row.state, "open");
111 assert.isTrue(row.isDraft);
112 assert.strictEqual(row.url, "https://staging.git.cafe/owner/repo/pulls/7");
113 assert.deepStrictEqual(row.author, {
114 login: "alice",
115 name: null,
116 avatarUrl: "https://staging.git.cafe/avatars/one",
117 });
118 });
119
120 it("names linked GitHub reviewers by both logins and drops unavailable ones", () => {
121 const row = GitCafePullRequestJson.toChangeRequest(
122 decodePull({
123 ...pull,
124 author: { kind: "github", actorId: "act_remote", login: "remote-user", avatarUrl: null },
125 reviewers: [
126 { actor },
127 {
128 actor: {
129 kind: "github",
130 actorId: "act_github",
131 login: "upstream",
132 avatarUrl: null,
133 linkedProfile: { handle: "linked-local" },
134 },
135 },
136 { actor: { kind: "unavailable", actorId: "act_deleted" } },
137 ],
138 }),
139 { repository: "owner/repo", host: "git.cafe" },
140 );
141 assert.strictEqual(row.author?.login, "remote-user");
142 assert.deepStrictEqual(row.reviewRequestLogins, ["alice", "upstream", "linked-local"]);
143 });
144
145 it("requires capabilities on a detail response", () => {
146 assert.throws(() => decodePullDetail(pull));
147 });
148});
149
150describe("toViewerPermissions", () => {
151 const permissions = (overrides: Record<string, unknown>) =>
152 GitCafePullRequestJson.toViewerPermissions(decodePullDetail({ ...detail, ...overrides }));
153
154 it("lets an editor rewrite the title and description, and nobody else", () => {
155 assert.isTrue(permissions({}).editChangeRequest);
156 assert.isFalse(
157 permissions({
158 capabilities: { comment: true, review: true, merge: true, edit: false, moderate: true },
159 }).editChangeRequest,
160 );
161 });
162
163 it("offers lifecycle actions by state and never a standalone branch update", () => {
164 assert.deepStrictEqual(permissions({}).actions, ["draft", "close", "merge"]);
165 assert.deepStrictEqual(permissions({ state: "closed" }).actions, ["reopen"]);
166 assert.deepStrictEqual(permissions({ state: "merged" }).actions, []);
167 assert.isTrue(permissions({}).stackRebase);
168 });
169
170 it("keeps a locked conversation's composer for moderators only", () => {
171 const locked = (moderate: boolean) =>
172 permissions({
173 lockedAt: timestamp,
174 capabilities: { comment: true, review: false, merge: false, edit: false, moderate },
175 });
176 assert.isFalse(locked(false).comment);
177 assert.isFalse(locked(false).resolve);
178 assert.isTrue(locked(true).comment);
179 assert.deepStrictEqual(locked(true).verdicts, []);
180 });
181
182 it("offers no composer to a viewer GitCafe won't let comment", () => {
183 const silent = permissions({
184 capabilities: { comment: false, review: true, merge: true, edit: true, moderate: true },
185 });
186 assert.isFalse(silent.comment);
187 assert.isFalse(silent.resolve);
188 });
189});
190
191describe("toActivity", () => {
192 it("groups line comments by their persisted thread id and marks a paged list truncated", () => {
193 const result = GitCafePullRequestJson.toActivity({
194 comments: decodeComments({
195 items: [
196 { ...lineComment, id: "reply", threadId: "thread-one" },
197 { ...lineComment, id: "other", threadId: "thread-two" },
198 ],
199 next: "next",
200 }),
201 reviews: noReviews,
202 commits: noCommits,
203 headOid: "abcdef",
204 host: "git.cafe",
205 });
206 assert.deepStrictEqual(result.reviewThreads.map((thread) => thread.id).toSorted(), [
207 "thread-one",
208 "thread-two",
209 ]);
210 assert.isTrue(result.commentsTruncated);
211 });
212
213 it.each([timestamp, null])("takes the thread's resolution from its root: %s", (resolvedAt) => {
214 const result = activity([
215 { ...lineComment, id: "root", threadId: "root", resolvedAt },
216 { ...lineComment, id: "reply", threadId: "root", resolvedAt: null },
217 ]);
218 assert.strictEqual(result.reviewThreads.length, 1);
219 assert.strictEqual(result.reviewThreads[0]?.isResolved, resolvedAt !== null);
220 assert.strictEqual(result.reviewThreads[0]?.comments.length, 2);
221 });
222
223 it("carries per-remark edit permission onto both copies of a remark", () => {
224 const result = activity([
225 {
226 ...lineComment,
227 id: "root",
228 threadId: "root",
229 capabilities: capabilities({ edit: false, resolve: true }),
230 },
231 {
232 ...lineComment,
233 id: "reply",
234 threadId: "root",
235 capabilities: capabilities({ edit: true }),
236 },
237 { ...lineComment, id: "silent", threadId: "silent", path: null, line: null, side: null },
238 ]);
239 const canEdit = (id: string) => result.comments.find((comment) => comment.id === id)?.canEdit;
240 assert.strictEqual(canEdit("root"), false);
241 assert.strictEqual(canEdit("reply"), true);
242 // A host that says nothing about a remark leaves the page to guess.
243 assert.isUndefined(canEdit("silent"));
244 assert.deepStrictEqual(
245 result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.canEdit]),
246 [
247 ["root", false],
248 ["reply", true],
249 ],
250 );
251 });
252
253 it.each([
254 [null, { resolve: true, unresolve: false }, true],
255 [null, { resolve: false, unresolve: true }, false],
256 [timestamp, { resolve: true, unresolve: false }, false],
257 [timestamp, { resolve: false, unresolve: true }, true],
258 ] as const)(
259 "reads canResolve for a thread resolved at %s with %o as %s",
260 (resolvedAt, allowed, canResolve) => {
261 const result = activity([
262 {
263 ...lineComment,
264 id: "root",
265 threadId: "root",
266 resolvedAt,
267 capabilities: capabilities(allowed),
268 },
269 ]);
270 assert.strictEqual(result.reviewThreads[0]?.canResolve, canResolve);
271 },
272 );
273
274 it("leaves canResolve absent when the root says nothing", () => {
275 const result = activity([{ ...lineComment, id: "root", threadId: "root" }]);
276 assert.isUndefined(result.reviewThreads[0]?.canResolve);
277 });
278
279 it("maps review verdicts, dismissals and commits", () => {
280 const result = GitCafePullRequestJson.toActivity({
281 comments: decodeComments({ items: [], next: null }),
282 reviews: decodeReviews({
283 items: [
284 {
285 id: "r1",
286 author: actor,
287 body: "LGTM",
288 verdict: "approve",
289 dismissedAt: null,
290 createdAt: timestamp,
291 },
292 {
293 id: "r2",
294 author: actor,
295 body: null,
296 verdict: "request_changes",
297 dismissedAt: timestamp,
298 createdAt: "2026-09-12T13:00:00Z",
299 },
300 ],
301 next: null,
302 }),
303 commits: decodeCommits({
304 items: [{ oid: "abcdef", summary: "Change", time: 1_789_000_000 }],
305 headOid: "abcdef",
306 truncated: false,
307 next: null,
308 }),
309 host: "git.cafe",
310 });
311 assert.deepStrictEqual(
312 result.comments.map((comment) => [comment.id, comment.kind, comment.reviewState]),
313 [
314 ["r1", "review", "approved"],
315 ["r2", "review", "dismissed"],
316 ],
317 );
318 assert.deepStrictEqual(
319 result.commits.map((commit) => commit.oid),
320 ["abcdef"],
321 );
322 });
323
324 it("maps supported reactions to the pull request and to matching comments in both views", () => {
325 const result = activity(
326 [
327 { ...lineComment, id: "comment-a", threadId: "comment-a" },
328 { ...lineComment, id: "comment-b", threadId: "comment-a" },
329 ],
330 {
331 reactions: {
332 next: null,
333 items: [
334 {
335 subject: { kind: "pull_request_comment", id: "comment-b" },
336 emoji: { kind: "unicode", value: "👍" },
337 count: 3,
338 viewerReactionId: "reaction-viewer",
339 reactors: [actor, { kind: "unavailable", actorId: "deleted" }],
340 },
341 {
342 subject: { kind: "pull_request", id: "pr_one" },
343 emoji: { kind: "unicode", value: "🚀" },
344 count: 1,
345 viewerReactionId: null,
346 reactors: [actor],
347 },
348 // Neither a custom emoji nor one outside the shared set is shown under another name.
349 {
350 subject: { kind: "pull_request", id: "pr_one" },
351 emoji: { kind: "custom", id: "party-parrot" },
352 count: 2,
353 viewerReactionId: null,
354 reactors: [actor],
355 },
356 {
357 subject: { kind: "pull_request", id: "pr_one" },
358 emoji: { kind: "unicode", value: "🔥" },
359 count: 1,
360 viewerReactionId: null,
361 reactors: [actor],
362 },
363 ],
364 },
365 },
366 );
367 const thumbsUp = {
368 content: "thumbs-up",
369 count: 3,
370 actors: ["alice"],
371 viewerHasReacted: true,
372 } as const;
373 assert.deepStrictEqual(result.reactions, [
374 { content: "rocket", count: 1, actors: ["alice"], viewerHasReacted: false },
375 ]);
376 assert.deepStrictEqual(
377 result.comments.map((comment) => [comment.id, comment.reactions]),
378 [
379 ["comment-a", []],
380 ["comment-b", [thumbsUp]],
381 ],
382 );
383 assert.deepStrictEqual(
384 result.reviewThreads[0]?.comments.map((comment) => [comment.id, comment.reactions]),
385 [
386 ["comment-a", []],
387 ["comment-b", [thumbsUp]],
388 ],
389 );
390 });
391});
392
393describe("toStack", () => {
394 it("orders members by position and reports heads only where GitCafe gives them", () => {
395 const { stack } = decodeStackEnvelope({
396 stack: {
397 id: "stack-one",
398 number: 3,
399 revision: 1,
400 landingBase: "main",
401 members: [
402 {
403 pullRequestNumber: 7,
404 title: "Top",
405 state: "open",
406 draft: true,
407 sourceBranch: "feature",
408 position: 2,
409 },
410 {
411 pullRequestNumber: 6,
412 title: "Base",
413 state: "merged",
414 draft: false,
415 sourceBranch: "base",
416 headOid: "0123456789abcdef0123456789abcdef01234567",
417 position: 1,
418 },
419 ],
420 },
421 });
422 assert.isNotNull(stack);
423 const result = GitCafePullRequestJson.toStack(stack!, {
424 repository: "owner/repo",
425 host: "staging.git.cafe",
426 });
427 assert.strictEqual(result.url, "https://staging.git.cafe/owner/repo/stacks/3");
428 assert.strictEqual(result.base, "main");
429 assert.deepStrictEqual(result.layers, [
430 {
431 number: 6,
432 title: "Base",
433 headBranch: "base",
434 state: "merged",
435 isDraft: false,
436 headSha: "0123456789abcdef0123456789abcdef01234567",
437 },
438 { number: 7, title: "Top", headBranch: "feature", state: "open", isDraft: true },
439 ]);
440 });
441
442 it("reads an unstacked pull request as no stack", () => {
443 assert.isNull(decodeStackEnvelope({ stack: null }).stack);
444 });
445});
446
447describe("toDiff", () => {
448 it("converts numeric hunk coordinates into a unified patch", () => {
449 const diff = decodeDiff({
450 items: [
451 {
452 path: "file.ts",
453 oldPath: null,
454 status: "modified",
455 additions: 1,
456 deletions: 1,
457 binary: false,
458 hunks: [
459 {
460 oldStart: 1,
461 oldLines: 1,
462 newStart: 1,
463 newLines: 1,
464 lines: [
465 { origin: "-", content: "old\n" },
466 { origin: "+", content: "new\n" },
467 ],
468 },
469 ],
470 },
471 ],
472 truncated: false,
473 });
474 assert.include(GitCafePullRequestJson.toDiff(diff).patch, "@@ -1,1 +1,1 @@\n-old\n+new\n");
475 });
476
477 it("marks an omitted patch incomplete even when GitCafe's own flag is false", () => {
478 const diff = decodeDiff({
479 items: [{ path: "large.ts", oldPath: null, status: "modified", tooLarge: true, hunks: [] }],
480 truncated: false,
481 });
482 const result = GitCafePullRequestJson.toDiff(diff);
483 assert.isTrue(result.truncated);
484 assert.deepStrictEqual(result.omittedFileStats, [
485 { path: "large.ts", additions: 0, deletions: 0 },
486 ]);
487 });
488
489 it("quotes renamed and binary paths", () => {
490 const { patch } = GitCafePullRequestJson.toDiff(
491 decodeDiff({
492 items: [
493 {
494 path: "new image.png",
495 oldPath: "old image.png",
496 status: "renamed",
497 binary: true,
498 hunks: [],
499 },
500 ],
501 truncated: false,
502 }),
503 );
504 // Git quotes a name for the bytes a header can't carry, not for a space.
505 assert.include(patch, "rename from old image.png");
506 assert.include(patch, "Binary files a/old image.png and b/new image.png differ");
507 });
508
509 it("C-escapes a name whose newline or tab would break the patch headers", () => {
510 const { patch } = GitCafePullRequestJson.toDiff(
511 decodeDiff({
512 items: [{ path: "two\nlines\tname.ts", status: "added", hunks: [] }],
513 truncated: false,
514 }),
515 );
516 assert.include(patch, 'diff --git "a/two\\nlines\\tname.ts" "b/two\\nlines\\tname.ts"');
517 assert.notInclude(patch, "two\nlines");
518 });
519});
520
521describe("toChecks", () => {
522 it("maps GitCafe's status and conclusion onto the shared check states", () => {
523 const check = (status: string, conclusion: string | null) => ({
524 name: `${status}-${conclusion}`,
525 status,
526 conclusion,
527 summary: null,
528 detailsUrl: null,
529 });
530 const checks = GitCafePullRequestJson.toChecks(
531 decodeChecks({
532 items: [
533 check("queued", null),
534 check("in_progress", null),
535 check("completed", "success"),
536 check("completed", "action_required"),
537 check("completed", "timed_out"),
538 ],
539 next: null,
540 }),
541 );
542 assert.deepStrictEqual(
543 checks.map((item) => item.status),
544 ["pending", "pending", "success", "action-required", "failure"],
545 );
546 });
547});