1/**
2 * Pins the requests `./GitCafePullRequestProvider.ts` sends for each operation this layer
3 * supports. Pass 2 creates:
4 *
5 * - `./GitCafeApi.ts`: service `GitCafeApi` (key `"@t3tools/source-control-gitcafe/server/GitCafeApi"`)
6 * with `request({ host, operation, method?, path, body? }) => Effect<string, GitCafeApiError>`
7 * (the response body), sending `https://<host>/api<path>` with the `GitCafeCredentials` bearer
8 * token through `HttpClient`; `make` / `layer`. Refuses any host outside `GITCAFE_HOSTS`.
9 * `GitCafeApiError` carries the HTTP `status` so 401/429 map to unauthenticated/rate-limited.
10 * - `./GitCafePullRequestProvider.ts`: `make: Effect<PullRequestProviderApi, never, GitCafeApi>`
11 * with kind `"gitcafe"`, implementing list, summary/detail, activity, diff, stack, comment,
12 * reply, resolve, review submit, synchronous merge, and stack land/restack fenced by
13 * `expectedStackHeads`.
14 *
15 * Every write carries a fresh `requestId`, which GitCafe requires.
16 *
17 * Out of scope here (layer 3): review-revision fencing, async outcome polling.
18 */
19import { assert, describe, it } from "@effect/vitest";
20import * as Effect from "effect/Effect";
21import * as Context from "effect/Context";
22import * as Layer from "effect/Layer";
23import * as Schema from "effect/Schema";
24import * as FetchHttpClient from "effect/http/FetchHttpClient";
25import * as HttpClient from "effect/http/HttpClient";
26import type * as HttpClientRequest from "effect/http/HttpClientRequest";
27import * as HttpClientResponse from "effect/http/HttpClientResponse";
28import * as HostProcess from "@t3tools/shared/HostProcess";
29import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
30
31import * as GitCafeApi from "./GitCafeApi.ts";
32import * as GitCafeCredentials from "./GitCafeCredentials.ts";
33import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts";
34
35const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
36const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
37
38interface SentRequest {
39 readonly method: string;
40 /** The path below `/api`, without its query. */
41 readonly path: string;
42 readonly query: URLSearchParams;
43 readonly host: string;
44 readonly body: unknown;
45 /** The `redirect` mode FetchHttpClient would hand to fetch for this request. */
46 readonly redirect: string | undefined;
47}
48
49/**
50 * A GitCafe host answering `"METHOD /path"` (query stripped) from `routes`; anything else is a 404
51 * problem document. Every request is recorded in order.
52 */
53function fakeGitCafe(
54 routes: Record<string, unknown | ((request: SentRequest) => unknown)>,
55 options: { readonly status?: number; readonly env?: NodeJS.ProcessEnv } = {},
56) {
57 const sent: Array<SentRequest> = [];
58 const client = HttpClient.make(
59 (request: HttpClientRequest.HttpClientRequest, _url, _signal, fiber) => {
60 const url = new URL(request.url);
61 assert.isTrue(url.pathname.startsWith("/api/"), url.pathname);
62 assert.strictEqual(request.headers.authorization, "Bearer env-token");
63 const entry: SentRequest = {
64 method: request.method,
65 path: url.pathname.slice("/api".length),
66 query: url.searchParams,
67 host: url.host,
68 body:
69 request.body._tag === "Uint8Array"
70 ? decodeJson(new TextDecoder().decode(request.body.body))
71 : undefined,
72 redirect: Context.getOrUndefined(fiber.context, FetchHttpClient.RequestInit)?.redirect,
73 };
74 sent.push(entry);
75 const route = routes[`${entry.method} ${entry.path}`];
76 const response =
77 route === undefined
78 ? new Response(encodeJson({ type: "https://cafe.sh/errors/not-found" }), { status: 404 })
79 : new Response(encodeJson(typeof route === "function" ? route(entry) : route), {
80 status: options.status ?? 200,
81 });
82 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
83 },
84 );
85 const layer = GitCafeApi.layer.pipe(
86 Layer.provide(GitCafeCredentials.layer),
87 Layer.provide(TestSourceControlHost.layer()),
88 // An environment token, so no test runs `cafe`.
89 Layer.provide(
90 Layer.succeed(HostProcess.Environment, options.env ?? { CAFE_TOKEN: "env-token" }),
91 ),
92 Layer.provide(Layer.succeed(HostProcess.WorkingDirectory, "/server")),
93 Layer.provide(Layer.succeed(HttpClient.HttpClient, client)),
94 );
95 const writes = () =>
96 sent.filter((request) => request.method !== "GET").map((r) => `${r.method} ${r.path}`);
97 return { layer, sent, writes };
98}
99
100const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 };
101const timestamp = "2026-09-12T12:00:00Z";
102const headOid = "0123456789abcdef0123456789abcdef01234567";
103const baseOid = "89abcdef0123456789abcdef0123456789abcdef";
104const actor = {
105 kind: "local",
106 actorId: "act_one",
107 handle: "alice",
108 displayName: null,
109 avatarUrl: "/avatars/one",
110};
111const pull = {
112 id: "pr_one",
113 number: 7,
114 title: "Change",
115 state: "open",
116 draft: false,
117 sourceBranch: "feature",
118 targetBranch: "main",
119 headOid,
120 observedBaseOid: baseOid,
121 mergeRoute: "provider",
122 author: actor,
123 createdAt: timestamp,
124 updatedAt: timestamp,
125 version: 4,
126 description: "Body",
127 closedAt: null,
128 mergedAt: null,
129 sourceRepo: null,
130 capabilities: { comment: true, review: true, merge: true, edit: true, moderate: true },
131};
132const root = {
133 id: "root",
134 threadId: "root",
135 version: 2,
136 author: actor,
137 body: "Review",
138 path: "file.ts",
139 line: 3,
140 side: "right",
141 createdAt: timestamp,
142 commitOid: headOid,
143 resolvedAt: null,
144 capabilities: {
145 edit: true,
146 hide: false,
147 unhide: false,
148 delete: false,
149 resolve: true,
150 unresolve: false,
151 },
152};
153/**
154 * GitCafe's stack route for pull 7, as it answers: members carry no heads, so each unmerged
155 * layer's head comes from its own pull.
156 */
157const stack = (
158 members: ReadonlyArray<{ number: number; headOid: string; state?: string }>,
159): Record<string, unknown> => ({
160 "GET /repos/owner/repo/pulls/7/stack": {
161 stack: {
162 id: "stack-one",
163 number: 3,
164 revision: 5,
165 landingBase: "main",
166 members: members.map((member, index) => ({
167 pullRequestNumber: member.number,
168 title: `Layer ${member.number}`,
169 state: member.state ?? "open",
170 draft: false,
171 sourceBranch: `layer-${member.number}`,
172 position: index + 1,
173 })),
174 },
175 },
176 ...Object.fromEntries(
177 members.map((member) => [
178 `GET /repos/owner/repo/pulls/${member.number}`,
179 { ...pull, number: member.number, headOid: member.headOid },
180 ]),
181 ),
182});
183
184/** GitCafe refuses a write without an idempotency key. */
185const assertRequestId = (body: unknown) =>
186 assert.match(
187 (body as { readonly requestId?: unknown } | undefined)?.requestId as string,
188 /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u,
189 );
190
191describe("GitCafePullRequestProvider", () => {
192 it.effect("lists a repository's pull requests from its pulls collection", () => {
193 const server = fakeGitCafe({
194 "GET /repos/owner/repo/pulls": { items: [pull], next: null },
195 });
196 return Effect.gen(function* () {
197 const provider = yield* GitCafePullRequestProvider.make;
198 const page = yield* provider.listChangeRequests({
199 ...target,
200 state: "open",
201 involvement: "all",
202 viewer: "alice",
203 limit: 10,
204 });
205 assert.deepStrictEqual(
206 page.items.map((item) => item.number),
207 [7],
208 );
209 assert.deepStrictEqual(
210 server.sent.map((request) => `${request.method} ${request.path}`),
211 ["GET /repos/owner/repo/pulls"],
212 );
213 }).pipe(Effect.provide(server.layer));
214 });
215
216 it.effect("keeps a staging repository's requests and links on staging", () => {
217 // CAFE_TOKEN follows CAFE_HOST, so staging reads the env token only when it is the target.
218 const server = fakeGitCafe(
219 { "GET /repos/owner/repo/pulls/7": pull },
220 { env: { CAFE_TOKEN: "env-token", CAFE_HOST: "staging.git.cafe" } },
221 );
222 return Effect.gen(function* () {
223 const provider = yield* GitCafePullRequestProvider.make;
224 const summary = yield* provider.getChangeRequestSummary!({
225 ...target,
226 host: "staging.git.cafe",
227 });
228 assert.strictEqual(summary.url, "https://staging.git.cafe/owner/repo/pulls/7");
229 assert.isTrue(server.sent.every((request) => request.host === "staging.git.cafe"));
230 }).pipe(Effect.provide(server.layer));
231 });
232
233 it.effect("refuses a repository outside GitCafe's hosts before sending anything", () => {
234 const server = fakeGitCafe({});
235 return Effect.gen(function* () {
236 const provider = yield* GitCafePullRequestProvider.make;
237 const result = yield* provider
238 .getChangeRequest({ ...target, host: "github.com" })
239 .pipe(Effect.result);
240 assert.strictEqual(result._tag, "Failure");
241 assert.deepStrictEqual(server.sent, []);
242 }).pipe(Effect.provide(server.layer));
243 });
244
245 it.effect("reports a refused token as unauthenticated and a limit as rate-limited", () =>
246 Effect.gen(function* () {
247 for (const [status, reason] of [
248 [401, "unauthenticated"],
249 [429, "rate-limited"],
250 ] as const) {
251 const server = fakeGitCafe(
252 { "GET /repos/owner/repo/pulls/7/stack": { stack: null } },
253 {
254 status,
255 },
256 );
257 const error = yield* GitCafePullRequestProvider.make.pipe(
258 Effect.flatMap((provider) => provider.getChangeRequestStack!(target)),
259 Effect.flip,
260 Effect.provide(server.layer),
261 );
262 assert.strictEqual(error.reason, reason);
263 }
264 }),
265 );
266
267 it.effect("never lets fetch follow a redirect with the token", () => {
268 const server = fakeGitCafe({ "GET /repos/owner/repo/pulls/7/stack": { stack: null } });
269 return GitCafePullRequestProvider.make.pipe(
270 Effect.flatMap((provider) => provider.getChangeRequestStack!(target)),
271 Effect.tap(() =>
272 Effect.sync(() =>
273 assert.deepStrictEqual(
274 server.sent.map((r) => r.redirect),
275 ["manual"],
276 ),
277 ),
278 ),
279 Effect.provide(server.layer),
280 );
281 });
282
283 it.effect("reads the stack a pull request belongs to, with each open layer's head", () => {
284 const { "GET /repos/owner/repo/pulls/6": _merged, ...routes } = stack([
285 { number: 6, headOid: baseOid, state: "merged" },
286 { number: 7, headOid },
287 ]);
288 const server = fakeGitCafe(routes);
289 return Effect.gen(function* () {
290 const provider = yield* GitCafePullRequestProvider.make;
291 const result = yield* provider.getChangeRequestStack!(target);
292 assert.strictEqual(result?.number, 3);
293 // A merged layer is not fenced on, so its pull is never read.
294 assert.deepStrictEqual(
295 result?.layers.map((layer) => [layer.number, layer.headSha]),
296 [
297 [6, undefined],
298 [7, headOid],
299 ],
300 );
301 }).pipe(Effect.provide(server.layer));
302 });
303
304 it.effect("reads activity with the host's edit and resolve permissions", () => {
305 const server = fakeGitCafe({
306 "GET /repos/owner/repo/pulls/7": pull,
307 "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null },
308 "GET /repos/owner/repo/pulls/7/reviews": { items: [], next: null },
309 "GET /repos/owner/repo/pulls/7/commits": {
310 items: [],
311 truncated: false,
312 next: null,
313 headOid,
314 },
315 "GET /repos/owner/repo/pulls/7/reactions/": { items: [], next: null },
316 });
317 return Effect.gen(function* () {
318 const provider = yield* GitCafePullRequestProvider.make;
319 const activity = yield* provider.getChangeRequestActivity(target);
320 assert.strictEqual(activity.comments[0]?.canEdit, true);
321 assert.strictEqual(activity.reviewThreads[0]?.canResolve, true);
322 assert.deepStrictEqual(server.writes(), []);
323 }).pipe(Effect.provide(server.layer));
324 });
325
326 it.effect("reads the viewer's permission to edit the pull request", () => {
327 const server = fakeGitCafe({
328 "GET /repos/owner/repo/pulls/7": {
329 ...pull,
330 capabilities: { ...pull.capabilities, edit: false },
331 },
332 });
333 return Effect.gen(function* () {
334 const provider = yield* GitCafePullRequestProvider.make;
335 const permissions = yield* provider.getViewerPermissions(target);
336 assert.strictEqual(permissions.editChangeRequest, false);
337 }).pipe(Effect.provide(server.layer));
338 });
339
340 it.effect("reads the diff from the pull request's changed files", () => {
341 const file = {
342 path: "file.ts",
343 oldPath: null,
344 status: "modified",
345 additions: 1,
346 deletions: 1,
347 binary: false,
348 };
349 const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid };
350 const server = fakeGitCafe({
351 "GET /repos/owner/repo/pulls/7": pull,
352 "GET /repos/owner/repo/pulls/7/changes": { ...snapshot, items: [file], next: null },
353 "POST /repos/owner/repo/pulls/7/diff-files": {
354 ...snapshot,
355 items: [
356 {
357 ...file,
358 hunks: [
359 {
360 oldStart: 1,
361 oldLines: 1,
362 newStart: 1,
363 newLines: 1,
364 lines: [
365 { origin: "-", content: "old\n" },
366 { origin: "+", content: "new\n" },
367 ],
368 },
369 ],
370 },
371 ],
372 },
373 });
374 return Effect.gen(function* () {
375 const provider = yield* GitCafePullRequestProvider.make;
376 const diff = yield* provider.getDiff(target);
377 assert.include(diff.patch, "-old\n+new\n");
378 assert.isNull(diff.nextCursor);
379 // The batched hunk read is a POST, but it writes nothing.
380 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/diff-files"]);
381 }).pipe(Effect.provide(server.layer));
382 });
383
384 it.effect("comments and replies to a thread on the pull request's comments", () => {
385 const written = { id: "c1", threadId: "root", version: 1 };
386 const server = fakeGitCafe({
387 "POST /repos/owner/repo/pulls/7/comments/": written,
388 "POST /repos/owner/repo/pulls/7/comments/root/replies": written,
389 });
390 return Effect.gen(function* () {
391 const provider = yield* GitCafePullRequestProvider.make;
392 yield* provider.comment({ ...target, body: "Hello" });
393 yield* provider.replyToThread({ ...target, threadId: "root", body: "Reply" });
394 assert.deepStrictEqual(server.writes(), [
395 "POST /repos/owner/repo/pulls/7/comments/",
396 "POST /repos/owner/repo/pulls/7/comments/root/replies",
397 ]);
398 assert.deepStrictEqual(
399 server.sent.map((request) => request.body),
400 [{ body: "Hello" }, { body: "Reply" }],
401 );
402 }).pipe(Effect.provide(server.layer));
403 });
404
405 it.effect("resolves a thread through its root at the root's version", () => {
406 const server = fakeGitCafe({
407 "GET /repos/owner/repo/pulls/7/comments": { items: [root], next: null },
408 "POST /repos/owner/repo/pulls/7/comments/root/resolve": {
409 id: "root",
410 threadId: "root",
411 version: 3,
412 },
413 });
414 return Effect.gen(function* () {
415 const provider = yield* GitCafePullRequestProvider.make;
416 yield* provider.setThreadResolution({ ...target, threadId: "root", resolved: true });
417 assert.deepStrictEqual(server.writes(), [
418 "POST /repos/owner/repo/pulls/7/comments/root/resolve",
419 ]);
420 assert.deepStrictEqual(server.sent.at(-1)?.body, { expectedVersion: 2 });
421 }).pipe(Effect.provide(server.layer));
422 });
423
424 it.effect("submits a review against the pull request's current head", () => {
425 const server = fakeGitCafe({
426 "GET /repos/owner/repo/pulls/7": pull,
427 "POST /repos/owner/repo/pulls/7/reviews": { id: "review-1" },
428 });
429 return Effect.gen(function* () {
430 const provider = yield* GitCafePullRequestProvider.make;
431 yield* provider.submitReview({
432 ...target,
433 verdict: "request-changes",
434 body: "Please fix",
435 comments: [],
436 });
437 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/reviews"]);
438 assert.deepInclude(server.sent.at(-1)?.body as object, {
439 verdict: "request_changes",
440 body: "Please fix",
441 commitOid: headOid,
442 });
443 assertRequestId(server.sent.at(-1)?.body);
444
445 // GitCafe rejects a null body; an empty review leaves it out.
446 yield* provider.submitReview({ ...target, verdict: "approve", body: "", comments: [] });
447 assert.notProperty(server.sent.at(-1)?.body as object, "body");
448 assertRequestId(server.sent.at(-1)?.body);
449 }).pipe(Effect.provide(server.layer));
450 });
451
452 it.effect.each([
453 ["completed", true],
454 ["failed", false],
455 ] as const)("merges synchronously and reads a %s outcome", ([state, succeeds]) => {
456 const server = fakeGitCafe({
457 "GET /repos/owner/repo/pulls/7": pull,
458 "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state, reason: "Blocked" },
459 });
460 return Effect.gen(function* () {
461 const provider = yield* GitCafePullRequestProvider.make;
462 const result = yield* provider
463 .runAction({ ...target, action: "merge", mergeMethod: "squash" })
464 .pipe(Effect.result);
465 assert.strictEqual(result._tag, succeeds ? "Success" : "Failure");
466 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/7/merge"]);
467 assert.deepInclude(server.sent.at(-1)?.body as object, {
468 strategy: "squash",
469 headOid,
470 expectedVersion: 4,
471 });
472 assertRequestId(server.sent.at(-1)?.body);
473 }).pipe(Effect.provide(server.layer));
474 });
475
476 it.effect(
477 "lands a stack through a layer only while its heads are the ones the reader saw",
478 () => {
479 const layers = [
480 { number: 6, headOid: baseOid },
481 { number: 7, headOid },
482 ];
483 const server = fakeGitCafe({
484 ...stack(layers),
485 "POST /repos/owner/repo/pulls/stacks/3/land-through": {
486 id: "land-1",
487 state: "completed",
488 landedCount: 2,
489 stepCount: 2,
490 stopReason: null,
491 error: null,
492 steps: [],
493 },
494 });
495 return Effect.gen(function* () {
496 const provider = yield* GitCafePullRequestProvider.make;
497 const heads = layers.map((layer) => ({ number: layer.number, headSha: layer.headOid }));
498 const stale = yield* provider
499 .runAction({
500 ...target,
501 action: "merge",
502 stackNumber: 3,
503 expectedStackHeads: [heads[0]!, { number: 7, headSha: baseOid }],
504 })
505 .pipe(Effect.result);
506 assert.strictEqual(stale._tag, "Failure");
507 assert.deepStrictEqual(server.writes(), []);
508
509 yield* provider.runAction({
510 ...target,
511 action: "merge",
512 stackNumber: 3,
513 expectedStackHeads: heads,
514 mergeMethod: "merge",
515 });
516 assert.deepStrictEqual(server.writes(), [
517 "POST /repos/owner/repo/pulls/stacks/3/land-through",
518 ]);
519 assert.deepInclude(server.sent.at(-1)?.body as object, {
520 expectedRevision: 5,
521 throughPullRequestNumber: 7,
522 strategy: "merge",
523 });
524 assertRequestId(server.sent.at(-1)?.body);
525 }).pipe(Effect.provide(server.layer));
526 },
527 );
528
529 it.effect("restacks from a layer on update-branch", () => {
530 const server = fakeGitCafe({
531 ...stack([{ number: 7, headOid }]),
532 "POST /repos/owner/repo/pulls/stacks/3/restack": {
533 id: "restack-1",
534 state: "completed",
535 stepCount: 1,
536 completedStepCount: 1,
537 pauseReason: null,
538 },
539 });
540 return Effect.gen(function* () {
541 const provider = yield* GitCafePullRequestProvider.make;
542 yield* provider.runAction({
543 ...target,
544 action: "update-branch",
545 updateMethod: "rebase",
546 stackNumber: 3,
547 expectedStackHeads: [{ number: 7, headSha: headOid }],
548 });
549 assert.deepStrictEqual(server.writes(), ["POST /repos/owner/repo/pulls/stacks/3/restack"]);
550 const { requestId, ...body } = server.sent.at(-1)?.body as { requestId: unknown };
551 assertRequestId({ requestId });
552 assert.deepStrictEqual(body, { expectedRevision: 5 });
553 }).pipe(Effect.provide(server.layer));
554 });
555
556 it.effect.each([
557 ["authored", "authors"],
558 ["reviewing", "reviewers"],
559 ] as const)("filters %s pull requests by the viewer's actor on GitCafe", ([involvement, key]) => {
560 const server = fakeGitCafe({
561 "GET /repos/owner/repo/filter-options": {
562 actors: [{ actorId: "act_one", handle: "Alice" }],
563 },
564 "GET /repos/owner/repo/pulls": { items: [pull], next: null },
565 });
566 return Effect.gen(function* () {
567 const provider = yield* GitCafePullRequestProvider.make;
568 const page = yield* provider.listChangeRequests({
569 ...target,
570 state: "open",
571 involvement,
572 viewer: "alice",
573 limit: 10,
574 });
575 assert.strictEqual(server.sent.at(-1)?.query.get(key), '["act_one"]');
576 assert.strictEqual(
577 page.items[0]?.reviewRequestLogins.includes("alice"),
578 involvement === "reviewing",
579 );
580 }).pipe(Effect.provide(server.layer));
581 });
582
583 it.effect("lists nothing for a viewer GitCafe has no actor for", () => {
584 const server = fakeGitCafe({
585 "GET /repos/owner/repo/filter-options": { actors: [] },
586 });
587 return Effect.gen(function* () {
588 const provider = yield* GitCafePullRequestProvider.make;
589 const page = yield* provider.listChangeRequests({
590 ...target,
591 state: "open",
592 involvement: "authored",
593 viewer: "alice",
594 limit: 10,
595 });
596 assert.deepStrictEqual(page.items, []);
597 assert.deepStrictEqual(
598 server.sent.map((request) => request.path),
599 ["/repos/owner/repo/filter-options"],
600 );
601 }).pipe(Effect.provide(server.layer));
602 });
603
604 it.effect.each(["authored", "reviewing"] as const)(
605 "lists %s unnarrowed when GitCafe can't say who the viewer is",
606 (involvement) => {
607 // No `filter-options` route: the fake answers 404, which is an ordinary failure.
608 const server = fakeGitCafe({ "GET /repos/owner/repo/pulls": { items: [pull], next: null } });
609 return Effect.gen(function* () {
610 const provider = yield* GitCafePullRequestProvider.make;
611 const page = yield* provider.listChangeRequests({
612 ...target,
613 state: "open",
614 involvement,
615 viewer: "alice",
616 limit: 10,
617 });
618 const listing = server.sent.find((request) => request.path === "/repos/owner/repo/pulls");
619 assert.isDefined(listing);
620 assert.isNull(listing!.query.get(involvement === "reviewing" ? "reviewers" : "authors"));
621 // An unnarrowed row says nothing about who was asked to review it.
622 assert.notInclude(page.items[0]?.reviewRequestLogins ?? [], "alice");
623 }).pipe(Effect.provide(server.layer));
624 },
625 );
626
627 it.effect("reads line counts, open merge strategies and requested reviewers", () => {
628 const snapshot = { version: 4, headOid, comparisonBaseOid: baseOid };
629 const files = (from: number, count: number) =>
630 Array.from({ length: count }, (_, index) => ({ path: `f${from + index}.ts` }));
631 let changePage = 0;
632 const server = fakeGitCafe({
633 "GET /repos/owner/repo/pulls/7": { ...pull, reviewers: [{ actor }] },
634 "GET /repos/owner/repo/pulls/7/status": {
635 merge: {
636 conflicts: "unknown",
637 fastForward: true,
638 strategies: ["merge", "squash", "rebase"],
639 blockers: [{ blockedStrategies: ["squash"] }, {}],
640 },
641 checks: { pending: 0, failing: 0, total: 0 },
642 },
643 // Checks continue on a cursor too; a failure on a later page must not be dropped.
644 [`GET /repos/owner/repo/commits/${headOid}/checks`]: (request: SentRequest) => ({
645 items: [
646 {
647 name: request.query.has("after") ? "late" : "early",
648 status: "completed",
649 conclusion: request.query.has("after") ? "failure" : "success",
650 summary: null,
651 detailsUrl: null,
652 },
653 ],
654 next: request.query.has("after") ? null : "checks-1",
655 }),
656 // `/changes` counts no lines, and a pull past one page continues on a cursor.
657 "GET /repos/owner/repo/pulls/7/changes": () =>
658 changePage++ === 0
659 ? { ...snapshot, items: files(0, 2), next: "cursor-1" }
660 : { ...snapshot, items: files(2, 1), next: null },
661 "POST /repos/owner/repo/pulls/7/diff-files": (request: SentRequest) => ({
662 ...snapshot,
663 items: (request.body as { paths: ReadonlyArray<string> }).paths.map((path) => ({
664 path,
665 additions: 2,
666 deletions: 1,
667 })),
668 }),
669 });
670 return Effect.gen(function* () {
671 const provider = yield* GitCafePullRequestProvider.make;
672 const detail = yield* provider.getChangeRequest(target);
673 assert.deepInclude(detail, { additions: 6, deletions: 3, changedFiles: 3 });
674 assert.strictEqual(
675 server.sent.filter((request) => request.path.endsWith("/changes"))[1]?.query.get("after"),
676 "cursor-1",
677 );
678 assert.deepStrictEqual(detail.mergeCapabilities, {
679 merge: true,
680 squash: false,
681 rebase: true,
682 });
683 assert.deepStrictEqual(
684 detail.checks.map((check) => check.name),
685 ["early", "late"],
686 );
687 // A requested reviewer shows before they have reviewed.
688 assert.deepStrictEqual(
689 detail.reviewers.map((reviewer) => reviewer.login),
690 ["alice"],
691 );
692 }).pipe(Effect.provide(server.layer));
693 });
694
695 it.effect("fences a native merge on the target branch's current commit", () => {
696 const branchOid = "fedcba9876543210fedcba9876543210fedcba98";
697 const server = fakeGitCafe({
698 "GET /repos/owner/repo/pulls/7": { ...pull, mergeRoute: "native", observedBaseOid: null },
699 "GET /repos/owner/repo/commit": { oid: branchOid },
700 "POST /repos/owner/repo/pulls/7/merge": { id: "merge-1", state: "completed" },
701 });
702 return Effect.gen(function* () {
703 const provider = yield* GitCafePullRequestProvider.make;
704 yield* provider.runAction({ ...target, action: "merge" });
705 const branchRead = server.sent.find((request) => request.path === "/repos/owner/repo/commit");
706 assert.strictEqual(branchRead?.query.get("ref"), "refs/heads/main");
707 assert.deepInclude(server.sent.at(-1)?.body as object, { baseOid: branchOid, headOid });
708 }).pipe(Effect.provide(server.layer));
709 });
710
711 it.effect("refuses to drop a reaction whose id it cannot address", () => {
712 const server = fakeGitCafe({
713 "GET /repos/owner/repo/pulls/7/reactions/": {
714 items: [
715 {
716 subject: { kind: "pull_request_comment", id: "root" },
717 emoji: { kind: "unicode", value: "🚀" },
718 count: 1,
719 viewerReactionId: "../escape",
720 reactors: [actor],
721 },
722 ],
723 next: null,
724 },
725 });
726 return Effect.gen(function* () {
727 const provider = yield* GitCafePullRequestProvider.make;
728 const result = yield* provider
729 .setReaction({ ...target, subjectId: "root", content: "rocket", reacted: false })
730 .pipe(Effect.result);
731 assert.strictEqual(result._tag, "Failure");
732 assert.deepStrictEqual(server.writes(), []);
733 }).pipe(Effect.provide(server.layer));
734 });
735
736 it.effect("keeps GitCafe's explanation short and on one line", () => {
737 const server = fakeGitCafe(
738 {
739 "GET /repos/owner/repo/pulls/7": { title: "Conflict", detail: `Line\n${"x".repeat(900)}` },
740 },
741 { status: 409 },
742 );
743 return Effect.gen(function* () {
744 const provider = yield* GitCafePullRequestProvider.make;
745 const error = yield* provider.getChangeRequest(target).pipe(Effect.flip);
746 assert.notInclude(error.detail, "\n");
747 assert.isAtMost(error.detail.length, 310);
748 assert.isTrue(error.detail.startsWith("Line x"));
749 }).pipe(Effect.provide(server.layer));
750 });
751});