| 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 | */ |
| 19 | import { assert, describe, it } from "@effect/vitest"; |
| 20 | import * as Effect from "effect/Effect"; |
| 21 | import * as Context from "effect/Context"; |
| 22 | import * as Layer from "effect/Layer"; |
| 23 | import * as Schema from "effect/Schema"; |
| 24 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 25 | import * as HttpClient from "effect/http/HttpClient"; |
| 26 | import type * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 27 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 28 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 29 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 30 | |
| 31 | import * as GitCafeApi from "./GitCafeApi.ts"; |
| 32 | import * as GitCafeCredentials from "./GitCafeCredentials.ts"; |
| 33 | import * as GitCafePullRequestProvider from "./GitCafePullRequestProvider.ts"; |
| 34 | |
| 35 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 36 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 37 | |
| 38 | interface 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 | */ |
| 53 | function 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 | |
| 100 | const target = { cwd: "/repo", host: "git.cafe", repository: "owner/repo", number: 7 }; |
| 101 | const timestamp = "2026-09-12T12:00:00Z"; |
| 102 | const headOid = "0123456789abcdef0123456789abcdef01234567"; |
| 103 | const baseOid = "89abcdef0123456789abcdef0123456789abcdef"; |
| 104 | const actor = { |
| 105 | kind: "local", |
| 106 | actorId: "act_one", |
| 107 | handle: "alice", |
| 108 | displayName: null, |
| 109 | avatarUrl: "/avatars/one", |
| 110 | }; |
| 111 | const 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 | }; |
| 132 | const 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 | */ |
| 157 | const 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. */ |
| 185 | const 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 | |
| 191 | describe("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 | }); |