| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 3 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Clock from "effect/Clock"; |
| 6 | import * as Deferred from "effect/Deferred"; |
| 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Schema from "effect/Schema"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | |
| 13 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 14 | |
| 15 | import * as GitHubApi from "./GitHubApi.ts"; |
| 16 | import * as GitHubCredentials from "./GitHubCredentials.ts"; |
| 17 | import * as GitHubQuota from "./GitHubQuota.ts"; |
| 18 | import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; |
| 19 | import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; |
| 20 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 21 | import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; |
| 22 | import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; |
| 23 | |
| 24 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 25 | |
| 26 | const coreResponse = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ |
| 27 | data: { |
| 28 | repository: { |
| 29 | mergeCommitAllowed: true, |
| 30 | squashMergeAllowed: false, |
| 31 | rebaseMergeAllowed: true, |
| 32 | viewerPermission: "WRITE", |
| 33 | pullRequest: { |
| 34 | number: 7, |
| 35 | title: "Pull request 7", |
| 36 | url: "https://github.com/acme/web/pull/7", |
| 37 | headRefName: "feature", |
| 38 | baseRefName: "main", |
| 39 | headRefOid: "abc123", |
| 40 | state: "OPEN", |
| 41 | createdAt: "2026-07-01T00:00:00Z", |
| 42 | updatedAt: "2026-07-02T00:00:00Z", |
| 43 | viewerCanUpdate: true, |
| 44 | viewerDidAuthor: false, |
| 45 | viewerCanUpdateBranch: true, |
| 46 | baseRef: { compare: { behindBy: 2 } }, |
| 47 | reviewRequests: { nodes: [] }, |
| 48 | labels: { nodes: [] }, |
| 49 | commits: { nodes: [] }, |
| 50 | ...pullRequest, |
| 51 | }, |
| 52 | }, |
| 53 | }, |
| 54 | }); |
| 55 | |
| 56 | /** One request the CLI made, as the transport received it. */ |
| 57 | type ApiCall = |
| 58 | | ({ readonly kind: "graphql" } & GitHubApi.GitHubGraphQlInput) |
| 59 | | ({ readonly kind: "rest" } & GitHubApi.GitHubRestInput); |
| 60 | |
| 61 | type ApiAnswer = Effect.Effect<GitHubApi.GitHubRestResponse, GitHubApi.GitHubApiError>; |
| 62 | |
| 63 | const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); |
| 64 | const defaultCredential = (host: string) => |
| 65 | Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); |
| 66 | const mockedCredential = vi.fn(defaultCredential); |
| 67 | const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => |
| 68 | Effect.succeed(output('{"data":{}}')), |
| 69 | ); |
| 70 | |
| 71 | /** |
| 72 | * A transport that answers from the mocks above but spends the real GraphQL quota from each |
| 73 | * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. |
| 74 | */ |
| 75 | const mockApi = Layer.effect( |
| 76 | GitHubApi.GitHubApi, |
| 77 | Effect.gen(function* () { |
| 78 | const quota = yield* GitHubQuota.GitHubQuota; |
| 79 | return GitHubApi.GitHubApi.of({ |
| 80 | graphql: (input) => |
| 81 | SourceControlRateLimit.Interactive.pipe( |
| 82 | Effect.flatMap((interactive) => |
| 83 | quota.admit(input.host, "graphql", { |
| 84 | allowReserve: input.allowReserve ?? interactive, |
| 85 | }), |
| 86 | ), |
| 87 | Effect.andThen(() => |
| 88 | (input.query.includes("query PullRequestStackMemberships") |
| 89 | ? mockedStackMemberships |
| 90 | : mockedExecute)({ kind: "graphql", ...input }), |
| 91 | ), |
| 92 | Effect.tap((answer) => quota.observe(input.host, answer.headers)), |
| 93 | Effect.map((answer) => answer.body), |
| 94 | ), |
| 95 | rest: (input) => mockedExecute({ kind: "rest", ...input }), |
| 96 | credential: (host) => mockedCredential(host), |
| 97 | }); |
| 98 | }), |
| 99 | ); |
| 100 | |
| 101 | const layer = it.layer( |
| 102 | GitHubPullRequestApi.layer.pipe( |
| 103 | Layer.provideMerge(mockApi), |
| 104 | Layer.provideMerge(GitHubQuota.layer), |
| 105 | Layer.provide(NodeCrypto.layer), |
| 106 | Layer.provide(TestSourceControlHost.layer()), |
| 107 | Layer.provide(NodeServices.layer), |
| 108 | ), |
| 109 | ); |
| 110 | |
| 111 | /** A successful answer; GraphQL reads take its body. */ |
| 112 | function output( |
| 113 | body: string, |
| 114 | truncated = false, |
| 115 | invalidUtf8 = false, |
| 116 | headers: Readonly<Record<string, string>> = {}, |
| 117 | ): GitHubApi.GitHubRestResponse { |
| 118 | return { status: 200, headers, body, truncated, invalidUtf8 }; |
| 119 | } |
| 120 | |
| 121 | /** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ |
| 122 | function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { |
| 123 | return { |
| 124 | "x-ratelimit-resource": "graphql", |
| 125 | "x-ratelimit-limit": "5000", |
| 126 | "x-ratelimit-remaining": String(remaining), |
| 127 | "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), |
| 128 | }; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Listing rows as GraphQL answers them. Overrides may use the flat shapes a test reads most |
| 133 | * easily: `reviewRequests` as `[{ login }]` or `[{ slug }]`, and `checks` as the rollup state. |
| 134 | */ |
| 135 | function rows( |
| 136 | count: number, |
| 137 | firstNumber: number, |
| 138 | overrides: (number: number) => Readonly<Record<string, unknown>> = () => ({}), |
| 139 | ): ReadonlyArray<Record<string, unknown>> { |
| 140 | return Array.from({ length: count }, (_, index) => { |
| 141 | const number = firstNumber + index; |
| 142 | const { reviewRequests, checks, ...rest } = overrides(number) as { |
| 143 | reviewRequests?: ReadonlyArray<Record<string, string>>; |
| 144 | checks?: string; |
| 145 | } & Record<string, unknown>; |
| 146 | return { |
| 147 | number, |
| 148 | title: `Pull request ${number}`, |
| 149 | url: `https://github.com/acme/web/pull/${number}`, |
| 150 | headRefName: "feat/page", |
| 151 | baseRefName: "main", |
| 152 | createdAt: "2026-07-01T00:00:00Z", |
| 153 | updatedAt: "2026-07-02T00:00:00Z", |
| 154 | repository: { nameWithOwner: "acme/web" }, |
| 155 | ...(reviewRequests === undefined |
| 156 | ? {} |
| 157 | : { |
| 158 | reviewRequests: { |
| 159 | nodes: reviewRequests.map((requestedReviewer) => ({ requestedReviewer })), |
| 160 | }, |
| 161 | }), |
| 162 | ...(checks === undefined |
| 163 | ? {} |
| 164 | : { commits: { nodes: [{ commit: { statusCheckRollup: { state: checks } } }] } }), |
| 165 | ...rest, |
| 166 | }; |
| 167 | }); |
| 168 | } |
| 169 | |
| 170 | /** A page of the searched listing. */ |
| 171 | function pullRequests( |
| 172 | count: number, |
| 173 | firstNumber: number, |
| 174 | overrides?: (number: number) => Readonly<Record<string, unknown>>, |
| 175 | ): string { |
| 176 | return encodeJson({ |
| 177 | data: { |
| 178 | search: { pageInfo: { hasNextPage: false }, nodes: rows(count, firstNumber, overrides) }, |
| 179 | }, |
| 180 | }); |
| 181 | } |
| 182 | |
| 183 | /** A page of a repository's own list, which is what the search-free fallback reads. */ |
| 184 | function listedPullRequests( |
| 185 | count: number, |
| 186 | firstNumber: number, |
| 187 | overrides?: (number: number) => Readonly<Record<string, unknown>>, |
| 188 | ): string { |
| 189 | return encodeJson({ |
| 190 | data: { |
| 191 | repository: { |
| 192 | pullRequests: { |
| 193 | pageInfo: { hasNextPage: false }, |
| 194 | nodes: rows(count, firstNumber, overrides), |
| 195 | }, |
| 196 | }, |
| 197 | }, |
| 198 | }); |
| 199 | } |
| 200 | |
| 201 | /** A search that found nothing, which is also what GitHub says for a repository it does not index. */ |
| 202 | const emptySearch = () => output(pullRequests(0, 1)); |
| 203 | const emptyList = () => output(listedPullRequests(0, 1)); |
| 204 | |
| 205 | function pullRequestFiles(count: number, firstIndex: number): string { |
| 206 | return encodeJson( |
| 207 | Array.from({ length: count }, (_, index) => ({ |
| 208 | filename: `src/file${firstIndex + index}.ts`, |
| 209 | status: "modified", |
| 210 | patch: "@@ -1 +1 @@\n-old\n+new", |
| 211 | })), |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | /** One thread's comments as the GraphQL read returns them, cursor and all. */ |
| 216 | function threadComments( |
| 217 | ids: ReadonlyArray<string>, |
| 218 | endCursor: string | null, |
| 219 | totalCount = ids.length, |
| 220 | ) { |
| 221 | return { |
| 222 | totalCount, |
| 223 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, |
| 224 | nodes: ids.map((id) => ({ id, body: id, createdAt: "2026-07-01T00:00:00Z" })), |
| 225 | }; |
| 226 | } |
| 227 | |
| 228 | function thread(id: string, ...commentIds: ReadonlyArray<string>) { |
| 229 | return { |
| 230 | id, |
| 231 | path: "src/a.ts", |
| 232 | line: 1, |
| 233 | diffSide: "RIGHT", |
| 234 | isResolved: false, |
| 235 | isOutdated: false, |
| 236 | comments: threadComments(commentIds, null), |
| 237 | }; |
| 238 | } |
| 239 | |
| 240 | function reviewThreadsPage( |
| 241 | nodes: ReadonlyArray<Record<string, unknown>>, |
| 242 | endCursor: string | null, |
| 243 | ): string { |
| 244 | return encodeJson({ |
| 245 | data: { |
| 246 | repository: { |
| 247 | pullRequest: { |
| 248 | reviewThreads: { |
| 249 | totalCount: nodes.length, |
| 250 | pageInfo: { hasNextPage: endCursor !== null, endCursor }, |
| 251 | nodes, |
| 252 | }, |
| 253 | }, |
| 254 | }, |
| 255 | }, |
| 256 | }); |
| 257 | } |
| 258 | |
| 259 | function threadCommentsPage( |
| 260 | ids: ReadonlyArray<string>, |
| 261 | endCursor: string | null, |
| 262 | totalCount: number, |
| 263 | pullRequestId = "PR_7", |
| 264 | ): string { |
| 265 | return encodeJson({ |
| 266 | data: { |
| 267 | repository: { pullRequest: { id: "PR_7" } }, |
| 268 | node: { |
| 269 | pullRequest: { id: pullRequestId }, |
| 270 | comments: threadComments(ids, endCursor, totalCount), |
| 271 | }, |
| 272 | }, |
| 273 | }); |
| 274 | } |
| 275 | |
| 276 | /** What GitHub answers for a pull request it will not serve a whole diff for. */ |
| 277 | const diffRefused = new GitHubApi.GitHubApiResponseError({ |
| 278 | host: "github.com", |
| 279 | operation: "getPullRequestDiff", |
| 280 | status: 406, |
| 281 | }); |
| 282 | |
| 283 | /** The nth request the CLI made. */ |
| 284 | function callAt(index: number): ApiCall { |
| 285 | const call = mockedExecute.mock.calls[index]; |
| 286 | assert.isDefined(call); |
| 287 | return call[0]; |
| 288 | } |
| 289 | |
| 290 | /** The GraphQL variables of the nth request. */ |
| 291 | function varsAt(index: number): Readonly<Record<string, unknown>> { |
| 292 | const call = callAt(index); |
| 293 | assert.strictEqual(call.kind, "graphql"); |
| 294 | return call.kind === "graphql" ? (call.variables ?? {}) : {}; |
| 295 | } |
| 296 | |
| 297 | /** The GraphQL document of the nth request. */ |
| 298 | function queryAt(index: number): string { |
| 299 | const call = callAt(index); |
| 300 | return call.kind === "graphql" ? call.query : ""; |
| 301 | } |
| 302 | |
| 303 | /** The REST path of the nth request. */ |
| 304 | function pathAt(index: number): string { |
| 305 | const call = callAt(index); |
| 306 | return call.kind === "rest" ? call.path : ""; |
| 307 | } |
| 308 | |
| 309 | /** The search the nth request carried, or undefined for a read that did not search. */ |
| 310 | function searchOfCall(index: number): string | undefined { |
| 311 | const q = callAt(index).kind === "graphql" ? varsAt(index)["q"] : undefined; |
| 312 | return typeof q === "string" ? q : undefined; |
| 313 | } |
| 314 | |
| 315 | /** One row as a search answers it, which is the listing's row one connection deeper. */ |
| 316 | function searchItem(number: number, repository: string, updatedAt: string) { |
| 317 | return { |
| 318 | number, |
| 319 | title: `Pull request ${number}`, |
| 320 | url: `https://github.com/${repository}/pull/${number}`, |
| 321 | author: { login: "octocat", avatarUrl: "https://avatars/octocat" }, |
| 322 | headRefName: "feat/page", |
| 323 | baseRefName: "main", |
| 324 | state: "OPEN", |
| 325 | isDraft: false, |
| 326 | mergeable: "MERGEABLE", |
| 327 | createdAt: "2026-07-01T00:00:00Z", |
| 328 | updatedAt, |
| 329 | repository: { nameWithOwner: repository }, |
| 330 | reviewRequests: { nodes: [{ requestedReviewer: { login: "hubot" } }] }, |
| 331 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, |
| 332 | }; |
| 333 | } |
| 334 | |
| 335 | function searchPage(nodes: ReadonlyArray<unknown>, hasNextPage = false) { |
| 336 | return output(encodeJson({ data: { search: { pageInfo: { hasNextPage }, nodes } } })); |
| 337 | } |
| 338 | |
| 339 | const searchQueryOfCall = searchOfCall; |
| 340 | |
| 341 | /** |
| 342 | * Answers every request with the body of the first route whose needle its GraphQL document or |
| 343 | * REST path contains, and anything unrouted with an empty object, which a mutation never reads. |
| 344 | */ |
| 345 | function route(...routes: ReadonlyArray<readonly [needle: string, body: unknown]>): void { |
| 346 | mockedExecute.mockImplementation((call) => { |
| 347 | const target = call.kind === "graphql" ? call.query : call.path; |
| 348 | const match = routes.find(([needle]) => target.includes(needle)); |
| 349 | return Effect.succeed(output(match === undefined ? "{}" : encodeJson(match[1]))); |
| 350 | }); |
| 351 | } |
| 352 | |
| 353 | /** The variables of every GraphQL request whose document contains `needle`, in order. */ |
| 354 | function variablesOf(needle: string): ReadonlyArray<Readonly<Record<string, unknown>>> { |
| 355 | return mockedExecute.mock.calls.flatMap(([call]) => |
| 356 | call.kind === "graphql" && call.query.includes(needle) ? [call.variables ?? {}] : [], |
| 357 | ); |
| 358 | } |
| 359 | |
| 360 | /** Every REST request whose path contains `needle`, in order. */ |
| 361 | function restCallsTo(needle: string): ReadonlyArray<GitHubApi.GitHubRestInput> { |
| 362 | return mockedExecute.mock.calls.flatMap(([call]) => |
| 363 | call.kind === "rest" && call.path.includes(needle) ? [call] : [], |
| 364 | ); |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * The check that a client-given node hangs off the pull request it names. Routed before the node |
| 369 | * id lookup, whose document it contains. |
| 370 | */ |
| 371 | const SUBJECT_SCOPE_QUERY = "node(id: $subjectId)"; |
| 372 | const subjectScope = (subjectId: string, pullRequestId: string) => ({ |
| 373 | data: { |
| 374 | repository: { pullRequest: { id: pullRequestId } }, |
| 375 | node: { id: subjectId, pullRequest: { id: pullRequestId } }, |
| 376 | }, |
| 377 | }); |
| 378 | |
| 379 | /** One page of a head commit's check contexts, the read a rollup past one page is walked by. */ |
| 380 | const checkContextsPage = ( |
| 381 | nodes: ReadonlyArray<Record<string, unknown>>, |
| 382 | endCursor: string | null, |
| 383 | headRefOid = "abc123", |
| 384 | ) => |
| 385 | encodeJson({ |
| 386 | data: { |
| 387 | repository: { |
| 388 | pullRequest: { |
| 389 | headRefOid, |
| 390 | commits: { |
| 391 | nodes: [ |
| 392 | { |
| 393 | commit: { |
| 394 | statusCheckRollup: { |
| 395 | contexts: { nodes, pageInfo: { hasNextPage: endCursor !== null, endCursor } }, |
| 396 | }, |
| 397 | }, |
| 398 | }, |
| 399 | ], |
| 400 | }, |
| 401 | }, |
| 402 | }, |
| 403 | }, |
| 404 | }); |
| 405 | |
| 406 | /** A pull request's base and head, as the REST read a file expansion starts from answers them. */ |
| 407 | const pullRequestRefs = encodeJson({ base: { sha: "a1b2c3d" }, head: { sha: "b1c2d3e" } }); |
| 408 | |
| 409 | /** A cross-repository pull request whose head waits on a maintainer to run its workflows. */ |
| 410 | const crossRepositoryDetail = (headRefOid = "abc123") => |
| 411 | coreResponse({ |
| 412 | headRefName: "feat/page", |
| 413 | headRefOid, |
| 414 | isCrossRepository: true, |
| 415 | headRepositoryOwner: { login: "octocat" }, |
| 416 | }); |
| 417 | |
| 418 | /** Open pull requests on one head branch, every one of them from the same fork head. */ |
| 419 | const heads = (numbers: ReadonlyArray<number>) => ({ |
| 420 | data: { |
| 421 | repository: { |
| 422 | pullRequests: { |
| 423 | pageInfo: { hasNextPage: false, endCursor: null }, |
| 424 | nodes: numbers.map((number) => ({ |
| 425 | number, |
| 426 | headRefOid: "abc123", |
| 427 | isCrossRepository: true, |
| 428 | headRepositoryOwner: { login: "octocat" }, |
| 429 | })), |
| 430 | }, |
| 431 | }, |
| 432 | }, |
| 433 | }); |
| 434 | |
| 435 | const workflowRuns = (ids: ReadonlyArray<number>) => ({ |
| 436 | workflow_runs: ids.map((id) => ({ |
| 437 | id, |
| 438 | name: id === 10 ? "build" : `run ${id}`, |
| 439 | html_url: `https://example.com/${id}`, |
| 440 | })), |
| 441 | }); |
| 442 | |
| 443 | /** Answers the reads an approval makes: the detail, the heads, the runs; approvals return nothing. */ |
| 444 | function workflowApprovalRoutes( |
| 445 | detail: () => unknown, |
| 446 | headsAnswer: unknown, |
| 447 | runsAnswer: unknown, |
| 448 | ): void { |
| 449 | mockedExecute.mockImplementation((call) => |
| 450 | Effect.sync(() => |
| 451 | output( |
| 452 | call.kind === "rest" |
| 453 | ? call.path.endsWith("/approve") |
| 454 | ? "" |
| 455 | : encodeJson(runsAnswer) |
| 456 | : call.query.includes("headRefName: $head") |
| 457 | ? encodeJson(headsAnswer) |
| 458 | : encodeJson(detail()), |
| 459 | ), |
| 460 | ), |
| 461 | ); |
| 462 | } |
| 463 | |
| 464 | /** The pull request node id lookup, which the layer caches for every test after the first. */ |
| 465 | const NODE_ID_QUERY = "pullRequest(number: $number) { id }"; |
| 466 | const nodeIdAnswer = (id: string) => ({ data: { repository: { pullRequest: { id } } } }); |
| 467 | |
| 468 | afterEach(() => { |
| 469 | mockedExecute.mockReset(); |
| 470 | mockedStackMemberships.mockReset(); |
| 471 | mockedCredential.mockReset(); |
| 472 | mockedCredential.mockImplementation(defaultCredential); |
| 473 | }); |
| 474 | |
| 475 | it.effect( |
| 476 | "keeps a verified credential through an auth switch and separates token fingerprints", |
| 477 | () => |
| 478 | Effect.gen(function* () { |
| 479 | let activeToken = "broad-credential"; |
| 480 | const requests: Array<{ readonly url: string; readonly authorization: string | undefined }> = |
| 481 | []; |
| 482 | const credentials = Layer.succeed( |
| 483 | GitHubCredentials.GitHubCredentials, |
| 484 | GitHubCredentials.GitHubCredentials.of({ |
| 485 | get: (host) => |
| 486 | Effect.sync(() => ({ |
| 487 | host, |
| 488 | token: Redacted.make(activeToken), |
| 489 | source: "gh" as const, |
| 490 | fingerprint: `${host}:${activeToken.length}${activeToken.at(-1)}`, |
| 491 | })), |
| 492 | invalidate: () => Effect.void, |
| 493 | }), |
| 494 | ); |
| 495 | const http = Layer.succeed( |
| 496 | HttpClient.HttpClient, |
| 497 | HttpClient.make((request) => |
| 498 | Effect.sync(() => { |
| 499 | requests.push({ url: request.url, authorization: request.headers["authorization"] }); |
| 500 | const body = request.url.endsWith("/user") |
| 501 | ? { id: 123, login: "same-account" } |
| 502 | : { data: { repository: { pullRequest: { id: "PR_1" } }, addComment: {} } }; |
| 503 | return HttpClientResponse.fromWeb(request, new Response(encodeJson(body))); |
| 504 | }), |
| 505 | ), |
| 506 | ); |
| 507 | const cli = yield* GitHubPullRequestApi.make.pipe( |
| 508 | Effect.provide( |
| 509 | GitHubApi.layer.pipe( |
| 510 | Layer.provide(Layer.mergeAll(credentials, http)), |
| 511 | Layer.provide(GitHubQuota.layer), |
| 512 | Layer.provide(SourceControlRateLimit.layer), |
| 513 | Layer.merge(TestSourceControlHost.layer()), |
| 514 | Layer.provideMerge(NodeServices.layer), |
| 515 | ), |
| 516 | ), |
| 517 | ); |
| 518 | const input = { cwd: "/repo", host: "github.com" }; |
| 519 | const first = yield* cli.withVerifiedCredential(input, (identity) => |
| 520 | Effect.gen(function* () { |
| 521 | activeToken = "restricted-credential"; |
| 522 | expect(yield* cli.getViewerLogin(input)).toBe("same-account"); |
| 523 | yield* cli.commentOnPullRequest({ |
| 524 | ...input, |
| 525 | repository: "owner/repo", |
| 526 | number: 1, |
| 527 | body: "comment", |
| 528 | }); |
| 529 | return identity; |
| 530 | }), |
| 531 | ); |
| 532 | const second = yield* cli.withVerifiedCredential(input, Effect.succeed); |
| 533 | expect(first.accountId).toBe(second.accountId); |
| 534 | expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); |
| 535 | expect(encodeJson([first, second])).not.toContain("broad-credential"); |
| 536 | expect(encodeJson([first, second])).not.toContain("restricted-credential"); |
| 537 | // The comment was written under the credential the page verified, not the switched one. |
| 538 | expect( |
| 539 | requests |
| 540 | .filter((request) => request.url.endsWith("/graphql")) |
| 541 | .map((request) => request.authorization), |
| 542 | ).toEqual(["Bearer broad-credential", "Bearer broad-credential"]); |
| 543 | expect( |
| 544 | requests |
| 545 | .filter((request) => request.url.endsWith("/user")) |
| 546 | .map((request) => request.authorization), |
| 547 | ).toEqual(["Bearer broad-credential", "Bearer restricted-credential"]); |
| 548 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 549 | accountId: "123", |
| 550 | viewer: "same-account", |
| 551 | }); |
| 552 | }), |
| 553 | ); |
| 554 | |
| 555 | layer("GitHubPullRequestApi.layer", (it) => { |
| 556 | it.effect("holds background previews once an answer reports the reserve, until it resets", () => |
| 557 | Effect.gen(function* () { |
| 558 | const now = yield* Clock.currentTimeMillis; |
| 559 | const quota = yield* GitHubQuota.GitHubQuota; |
| 560 | const resetAt = "2099-08-13T14:00:00Z"; |
| 561 | yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); |
| 562 | // GitHub's count, not ours: the answer below reports the reserve has been reached. |
| 563 | mockedExecute.mockReturnValue( |
| 564 | Effect.succeed( |
| 565 | output( |
| 566 | encodeJson({ |
| 567 | data: { |
| 568 | repository: { |
| 569 | pullRequest: { |
| 570 | number: 7, |
| 571 | title: "Fast previews", |
| 572 | url: "https://preview-budget.example/acme/web/pull/7", |
| 573 | state: "OPEN", |
| 574 | isDraft: false, |
| 575 | createdAt: "2026-07-01T00:00:00Z", |
| 576 | author: null, |
| 577 | }, |
| 578 | }, |
| 579 | }, |
| 580 | }), |
| 581 | false, |
| 582 | false, |
| 583 | graphqlQuota(499, resetAt), |
| 584 | ), |
| 585 | ), |
| 586 | ); |
| 587 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 588 | const input = { |
| 589 | cwd: "/w", |
| 590 | repository: "acme/web", |
| 591 | host: "preview-budget.example", |
| 592 | number: 7, |
| 593 | }; |
| 594 | yield* cli.getPullRequestPreview(input); |
| 595 | const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); |
| 596 | expect(held._tag).toBe("SourceControlRateLimitPausedError"); |
| 597 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 598 | yield* TestClock.setTime(Date.parse(resetAt)); |
| 599 | yield* cli.getPullRequestPreview(input); |
| 600 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 601 | yield* TestClock.setTime(now); |
| 602 | }), |
| 603 | ); |
| 604 | |
| 605 | it.effect("loads the complete hover card with one GraphQL request", () => |
| 606 | Effect.gen(function* () { |
| 607 | mockedExecute.mockReturnValueOnce( |
| 608 | Effect.succeed( |
| 609 | output( |
| 610 | encodeJson({ |
| 611 | data: { |
| 612 | repository: { |
| 613 | pullRequest: { |
| 614 | number: 7, |
| 615 | title: "Fast previews", |
| 616 | url: "https://github.example/acme/web/pull/7", |
| 617 | state: "MERGED", |
| 618 | isDraft: false, |
| 619 | createdAt: "2026-07-01T00:00:00Z", |
| 620 | author: { |
| 621 | login: "octocat", |
| 622 | name: "Octo Cat", |
| 623 | avatarUrl: "https://github.example/avatar.png", |
| 624 | }, |
| 625 | }, |
| 626 | }, |
| 627 | }, |
| 628 | }), |
| 629 | ), |
| 630 | ), |
| 631 | ); |
| 632 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 633 | const preview = yield* cli.getPullRequestPreview({ |
| 634 | cwd: "/w", |
| 635 | repository: "acme/web", |
| 636 | host: "github.example", |
| 637 | number: 7, |
| 638 | }); |
| 639 | expect(preview).toEqual({ |
| 640 | number: 7, |
| 641 | title: "Fast previews", |
| 642 | url: "https://github.example/acme/web/pull/7", |
| 643 | state: "merged", |
| 644 | isDraft: false, |
| 645 | createdAt: "2026-07-01T00:00:00Z", |
| 646 | author: { |
| 647 | login: "octocat", |
| 648 | name: "Octo Cat", |
| 649 | avatarUrl: "https://github.example/avatar.png", |
| 650 | }, |
| 651 | }); |
| 652 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 653 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.example" }); |
| 654 | }), |
| 655 | ); |
| 656 | |
| 657 | it.effect("coalesces concurrent identity verification for the same host and credential", () => |
| 658 | Effect.gen(function* () { |
| 659 | mockedExecute.mockImplementation(() => |
| 660 | Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), |
| 661 | ); |
| 662 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 663 | const results = yield* Effect.all( |
| 664 | Array.from({ length: 4 }, () => |
| 665 | cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), |
| 666 | ), |
| 667 | { concurrency: 4 }, |
| 668 | ); |
| 669 | expect(results).toEqual( |
| 670 | Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), |
| 671 | ); |
| 672 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 673 | expect(pathAt(0)).toBe("user"); |
| 674 | }), |
| 675 | ); |
| 676 | |
| 677 | it.effect( |
| 678 | "lets another identity reader continue when the first verification is interrupted", |
| 679 | () => |
| 680 | Effect.gen(function* () { |
| 681 | const firstStarted = yield* Deferred.make<void>(); |
| 682 | let verifications = 0; |
| 683 | mockedExecute.mockImplementation(() => |
| 684 | Effect.gen(function* () { |
| 685 | if (++verifications === 1) { |
| 686 | yield* Deferred.succeed(firstStarted, undefined); |
| 687 | return yield* Effect.never; |
| 688 | } |
| 689 | return output('{"id":123,"login":"viewer"}'); |
| 690 | }), |
| 691 | ); |
| 692 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 693 | const input = { cwd: "/w", host: "github.identity-cancel.test" }; |
| 694 | const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 695 | yield* Deferred.await(firstStarted); |
| 696 | // The second reader is queued on the same credential's lock behind the first. |
| 697 | const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); |
| 698 | yield* Effect.yieldNow; |
| 699 | yield* Fiber.interrupt(first); |
| 700 | expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); |
| 701 | expect(verifications).toBe(2); |
| 702 | }), |
| 703 | ); |
| 704 | |
| 705 | it.effect("reads linked pull requests on one host together, filed back by position", () => |
| 706 | Effect.gen(function* () { |
| 707 | const node = (number: number) => ({ |
| 708 | number, |
| 709 | title: `Pull request ${number}`, |
| 710 | url: `https://github.com/acme/web/pull/${number}`, |
| 711 | author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, |
| 712 | baseRefName: "main", |
| 713 | headRefName: `feat/${number}`, |
| 714 | state: "OPEN", |
| 715 | isDraft: false, |
| 716 | mergeable: "MERGEABLE", |
| 717 | reviewDecision: null, |
| 718 | latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, |
| 719 | additions: 12, |
| 720 | deletions: 3, |
| 721 | changedFiles: 2, |
| 722 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 723 | mergedAt: null, |
| 724 | closedAt: null, |
| 725 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, |
| 726 | }); |
| 727 | mockedExecute.mockReturnValueOnce( |
| 728 | Effect.succeed( |
| 729 | output( |
| 730 | encodeJson({ |
| 731 | data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, |
| 732 | }), |
| 733 | ), |
| 734 | ), |
| 735 | ); |
| 736 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 737 | |
| 738 | const reads = yield* Effect.forEach( |
| 739 | [7, 8], |
| 740 | (number) => |
| 741 | cli.getPullRequestSummary({ |
| 742 | cwd: "/w", |
| 743 | repository: "acme/web", |
| 744 | host: "github.com", |
| 745 | number, |
| 746 | }), |
| 747 | { concurrency: "unbounded" }, |
| 748 | ).pipe(Effect.forkChild); |
| 749 | yield* TestClock.adjust("10 millis"); |
| 750 | const [seven, eight] = yield* Fiber.join(reads); |
| 751 | |
| 752 | assert.deepStrictEqual( |
| 753 | { |
| 754 | number: seven?.number, |
| 755 | state: seven?.state, |
| 756 | headBranch: seven?.headBranch, |
| 757 | author: seven?.author?.login, |
| 758 | changedFiles: seven?.changedFiles, |
| 759 | reviewDecision: seven?.reviewDecision, |
| 760 | checksState: seven?.checksState, |
| 761 | mergeability: seven?.mergeability, |
| 762 | }, |
| 763 | { |
| 764 | number: 7, |
| 765 | state: "open", |
| 766 | headBranch: "feat/7", |
| 767 | author: "octocat", |
| 768 | changedFiles: 2, |
| 769 | reviewDecision: "approved", |
| 770 | checksState: "passing", |
| 771 | mergeability: "mergeable", |
| 772 | }, |
| 773 | ); |
| 774 | assert.strictEqual(eight?.headBranch, "feat/8"); |
| 775 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 776 | expect(queryAt(0)).toContain( |
| 777 | "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", |
| 778 | ); |
| 779 | expect(varsAt(0)).toMatchObject({ |
| 780 | s0_owner: "acme", |
| 781 | s0_name: "web", |
| 782 | s0_number: 7, |
| 783 | s1_number: 8, |
| 784 | }); |
| 785 | }), |
| 786 | ); |
| 787 | |
| 788 | it.effect("fingerprints watched pull requests on one host in one read", () => |
| 789 | Effect.gen(function* () { |
| 790 | const node = (comments: number) => ({ |
| 791 | state: "OPEN", |
| 792 | mergeable: "MERGEABLE", |
| 793 | headRefOid: "abc123", |
| 794 | comments: { totalCount: comments, nodes: [] }, |
| 795 | reviews: { totalCount: 0, nodes: [] }, |
| 796 | reviewThreads: { totalCount: 0 }, |
| 797 | commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, |
| 798 | }); |
| 799 | mockedExecute.mockReturnValueOnce( |
| 800 | Effect.succeed( |
| 801 | output( |
| 802 | JSON.stringify({ |
| 803 | data: { w0: { pullRequest: node(1) }, w1: { pullRequest: null } }, |
| 804 | }), |
| 805 | ), |
| 806 | ), |
| 807 | ); |
| 808 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 809 | |
| 810 | const reads = yield* Effect.forEach( |
| 811 | [7, 8], |
| 812 | (number) => |
| 813 | cli.getPullRequestWatchFingerprint({ |
| 814 | cwd: "/w", |
| 815 | repository: "acme/web", |
| 816 | host: "github.com", |
| 817 | number, |
| 818 | }), |
| 819 | { concurrency: "unbounded" }, |
| 820 | ).pipe(Effect.forkChild); |
| 821 | yield* TestClock.adjust("10 millis"); |
| 822 | const [seven, eight] = yield* Fiber.join(reads); |
| 823 | |
| 824 | expect(seven?.remarks.startsWith("1 ")).toBe(true); |
| 825 | // GitHub had no answer for #8, so its watch reads it in full. |
| 826 | expect(eight).toBeNull(); |
| 827 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 828 | expect(queryAt(0)).toContain( |
| 829 | "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", |
| 830 | ); |
| 831 | expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); |
| 832 | }), |
| 833 | ); |
| 834 | |
| 835 | it.effect("reads a pull request the batch said nothing about on its own", () => |
| 836 | Effect.gen(function* () { |
| 837 | mockedExecute |
| 838 | .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) |
| 839 | .mockReturnValueOnce( |
| 840 | Effect.succeed( |
| 841 | output( |
| 842 | encodeJson({ |
| 843 | data: { |
| 844 | s0: { |
| 845 | pullRequest: { |
| 846 | number: 7, |
| 847 | title: "Reuse the summary", |
| 848 | url: "https://github.com/acme/web/pull/7", |
| 849 | author: { login: "octocat", name: "Octo Cat" }, |
| 850 | baseRefName: "main", |
| 851 | headRefName: "feat/summary", |
| 852 | state: "OPEN", |
| 853 | isDraft: false, |
| 854 | mergeable: "MERGEABLE", |
| 855 | reviewDecision: "APPROVED", |
| 856 | additions: 12, |
| 857 | deletions: 3, |
| 858 | changedFiles: 2, |
| 859 | updatedAt: "2026-08-24T12:34:56.000Z", |
| 860 | commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, |
| 861 | }, |
| 862 | }, |
| 863 | }, |
| 864 | }), |
| 865 | ), |
| 866 | ), |
| 867 | ); |
| 868 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 869 | |
| 870 | const read = yield* cli |
| 871 | .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) |
| 872 | .pipe(Effect.forkChild); |
| 873 | yield* TestClock.adjust("10 millis"); |
| 874 | const summary = yield* Fiber.join(read); |
| 875 | |
| 876 | assert.strictEqual(summary.headBranch, "feat/summary"); |
| 877 | assert.strictEqual(summary.checksState, "passing"); |
| 878 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 879 | expect(varsAt(1)).toEqual({ owner: "acme", name: "web", number: 7 }); |
| 880 | }), |
| 881 | ); |
| 882 | |
| 883 | it.effect("reads the stack a pull request is in through the stacks preview, on its host", () => |
| 884 | Effect.gen(function* () { |
| 885 | mockedExecute.mockReturnValueOnce( |
| 886 | Effect.succeed( |
| 887 | output( |
| 888 | encodeJson([ |
| 889 | { |
| 890 | id: 42, |
| 891 | number: 3, |
| 892 | url: "https://api.github.com/repos/acme/web/stacks/3", |
| 893 | base: { ref: "main" }, |
| 894 | pull_requests: [ |
| 895 | { |
| 896 | number: 6, |
| 897 | head: { ref: "feat/one" }, |
| 898 | state: "closed", |
| 899 | merged_at: "2026-09-02", |
| 900 | }, |
| 901 | { number: 7, head: { ref: "feat/two" }, state: "open", merged_at: null }, |
| 902 | ], |
| 903 | }, |
| 904 | ]), |
| 905 | ), |
| 906 | ), |
| 907 | ); |
| 908 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 909 | |
| 910 | const stack = yield* cli.getPullRequestStack({ |
| 911 | cwd: "/w", |
| 912 | repository: "acme/web", |
| 913 | host: "ghe.example.com", |
| 914 | number: 7, |
| 915 | }); |
| 916 | |
| 917 | assert.deepStrictEqual(stack, { |
| 918 | id: "42", |
| 919 | number: 3, |
| 920 | url: "https://api.github.com/repos/acme/web/stacks/3", |
| 921 | base: "main", |
| 922 | layers: [ |
| 923 | { number: 6, headBranch: "feat/one", state: "merged" }, |
| 924 | { number: 7, headBranch: "feat/two", state: "open" }, |
| 925 | ], |
| 926 | }); |
| 927 | expect(callAt(0)).toMatchObject({ |
| 928 | kind: "rest", |
| 929 | host: "ghe.example.com", |
| 930 | path: "repos/acme/web/stacks?pull_request=7", |
| 931 | }); |
| 932 | }), |
| 933 | ); |
| 934 | |
| 935 | it.effect("fetches layer titles only when the caller asks for stack details", () => |
| 936 | Effect.gen(function* () { |
| 937 | const minimal = { |
| 938 | url: "https://api.github.com/repos/acme/web/stacks/3", |
| 939 | number: 3, |
| 940 | base: { ref: "main" }, |
| 941 | pull_requests: [ |
| 942 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, |
| 943 | ], |
| 944 | }; |
| 945 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson([minimal])))); |
| 946 | mockedExecute.mockReturnValueOnce( |
| 947 | Effect.succeed( |
| 948 | output( |
| 949 | encodeJson({ |
| 950 | ...minimal, |
| 951 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], |
| 952 | }), |
| 953 | ), |
| 954 | ), |
| 955 | ); |
| 956 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 957 | const stack = yield* cli.getPullRequestStack({ |
| 958 | cwd: "/w", |
| 959 | repository: "acme/web", |
| 960 | host: "github.com", |
| 961 | number: 7, |
| 962 | includeDetails: true, |
| 963 | }); |
| 964 | expect(stack?.layers[0]).toMatchObject({ |
| 965 | title: "Second layer", |
| 966 | headSha: "abc123", |
| 967 | isDraft: false, |
| 968 | }); |
| 969 | expect(pathAt(1)).toBe("repos/acme/web/stacks/3"); |
| 970 | }), |
| 971 | ); |
| 972 | |
| 973 | it.effect("reads an empty stacks listing as not stacked", () => |
| 974 | Effect.gen(function* () { |
| 975 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); |
| 976 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 977 | |
| 978 | const stack = yield* cli.getPullRequestStack({ |
| 979 | cwd: "/w", |
| 980 | repository: "acme/web", |
| 981 | host: "github.com", |
| 982 | number: 7, |
| 983 | }); |
| 984 | |
| 985 | assert.isNull(stack); |
| 986 | }), |
| 987 | ); |
| 988 | |
| 989 | it.effect("reads a host that refuses the stacks preview as not stacked", () => |
| 990 | Effect.gen(function* () { |
| 991 | mockedExecute.mockReturnValueOnce( |
| 992 | Effect.fail( |
| 993 | new GitHubApi.GitHubApiNotFoundError({ |
| 994 | host: "github.com", |
| 995 | operation: "getPullRequestStack", |
| 996 | }), |
| 997 | ), |
| 998 | ); |
| 999 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1000 | |
| 1001 | const stack = yield* cli.getPullRequestStack({ |
| 1002 | cwd: "/w", |
| 1003 | repository: "acme/web", |
| 1004 | host: "github.com", |
| 1005 | number: 7, |
| 1006 | }); |
| 1007 | |
| 1008 | assert.isNull(stack); |
| 1009 | }), |
| 1010 | ); |
| 1011 | |
| 1012 | it.effect("does not read a refused credential as an unstacked pull request", () => |
| 1013 | Effect.gen(function* () { |
| 1014 | mockedExecute.mockReturnValueOnce( |
| 1015 | Effect.fail( |
| 1016 | new GitHubApi.GitHubApiAuthenticationError({ |
| 1017 | host: "github.com", |
| 1018 | operation: "getPullRequestStack", |
| 1019 | }), |
| 1020 | ), |
| 1021 | ); |
| 1022 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1023 | |
| 1024 | const error = yield* Effect.flip( |
| 1025 | cli.getPullRequestStack({ |
| 1026 | cwd: "/w", |
| 1027 | repository: "acme/web", |
| 1028 | host: "github.com", |
| 1029 | number: 7, |
| 1030 | }), |
| 1031 | ); |
| 1032 | |
| 1033 | assert.strictEqual(error._tag, "GitHubApiAuthenticationError"); |
| 1034 | }), |
| 1035 | ); |
| 1036 | |
| 1037 | it.effect("preserves transient stack failures instead of reporting no stack", () => |
| 1038 | Effect.gen(function* () { |
| 1039 | const failure = new GitHubApi.GitHubApiResponseError({ |
| 1040 | host: "github.com", |
| 1041 | operation: "getPullRequestStack", |
| 1042 | status: 503, |
| 1043 | }); |
| 1044 | mockedExecute.mockReturnValueOnce(Effect.fail(failure)); |
| 1045 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1046 | const error = yield* Effect.flip( |
| 1047 | cli.getPullRequestStack({ |
| 1048 | cwd: "/w", |
| 1049 | repository: "acme/web", |
| 1050 | host: "github.com", |
| 1051 | number: 7, |
| 1052 | }), |
| 1053 | ); |
| 1054 | assert.strictEqual(error, failure); |
| 1055 | }), |
| 1056 | ); |
| 1057 | |
| 1058 | it.effect("reports a stacks answer it cannot read against the stack read", () => |
| 1059 | Effect.gen(function* () { |
| 1060 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); |
| 1061 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1062 | |
| 1063 | const error = yield* Effect.flip( |
| 1064 | cli.getPullRequestStack({ |
| 1065 | cwd: "/w", |
| 1066 | repository: "acme/web", |
| 1067 | host: "github.com", |
| 1068 | number: 7, |
| 1069 | }), |
| 1070 | ); |
| 1071 | |
| 1072 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); |
| 1073 | if (error._tag !== "GitHubPullRequestReadError") return; |
| 1074 | assert.strictEqual(error.operation, "getPullRequestStack"); |
| 1075 | }), |
| 1076 | ); |
| 1077 | |
| 1078 | it.effect("asks for one row more than the page, to probe for a next page", () => |
| 1079 | Effect.gen(function* () { |
| 1080 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); |
| 1081 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1082 | |
| 1083 | const batch = yield* cli.listPullRequests({ |
| 1084 | cwd: "/w", |
| 1085 | repository: "acme/web", |
| 1086 | host: "github.com", |
| 1087 | state: "open", |
| 1088 | involvement: "all", |
| 1089 | viewer: "bilal", |
| 1090 | limit: 10, |
| 1091 | }); |
| 1092 | |
| 1093 | assert.strictEqual(batch.items.length, 3); |
| 1094 | assert.isFalse(batch.truncated); |
| 1095 | expect(callAt(0)).toMatchObject({ kind: "graphql", host: "github.com" }); |
| 1096 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); |
| 1097 | expect(queryAt(0)).toContain("first: 11"); |
| 1098 | }), |
| 1099 | ); |
| 1100 | |
| 1101 | it.effect("reports truncation from the extra row, counted before decoding", () => |
| 1102 | Effect.gen(function* () { |
| 1103 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); |
| 1104 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1105 | |
| 1106 | const batch = yield* cli.listPullRequests({ |
| 1107 | cwd: "/w", |
| 1108 | repository: "acme/web", |
| 1109 | host: "github.com", |
| 1110 | state: "open", |
| 1111 | involvement: "all", |
| 1112 | viewer: "bilal", |
| 1113 | limit: 10, |
| 1114 | }); |
| 1115 | |
| 1116 | assert.strictEqual(batch.items.length, 10); |
| 1117 | assert.isTrue(batch.truncated); |
| 1118 | }), |
| 1119 | ); |
| 1120 | |
| 1121 | it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => |
| 1122 | Effect.gen(function* () { |
| 1123 | // Two pages of 100 that hand back the same cursor, for a page of 250. |
| 1124 | const repeating = output( |
| 1125 | encodeJson({ |
| 1126 | data: { |
| 1127 | search: { |
| 1128 | pageInfo: { hasNextPage: true, endCursor: "same" }, |
| 1129 | nodes: rows(100, 1), |
| 1130 | }, |
| 1131 | }, |
| 1132 | }), |
| 1133 | ); |
| 1134 | mockedExecute.mockReturnValue(Effect.succeed(repeating)); |
| 1135 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1136 | |
| 1137 | const batch = yield* cli.listPullRequests({ |
| 1138 | cwd: "/w", |
| 1139 | repository: "acme/web", |
| 1140 | host: "github.com", |
| 1141 | state: "open", |
| 1142 | involvement: "all", |
| 1143 | viewer: "bilal", |
| 1144 | limit: 250, |
| 1145 | }); |
| 1146 | |
| 1147 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 1148 | assert.isTrue(batch.truncated); |
| 1149 | }), |
| 1150 | ); |
| 1151 | |
| 1152 | it.effect("excludes merged pull requests from the Closed tab", () => |
| 1153 | Effect.gen(function* () { |
| 1154 | mockedExecute.mockImplementation((call) => |
| 1155 | Effect.succeed( |
| 1156 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1157 | ? emptyList() |
| 1158 | : emptySearch(), |
| 1159 | ), |
| 1160 | ); |
| 1161 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1162 | |
| 1163 | yield* cli.listPullRequests({ |
| 1164 | cwd: "/w", |
| 1165 | repository: "acme/web", |
| 1166 | host: "github.com", |
| 1167 | state: "closed", |
| 1168 | involvement: "all", |
| 1169 | viewer: "bilal", |
| 1170 | limit: 10, |
| 1171 | }); |
| 1172 | |
| 1173 | // A closed pull request may also be a merged one, so the tab narrows through search. |
| 1174 | expect(searchOfCall(0)).toBe("is:pr is:closed is:unmerged sort:updated-desc repo:acme/web"); |
| 1175 | }), |
| 1176 | ); |
| 1177 | |
| 1178 | it.effect("narrows to the author on the authored tab", () => |
| 1179 | Effect.gen(function* () { |
| 1180 | mockedExecute.mockImplementation((call) => |
| 1181 | Effect.succeed( |
| 1182 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1183 | ? emptyList() |
| 1184 | : emptySearch(), |
| 1185 | ), |
| 1186 | ); |
| 1187 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1188 | |
| 1189 | yield* cli.listPullRequests({ |
| 1190 | cwd: "/w", |
| 1191 | repository: "acme/web", |
| 1192 | host: "github.com", |
| 1193 | state: "open", |
| 1194 | involvement: "authored", |
| 1195 | viewer: "bilal", |
| 1196 | limit: 10, |
| 1197 | }); |
| 1198 | |
| 1199 | expect(searchOfCall(0)).toBe("is:pr is:open author:bilal sort:updated-desc repo:acme/web"); |
| 1200 | }), |
| 1201 | ); |
| 1202 | |
| 1203 | it.effect("narrows through search on the reviewing tab", () => |
| 1204 | Effect.gen(function* () { |
| 1205 | mockedExecute.mockImplementation((call) => |
| 1206 | Effect.succeed( |
| 1207 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1208 | ? emptyList() |
| 1209 | : emptySearch(), |
| 1210 | ), |
| 1211 | ); |
| 1212 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1213 | |
| 1214 | yield* cli.listPullRequests({ |
| 1215 | cwd: "/w", |
| 1216 | repository: "acme/web", |
| 1217 | host: "github.com", |
| 1218 | state: "open", |
| 1219 | involvement: "reviewing", |
| 1220 | viewer: "bilal", |
| 1221 | limit: 10, |
| 1222 | }); |
| 1223 | |
| 1224 | expect(searchOfCall(0)).toBe( |
| 1225 | "is:pr is:open review-requested:bilal sort:updated-desc repo:acme/web", |
| 1226 | ); |
| 1227 | }), |
| 1228 | ); |
| 1229 | |
| 1230 | it.effect("carries every repository and every qualifier into one search", () => |
| 1231 | Effect.gen(function* () { |
| 1232 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1233 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1234 | |
| 1235 | yield* cli.searchPullRequests({ |
| 1236 | cwd: "/w", |
| 1237 | host: "github.com", |
| 1238 | repositories: ["acme/web", "pingdotgg/t3code"], |
| 1239 | state: "closed", |
| 1240 | involvement: "reviewing", |
| 1241 | viewer: "bilal", |
| 1242 | limit: 10, |
| 1243 | query: "pull requests page", |
| 1244 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, |
| 1245 | }); |
| 1246 | |
| 1247 | // One request for both repositories, carrying everything the per-repository read expresses |
| 1248 | // as a flag: the tab, the involvement, the reader's words, where to carry on from, and the |
| 1249 | // order the page reads in. |
| 1250 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 1251 | assert.strictEqual( |
| 1252 | searchQueryOfCall(0), |
| 1253 | 'is:pr is:closed is:unmerged review-requested:bilal "pull requests page" ' + |
| 1254 | "updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web repo:pingdotgg/t3code", |
| 1255 | ); |
| 1256 | }), |
| 1257 | ); |
| 1258 | |
| 1259 | it.effect("narrows a search to the author, and to merged on the merged tab", () => |
| 1260 | Effect.gen(function* () { |
| 1261 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1262 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1263 | |
| 1264 | yield* cli.searchPullRequests({ |
| 1265 | cwd: "/w", |
| 1266 | host: "github.com", |
| 1267 | repositories: ["acme/web"], |
| 1268 | state: "merged", |
| 1269 | involvement: "authored", |
| 1270 | viewer: "bilal", |
| 1271 | limit: 10, |
| 1272 | }); |
| 1273 | |
| 1274 | assert.strictEqual( |
| 1275 | searchQueryOfCall(0), |
| 1276 | "is:pr is:merged author:bilal sort:updated-desc repo:acme/web", |
| 1277 | ); |
| 1278 | }), |
| 1279 | ); |
| 1280 | |
| 1281 | it.effect("keeps a searched-for qualifier inside the phrase", () => |
| 1282 | Effect.gen(function* () { |
| 1283 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1284 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1285 | |
| 1286 | yield* cli.searchPullRequests({ |
| 1287 | cwd: "/w", |
| 1288 | host: "github.com", |
| 1289 | repositories: ["acme/web"], |
| 1290 | state: "open", |
| 1291 | involvement: "all", |
| 1292 | viewer: "bilal", |
| 1293 | limit: 10, |
| 1294 | query: 'x" is:merged repo:evil/repo', |
| 1295 | }); |
| 1296 | |
| 1297 | // Quoted and escaped, so the words a reader typed narrow the listing rather than widening it. |
| 1298 | assert.strictEqual( |
| 1299 | searchQueryOfCall(0), |
| 1300 | 'is:pr is:open "x\\" is:merged repo:evil/repo" sort:updated-desc repo:acme/web', |
| 1301 | ); |
| 1302 | }), |
| 1303 | ); |
| 1304 | |
| 1305 | it.effect("refuses to search for a repository GitHub cannot address", () => |
| 1306 | Effect.gen(function* () { |
| 1307 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1308 | |
| 1309 | const failure = yield* Effect.flip( |
| 1310 | cli.searchPullRequests({ |
| 1311 | cwd: "/w", |
| 1312 | host: "github.com", |
| 1313 | repositories: ["acme/web", "acme/web is:merged"], |
| 1314 | state: "open", |
| 1315 | involvement: "all", |
| 1316 | viewer: "bilal", |
| 1317 | limit: 10, |
| 1318 | }), |
| 1319 | ); |
| 1320 | |
| 1321 | // Nothing is sent: a name that could end its own qualifier is refused rather than escaped. |
| 1322 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); |
| 1323 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 1324 | }), |
| 1325 | ); |
| 1326 | |
| 1327 | it.effect("files each searched row under the repository it came from", () => |
| 1328 | Effect.gen(function* () { |
| 1329 | mockedExecute.mockReturnValue( |
| 1330 | Effect.succeed( |
| 1331 | searchPage([ |
| 1332 | searchItem(7, "acme/web", "2026-07-03T00:00:00Z"), |
| 1333 | searchItem(9, "pingdotgg/t3code", "2026-07-02T00:00:00Z"), |
| 1334 | // Not a pull request, which `is:pr` excludes and a decode skips rather than fails on. |
| 1335 | {}, |
| 1336 | ]), |
| 1337 | ), |
| 1338 | ); |
| 1339 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1340 | |
| 1341 | const batch = yield* cli.searchPullRequests({ |
| 1342 | cwd: "/w", |
| 1343 | host: "github.com", |
| 1344 | repositories: ["acme/web", "pingdotgg/t3code"], |
| 1345 | state: "open", |
| 1346 | involvement: "all", |
| 1347 | viewer: "bilal", |
| 1348 | limit: 10, |
| 1349 | }); |
| 1350 | |
| 1351 | assert.deepStrictEqual( |
| 1352 | batch.items.map((item) => [item.repository, item.number, item.author?.avatarUrl]), |
| 1353 | [ |
| 1354 | ["acme/web", 7, "https://avatars/octocat"], |
| 1355 | ["pingdotgg/t3code", 9, "https://avatars/octocat"], |
| 1356 | ], |
| 1357 | ); |
| 1358 | // The listing leaves the line counts to a read of their own. |
| 1359 | assert.deepStrictEqual( |
| 1360 | batch.items.map((item) => [item.additions, item.deletions]), |
| 1361 | [ |
| 1362 | [0, 0], |
| 1363 | [0, 0], |
| 1364 | ], |
| 1365 | ); |
| 1366 | assert.isFalse(batch.truncated); |
| 1367 | }), |
| 1368 | ); |
| 1369 | |
| 1370 | it.effect("reports truncation from the extra row, and from a page GitHub says has more", () => |
| 1371 | Effect.gen(function* () { |
| 1372 | mockedExecute |
| 1373 | .mockReturnValueOnce( |
| 1374 | Effect.succeed( |
| 1375 | searchPage([ |
| 1376 | searchItem(1, "acme/web", "2026-07-03T00:00:00Z"), |
| 1377 | searchItem(2, "acme/web", "2026-07-02T00:00:00Z"), |
| 1378 | searchItem(3, "acme/web", "2026-07-01T00:00:00Z"), |
| 1379 | ]), |
| 1380 | ), |
| 1381 | ) |
| 1382 | .mockReturnValueOnce( |
| 1383 | Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), |
| 1384 | ); |
| 1385 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1386 | const read = () => |
| 1387 | cli.searchPullRequests({ |
| 1388 | cwd: "/w", |
| 1389 | host: "github.com", |
| 1390 | repositories: ["acme/web"], |
| 1391 | state: "open", |
| 1392 | involvement: "all", |
| 1393 | viewer: "bilal", |
| 1394 | limit: 2, |
| 1395 | }); |
| 1396 | |
| 1397 | const overflowing = yield* read(); |
| 1398 | const capped = yield* read(); |
| 1399 | |
| 1400 | // The extra row is the probe, and it is not handed on. |
| 1401 | assert.strictEqual(overflowing.items.length, 2); |
| 1402 | assert.isTrue(overflowing.truncated); |
| 1403 | // A slice at GitHub's own ceiling has no extra row to probe with, so `hasNextPage` answers. |
| 1404 | assert.isTrue(capped.truncated); |
| 1405 | }), |
| 1406 | ); |
| 1407 | |
| 1408 | it.effect("enriches only the visible fallback rows after filtering and widening", () => |
| 1409 | Effect.gen(function* () { |
| 1410 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1411 | mockedExecute.mockReturnValueOnce( |
| 1412 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ isDraft: true })))), |
| 1413 | ); |
| 1414 | mockedExecute.mockReturnValueOnce( |
| 1415 | Effect.succeed(output(listedPullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), |
| 1416 | ); |
| 1417 | mockedStackMemberships.mockReturnValueOnce( |
| 1418 | Effect.succeed( |
| 1419 | output( |
| 1420 | encodeJson({ |
| 1421 | data: { |
| 1422 | s0: { |
| 1423 | pullRequest: { |
| 1424 | stack: { number: 3, size: 2, baseRefName: "main" }, |
| 1425 | stackEntry: { position: 1 }, |
| 1426 | }, |
| 1427 | }, |
| 1428 | s1: { pullRequest: { stack: null, stackEntry: null } }, |
| 1429 | }, |
| 1430 | }), |
| 1431 | ), |
| 1432 | ), |
| 1433 | ); |
| 1434 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1435 | const batch = yield* cli.listPullRequests({ |
| 1436 | cwd: "/w", |
| 1437 | repository: "acme/web", |
| 1438 | host: "github.com", |
| 1439 | state: "open", |
| 1440 | involvement: "all", |
| 1441 | viewer: "bilal", |
| 1442 | limit: 2, |
| 1443 | filters: { draft: "hide" }, |
| 1444 | }); |
| 1445 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); |
| 1446 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); |
| 1447 | expect(batch.items[1]?.stack).toBeUndefined(); |
| 1448 | expect(batch.truncated).toBe(true); |
| 1449 | expect(batch.continues).toBe(false); |
| 1450 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); |
| 1451 | const membership = mockedStackMemberships.mock.calls[0]?.[0]; |
| 1452 | const numbers = Object.entries( |
| 1453 | membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, |
| 1454 | ) |
| 1455 | .filter(([name]) => name.endsWith("_number")) |
| 1456 | .map(([, number]) => number); |
| 1457 | expect(numbers).toEqual([4, 5]); |
| 1458 | }), |
| 1459 | ); |
| 1460 | |
| 1461 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => |
| 1462 | Effect.gen(function* () { |
| 1463 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); |
| 1464 | mockedStackMemberships.mockImplementation((input) => |
| 1465 | input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) |
| 1466 | ? Effect.fail( |
| 1467 | new GitHubApi.GitHubApiResponseError({ |
| 1468 | host: "github.com", |
| 1469 | operation: "listPullRequestStackMemberships", |
| 1470 | status: 502, |
| 1471 | }), |
| 1472 | ) |
| 1473 | : Effect.succeed( |
| 1474 | output( |
| 1475 | encodeJson({ |
| 1476 | data: { |
| 1477 | s0: { |
| 1478 | pullRequest: { |
| 1479 | stack: { number: 3, size: 2, baseRefName: "main" }, |
| 1480 | stackEntry: { position: 1 }, |
| 1481 | }, |
| 1482 | }, |
| 1483 | }, |
| 1484 | }), |
| 1485 | ), |
| 1486 | ), |
| 1487 | ); |
| 1488 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1489 | const batch = yield* cli.listPullRequests({ |
| 1490 | cwd: "/w", |
| 1491 | repository: "acme/web", |
| 1492 | host: "github.com", |
| 1493 | state: "open", |
| 1494 | involvement: "all", |
| 1495 | viewer: "bilal", |
| 1496 | limit: 26, |
| 1497 | }); |
| 1498 | expect(batch.items.map((item) => item.number)).toEqual( |
| 1499 | Array.from({ length: 26 }, (_, i) => i + 1), |
| 1500 | ); |
| 1501 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); |
| 1502 | expect(batch.items[25]?.stack).toBeUndefined(); |
| 1503 | expect(batch.truncated).toBe(true); |
| 1504 | expect(batch.continues).toBe(true); |
| 1505 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); |
| 1506 | }), |
| 1507 | ); |
| 1508 | |
| 1509 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => |
| 1510 | Effect.gen(function* () { |
| 1511 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1512 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); |
| 1513 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); |
| 1514 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1515 | const input = { |
| 1516 | cwd: "/w", |
| 1517 | repository: "acme/web", |
| 1518 | state: "open" as const, |
| 1519 | involvement: "all" as const, |
| 1520 | viewer: "bilal", |
| 1521 | limit: 2, |
| 1522 | }; |
| 1523 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); |
| 1524 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); |
| 1525 | expect(empty.items).toEqual([]); |
| 1526 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); |
| 1527 | expect(mockedStackMemberships).not.toHaveBeenCalled(); |
| 1528 | }), |
| 1529 | ); |
| 1530 | |
| 1531 | it.effect("reads the line counts in chunks, and files them back by position", () => |
| 1532 | Effect.gen(function* () { |
| 1533 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ |
| 1534 | repository: "acme/web", |
| 1535 | number: index + 1, |
| 1536 | })); |
| 1537 | mockedExecute.mockImplementation(() => |
| 1538 | // Every chunk answers for its first alias only, so a row GitHub said nothing about is |
| 1539 | // dropped rather than shown as a change of no size. |
| 1540 | Effect.succeed( |
| 1541 | output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), |
| 1542 | ), |
| 1543 | ); |
| 1544 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1545 | |
| 1546 | const stats = yield* cli.listPullRequestStats({ |
| 1547 | cwd: "/w", |
| 1548 | host: "github.com", |
| 1549 | changeRequests, |
| 1550 | }); |
| 1551 | |
| 1552 | // Twenty-five aliases a request, so twenty-six rows are two requests. |
| 1553 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 1554 | assert.deepStrictEqual(stats, [ |
| 1555 | { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, |
| 1556 | { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, |
| 1557 | ]); |
| 1558 | expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); |
| 1559 | expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); |
| 1560 | }), |
| 1561 | ); |
| 1562 | |
| 1563 | it.effect("refuses to look up counts for a repository GitHub cannot address", () => |
| 1564 | Effect.gen(function* () { |
| 1565 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1566 | |
| 1567 | const failure = yield* Effect.flip( |
| 1568 | cli.listPullRequestStats({ |
| 1569 | cwd: "/w", |
| 1570 | host: "github.com", |
| 1571 | changeRequests: [{ repository: 'acme/web") { x } #', number: 1 }], |
| 1572 | }), |
| 1573 | ); |
| 1574 | |
| 1575 | assert.strictEqual(failure._tag, "GitHubRepositorySelectorError"); |
| 1576 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 1577 | }), |
| 1578 | ); |
| 1579 | |
| 1580 | it.effect("hands a search to GitHub rather than to the rows already read", () => |
| 1581 | Effect.gen(function* () { |
| 1582 | mockedExecute.mockImplementation((call) => |
| 1583 | Effect.succeed( |
| 1584 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1585 | ? emptyList() |
| 1586 | : emptySearch(), |
| 1587 | ), |
| 1588 | ); |
| 1589 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1590 | |
| 1591 | yield* cli.listPullRequests({ |
| 1592 | cwd: "/w", |
| 1593 | repository: "acme/web", |
| 1594 | host: "github.com", |
| 1595 | state: "open", |
| 1596 | involvement: "all", |
| 1597 | viewer: "bilal", |
| 1598 | limit: 10, |
| 1599 | query: "pull requests page", |
| 1600 | }); |
| 1601 | |
| 1602 | // The recency qualifier rides along, because free text would otherwise reorder the page |
| 1603 | // by relevance and truncation would drop the newest matches. |
| 1604 | expect(searchOfCall(0)).toBe( |
| 1605 | 'is:pr is:open "pull requests page" sort:updated-desc repo:acme/web', |
| 1606 | ); |
| 1607 | }), |
| 1608 | ); |
| 1609 | |
| 1610 | it.effect("joins a search onto the tab's own qualifiers instead of replacing them", () => |
| 1611 | Effect.gen(function* () { |
| 1612 | mockedExecute.mockImplementation((call) => |
| 1613 | Effect.succeed( |
| 1614 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1615 | ? emptyList() |
| 1616 | : emptySearch(), |
| 1617 | ), |
| 1618 | ); |
| 1619 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1620 | |
| 1621 | yield* cli.listPullRequests({ |
| 1622 | cwd: "/w", |
| 1623 | repository: "acme/web", |
| 1624 | host: "github.com", |
| 1625 | state: "closed", |
| 1626 | involvement: "reviewing", |
| 1627 | viewer: "bilal", |
| 1628 | limit: 10, |
| 1629 | query: "page", |
| 1630 | }); |
| 1631 | |
| 1632 | expect(searchOfCall(0)).toBe( |
| 1633 | 'is:pr is:closed is:unmerged review-requested:bilal "page" sort:updated-desc repo:acme/web', |
| 1634 | ); |
| 1635 | }), |
| 1636 | ); |
| 1637 | |
| 1638 | it.effect("carries the further narrowings into the search as qualifiers", () => |
| 1639 | Effect.gen(function* () { |
| 1640 | mockedExecute.mockImplementation((call) => |
| 1641 | Effect.succeed( |
| 1642 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1643 | ? emptyList() |
| 1644 | : emptySearch(), |
| 1645 | ), |
| 1646 | ); |
| 1647 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1648 | |
| 1649 | yield* cli.listPullRequests({ |
| 1650 | cwd: "/w", |
| 1651 | repository: "acme/web", |
| 1652 | host: "github.com", |
| 1653 | state: "open", |
| 1654 | involvement: "all", |
| 1655 | viewer: "bilal", |
| 1656 | limit: 10, |
| 1657 | filters: { |
| 1658 | draft: "hide", |
| 1659 | review: "changes-requested", |
| 1660 | checks: "failing", |
| 1661 | labels: [["needs design"], ['quo"te']], |
| 1662 | excludedLabels: ["wip"], |
| 1663 | author: "octocat", |
| 1664 | }, |
| 1665 | }); |
| 1666 | |
| 1667 | // Quotes around anything a reader typed, and the one character that could end a quoted |
| 1668 | // value early dropped rather than escaped. |
| 1669 | expect(searchOfCall(0)).toBe( |
| 1670 | 'is:pr is:open label:"needs design" label:"quote" -label:"wip" author:"octocat" ' + |
| 1671 | "draft:false review:changes_requested status:failure sort:updated-desc repo:acme/web", |
| 1672 | ); |
| 1673 | }), |
| 1674 | ); |
| 1675 | |
| 1676 | it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => |
| 1677 | Effect.gen(function* () { |
| 1678 | mockedExecute.mockImplementation((call) => |
| 1679 | Effect.succeed( |
| 1680 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1681 | ? emptyList() |
| 1682 | : emptySearch(), |
| 1683 | ), |
| 1684 | ); |
| 1685 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1686 | |
| 1687 | yield* cli.listPullRequests({ |
| 1688 | cwd: "/w", |
| 1689 | repository: "acme/web", |
| 1690 | host: "github.com", |
| 1691 | state: "open", |
| 1692 | involvement: "all", |
| 1693 | viewer: "bilal", |
| 1694 | limit: 10, |
| 1695 | filters: { author: "me" }, |
| 1696 | }); |
| 1697 | |
| 1698 | expect(searchOfCall(0)).toBe('is:pr is:open author:"bilal" sort:updated-desc repo:acme/web'); |
| 1699 | }), |
| 1700 | ); |
| 1701 | |
| 1702 | it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => |
| 1703 | Effect.gen(function* () { |
| 1704 | mockedExecute.mockImplementation((call) => |
| 1705 | Effect.succeed( |
| 1706 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1707 | ? emptyList() |
| 1708 | : emptySearch(), |
| 1709 | ), |
| 1710 | ); |
| 1711 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1712 | |
| 1713 | yield* cli.listPullRequests({ |
| 1714 | cwd: "/w", |
| 1715 | repository: "acme/web", |
| 1716 | host: "github.com", |
| 1717 | state: "open", |
| 1718 | involvement: "all", |
| 1719 | viewer: "bilal", |
| 1720 | limit: 10, |
| 1721 | filters: { labels: [["size:S", "size:XS"], ["bug"]] }, |
| 1722 | }); |
| 1723 | |
| 1724 | // One qualifier satisfied by either size, and a second one that must hold as well. |
| 1725 | expect(searchOfCall(0)).toBe( |
| 1726 | 'is:pr is:open label:"size:S","size:XS" label:"bug" sort:updated-desc repo:acme/web', |
| 1727 | ); |
| 1728 | }), |
| 1729 | ); |
| 1730 | |
| 1731 | it.effect( |
| 1732 | "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", |
| 1733 | () => |
| 1734 | Effect.gen(function* () { |
| 1735 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1736 | mockedExecute.mockReturnValueOnce( |
| 1737 | Effect.succeed( |
| 1738 | output( |
| 1739 | listedPullRequests(2, 1, (number) => ({ |
| 1740 | checks: number === 1 ? "SUCCESS" : "FAILURE", |
| 1741 | })), |
| 1742 | ), |
| 1743 | ), |
| 1744 | ); |
| 1745 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1746 | |
| 1747 | const batch = yield* cli.listPullRequests({ |
| 1748 | cwd: "/w", |
| 1749 | repository: "acme/web", |
| 1750 | host: "github.com", |
| 1751 | state: "open", |
| 1752 | involvement: "all", |
| 1753 | viewer: "bilal", |
| 1754 | limit: 10, |
| 1755 | filters: { checks: "passing" }, |
| 1756 | }); |
| 1757 | |
| 1758 | // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is |
| 1759 | // now a filter the fallback judges itself, the same as `draft`: an empty search answer |
| 1760 | // under it is still ambiguous, and the row picked out afterwards is the one whose own |
| 1761 | // `checksState` reads "passing". |
| 1762 | expect(searchOfCall(1)).toBeUndefined(); |
| 1763 | assert.deepStrictEqual( |
| 1764 | batch.items.map((item) => item.number), |
| 1765 | [1], |
| 1766 | ); |
| 1767 | }), |
| 1768 | ); |
| 1769 | |
| 1770 | it.effect("fails a checks filter for a row whose checks are still pending", () => |
| 1771 | Effect.gen(function* () { |
| 1772 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1773 | mockedExecute.mockReturnValueOnce( |
| 1774 | Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), |
| 1775 | ); |
| 1776 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1777 | |
| 1778 | const batch = yield* cli.listPullRequests({ |
| 1779 | cwd: "/w", |
| 1780 | repository: "acme/web", |
| 1781 | host: "github.com", |
| 1782 | state: "open", |
| 1783 | involvement: "all", |
| 1784 | viewer: "bilal", |
| 1785 | limit: 10, |
| 1786 | filters: { checks: "passing" }, |
| 1787 | }); |
| 1788 | |
| 1789 | // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — |
| 1790 | // the same row would also be dropped by `checks: "failing"`. |
| 1791 | assert.deepStrictEqual(batch.items, []); |
| 1792 | }), |
| 1793 | ); |
| 1794 | |
| 1795 | it.effect( |
| 1796 | "falls back for a repository the index does not cover even under a judgeable filter", |
| 1797 | () => |
| 1798 | Effect.gen(function* () { |
| 1799 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1800 | mockedExecute.mockReturnValueOnce( |
| 1801 | Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), |
| 1802 | ); |
| 1803 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1804 | |
| 1805 | const batch = yield* cli.listPullRequests({ |
| 1806 | cwd: "/w", |
| 1807 | repository: "acme/web", |
| 1808 | host: "github.com", |
| 1809 | state: "open", |
| 1810 | involvement: "all", |
| 1811 | viewer: "bilal", |
| 1812 | limit: 10, |
| 1813 | filters: { draft: "hide" }, |
| 1814 | }); |
| 1815 | |
| 1816 | // `draft` is a filter the fallback can judge over its own rows just as search judges it, |
| 1817 | // so an empty search answer under it alone is still ambiguous between "nothing matches" |
| 1818 | // and "this repository is not indexed" — and the fallback applies the filter itself, |
| 1819 | // keeping only the non-draft row. |
| 1820 | expect(searchOfCall(1)).toBeUndefined(); |
| 1821 | expect(batch.items.map((item) => item.number)).toEqual([2]); |
| 1822 | }), |
| 1823 | ); |
| 1824 | |
| 1825 | it.effect("carries the further narrowings into a batched search", () => |
| 1826 | Effect.gen(function* () { |
| 1827 | mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); |
| 1828 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1829 | |
| 1830 | yield* cli.searchPullRequests({ |
| 1831 | cwd: "/w", |
| 1832 | host: "github.com", |
| 1833 | repositories: ["acme/web"], |
| 1834 | state: "open", |
| 1835 | involvement: "all", |
| 1836 | viewer: "bilal", |
| 1837 | limit: 10, |
| 1838 | filters: { draft: "only", review: "none", labels: [["bug"]] }, |
| 1839 | }); |
| 1840 | |
| 1841 | assert.strictEqual( |
| 1842 | searchQueryOfCall(0), |
| 1843 | 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', |
| 1844 | ); |
| 1845 | }), |
| 1846 | ); |
| 1847 | |
| 1848 | it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => |
| 1849 | Effect.gen(function* () { |
| 1850 | mockedExecute.mockImplementation((call) => |
| 1851 | Effect.succeed( |
| 1852 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1853 | ? emptyList() |
| 1854 | : emptySearch(), |
| 1855 | ), |
| 1856 | ); |
| 1857 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1858 | |
| 1859 | yield* cli.listPullRequests({ |
| 1860 | cwd: "/w", |
| 1861 | repository: "acme/web", |
| 1862 | host: "github.com", |
| 1863 | state: "open", |
| 1864 | involvement: "all", |
| 1865 | viewer: "bilal", |
| 1866 | limit: 10, |
| 1867 | query: '-- is:merged label:secret "widen me"', |
| 1868 | }); |
| 1869 | |
| 1870 | // Every word stays inside one phrase: nothing before it, nothing after it, and the |
| 1871 | // leading dashes are text rather than the start of another argument. |
| 1872 | expect(searchOfCall(0)).toBe( |
| 1873 | String.raw`is:pr is:open "-- is:merged label:secret \"widen me\"" sort:updated-desc repo:acme/web`, |
| 1874 | ); |
| 1875 | }), |
| 1876 | ); |
| 1877 | |
| 1878 | it.effect("escapes a backslash before the quote it would otherwise let out", () => |
| 1879 | Effect.gen(function* () { |
| 1880 | mockedExecute.mockImplementation((call) => |
| 1881 | Effect.succeed( |
| 1882 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1883 | ? emptyList() |
| 1884 | : emptySearch(), |
| 1885 | ), |
| 1886 | ); |
| 1887 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1888 | |
| 1889 | yield* cli.listPullRequests({ |
| 1890 | cwd: "/w", |
| 1891 | repository: "acme/web", |
| 1892 | host: "github.com", |
| 1893 | state: "open", |
| 1894 | involvement: "all", |
| 1895 | viewer: "bilal", |
| 1896 | limit: 10, |
| 1897 | query: String.raw`a\" is:merged`, |
| 1898 | }); |
| 1899 | |
| 1900 | // GitHub reads `\\` as one backslash and `\"` as one quote, so the phrase ends where |
| 1901 | // this says it does; escaping the quote alone would have closed it early. |
| 1902 | expect(searchOfCall(0)).toBe( |
| 1903 | String.raw`is:pr is:open "a\\\" is:merged" sort:updated-desc repo:acme/web`, |
| 1904 | ); |
| 1905 | }), |
| 1906 | ); |
| 1907 | |
| 1908 | it.effect("asks for nothing but the order when the reader typed only spaces", () => |
| 1909 | Effect.gen(function* () { |
| 1910 | mockedExecute.mockImplementation((call) => |
| 1911 | Effect.succeed( |
| 1912 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 1913 | ? emptyList() |
| 1914 | : emptySearch(), |
| 1915 | ), |
| 1916 | ); |
| 1917 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1918 | |
| 1919 | yield* cli.listPullRequests({ |
| 1920 | cwd: "/w", |
| 1921 | repository: "acme/web", |
| 1922 | host: "github.com", |
| 1923 | state: "open", |
| 1924 | involvement: "all", |
| 1925 | viewer: "bilal", |
| 1926 | limit: 10, |
| 1927 | query: " ", |
| 1928 | }); |
| 1929 | |
| 1930 | // An empty phrase would match nothing rather than everything, so it is left out; the |
| 1931 | // order the page reads rows in is asked for whether or not anything was typed. |
| 1932 | expect(searchOfCall(0)).toBe("is:pr is:open sort:updated-desc repo:acme/web"); |
| 1933 | }), |
| 1934 | ); |
| 1935 | |
| 1936 | it.effect("carries on from the instant the last slice ended on", () => |
| 1937 | Effect.gen(function* () { |
| 1938 | mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); |
| 1939 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1940 | |
| 1941 | const batch = yield* cli.listPullRequests({ |
| 1942 | cwd: "/w", |
| 1943 | repository: "acme/web", |
| 1944 | host: "github.com", |
| 1945 | state: "open", |
| 1946 | involvement: "all", |
| 1947 | viewer: "bilal", |
| 1948 | limit: 10, |
| 1949 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, |
| 1950 | }); |
| 1951 | |
| 1952 | // Inclusive, so the rows already sent at that instant come back for the caller to drop — |
| 1953 | // which is what keeps the ones beside them from being skipped. |
| 1954 | expect(searchOfCall(0)).toBe( |
| 1955 | "is:pr is:open updated:<=2026-07-02T00:00:00Z sort:updated-desc repo:acme/web", |
| 1956 | ); |
| 1957 | assert.isTrue(batch.continues); |
| 1958 | }), |
| 1959 | ); |
| 1960 | |
| 1961 | it.effect("answers a search that found nothing with nothing, not with the whole repository", () => |
| 1962 | Effect.gen(function* () { |
| 1963 | // The fallback is for a repository the index does not cover. Under a text search an empty |
| 1964 | // answer means the text matched nothing, and listing everything instead would fill the |
| 1965 | // page with rows the reader did not search for. |
| 1966 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1967 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1968 | |
| 1969 | const batch = yield* cli.listPullRequests({ |
| 1970 | cwd: "/w", |
| 1971 | repository: "acme/web", |
| 1972 | host: "github.com", |
| 1973 | state: "open", |
| 1974 | involvement: "all", |
| 1975 | viewer: "bilal", |
| 1976 | limit: 10, |
| 1977 | query: "fdsfklj", |
| 1978 | }); |
| 1979 | |
| 1980 | assert.strictEqual(batch.items.length, 0); |
| 1981 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 1982 | }), |
| 1983 | ); |
| 1984 | |
| 1985 | it.effect("reads a repository GitHub will not search from its own list", () => |
| 1986 | Effect.gen(function* () { |
| 1987 | // GitHub answers for a repository outside its search index with no rows and no error. |
| 1988 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 1989 | mockedExecute.mockReturnValueOnce( |
| 1990 | Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), |
| 1991 | ); |
| 1992 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 1993 | |
| 1994 | const batch = yield* cli.listPullRequests({ |
| 1995 | cwd: "/w", |
| 1996 | repository: "acme/web", |
| 1997 | host: "github.com", |
| 1998 | state: "closed", |
| 1999 | involvement: "all", |
| 2000 | viewer: "bilal", |
| 2001 | limit: 10, |
| 2002 | }); |
| 2003 | |
| 2004 | assert.strictEqual(batch.items.length, 3); |
| 2005 | // The fallback itself uses no search, then narrows the decoded rows locally. They arrive |
| 2006 | // newest-created first, so nothing can carry on from them. |
| 2007 | expect(searchOfCall(1)).toBeUndefined(); |
| 2008 | expect(varsAt(1)).toMatchObject({ owner: "acme", name: "web", states: ["CLOSED", "MERGED"] }); |
| 2009 | assert.isFalse(batch.continues); |
| 2010 | }), |
| 2011 | ); |
| 2012 | |
| 2013 | it.effect("keeps state and involvement filters on the search-free fallback", () => |
| 2014 | Effect.gen(function* () { |
| 2015 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 2016 | mockedExecute.mockReturnValueOnce( |
| 2017 | Effect.succeed( |
| 2018 | output( |
| 2019 | listedPullRequests(4, 1, (number) => ({ |
| 2020 | state: number === 4 ? "OPEN" : "CLOSED", |
| 2021 | ...(number === 3 ? { mergedAt: "2026-07-03T00:00:00Z" } : {}), |
| 2022 | reviewRequests: |
| 2023 | number === 2 ? [{ slug: "platform", name: "Platform" }] : [{ login: "bilal" }], |
| 2024 | })), |
| 2025 | ), |
| 2026 | ), |
| 2027 | ); |
| 2028 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2029 | |
| 2030 | const batch = yield* cli.listPullRequests({ |
| 2031 | cwd: "/w", |
| 2032 | repository: "acme/web", |
| 2033 | host: "github.com", |
| 2034 | state: "closed", |
| 2035 | involvement: "reviewing", |
| 2036 | viewer: "bilal", |
| 2037 | limit: 10, |
| 2038 | }); |
| 2039 | |
| 2040 | // Individual requests for this viewer and team requests survive. The fallback cannot |
| 2041 | // resolve team membership, so dropping team-routed reviews would hide legitimate work. |
| 2042 | expect(batch.items.map((item) => item.number)).toEqual([1, 2]); |
| 2043 | expect(searchOfCall(1)).toBeUndefined(); |
| 2044 | assert.isFalse(batch.continues); |
| 2045 | }), |
| 2046 | ); |
| 2047 | |
| 2048 | it.effect("grows the search-free fallback until it fills the filtered page", () => |
| 2049 | Effect.gen(function* () { |
| 2050 | const unrelated = () => ({ reviewRequests: [{ login: "somebody-else" }] }); |
| 2051 | mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); |
| 2052 | mockedExecute.mockReturnValueOnce( |
| 2053 | Effect.succeed(output(listedPullRequests(3, 1, unrelated))), |
| 2054 | ); |
| 2055 | mockedExecute.mockReturnValueOnce( |
| 2056 | Effect.succeed( |
| 2057 | output( |
| 2058 | listedPullRequests(4, 1, (number) => |
| 2059 | number === 4 ? { reviewRequests: [{ login: "bilal" }] } : unrelated(), |
| 2060 | ), |
| 2061 | ), |
| 2062 | ), |
| 2063 | ); |
| 2064 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2065 | |
| 2066 | const batch = yield* cli.listPullRequests({ |
| 2067 | cwd: "/w", |
| 2068 | repository: "acme/web", |
| 2069 | host: "github.com", |
| 2070 | state: "open", |
| 2071 | involvement: "reviewing", |
| 2072 | viewer: "bilal", |
| 2073 | limit: 2, |
| 2074 | }); |
| 2075 | |
| 2076 | expect(batch.items.map((item) => item.number)).toEqual([4]); |
| 2077 | expect(queryAt(1)).toContain("first: 3,"); |
| 2078 | expect(queryAt(2)).toContain("first: 6,"); |
| 2079 | assert.isFalse(batch.truncated); |
| 2080 | }), |
| 2081 | ); |
| 2082 | |
| 2083 | it.effect("bounds a sparse search-free fallback and reports the unread tail", () => |
| 2084 | Effect.gen(function* () { |
| 2085 | // A repository with far more pull requests than the bound, none of them for this reader. |
| 2086 | let listed = 0; |
| 2087 | mockedExecute.mockImplementation((call) => { |
| 2088 | if (call.kind !== "graphql" || !call.query.includes("pullRequests(")) { |
| 2089 | return Effect.succeed(emptySearch()); |
| 2090 | } |
| 2091 | const first = Number(/first: (\d+)/.exec(call.query)?.[1]); |
| 2092 | const nodes = rows(first, listed + 1, () => ({ |
| 2093 | reviewRequests: [{ login: "somebody-else" }], |
| 2094 | })); |
| 2095 | listed += first; |
| 2096 | return Effect.succeed( |
| 2097 | output( |
| 2098 | encodeJson({ |
| 2099 | data: { |
| 2100 | repository: { |
| 2101 | pullRequests: { pageInfo: { hasNextPage: true, endCursor: `c${listed}` }, nodes }, |
| 2102 | }, |
| 2103 | }, |
| 2104 | }), |
| 2105 | ), |
| 2106 | ); |
| 2107 | }); |
| 2108 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2109 | |
| 2110 | const batch = yield* cli.listPullRequests({ |
| 2111 | cwd: "/w", |
| 2112 | repository: "acme/web", |
| 2113 | host: "github.com", |
| 2114 | state: "open", |
| 2115 | involvement: "reviewing", |
| 2116 | viewer: "bilal", |
| 2117 | limit: 2, |
| 2118 | }); |
| 2119 | |
| 2120 | // The last widening reads a thousand rows, a page of a hundred at a time, and stops there. |
| 2121 | const last = mockedExecute.mock.calls.length - 1; |
| 2122 | expect(varsAt(last)["after"]).toBe(`c${listed - 100}`); |
| 2123 | expect(listed - (3 + 6 + 12 + 24 + 48 + 96 + 192 + 384 + 768)).toBe(1000); |
| 2124 | assert.strictEqual(batch.items.length, 0); |
| 2125 | assert.isTrue(batch.truncated); |
| 2126 | }), |
| 2127 | ); |
| 2128 | |
| 2129 | it.effect("takes an empty slice for a repository that has run out, not one to read again", () => |
| 2130 | Effect.gen(function* () { |
| 2131 | mockedExecute.mockImplementation((call) => |
| 2132 | Effect.succeed( |
| 2133 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 2134 | ? emptyList() |
| 2135 | : emptySearch(), |
| 2136 | ), |
| 2137 | ); |
| 2138 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2139 | |
| 2140 | yield* cli.listPullRequests({ |
| 2141 | cwd: "/w", |
| 2142 | repository: "acme/web", |
| 2143 | host: "github.com", |
| 2144 | state: "open", |
| 2145 | involvement: "all", |
| 2146 | viewer: "bilal", |
| 2147 | limit: 10, |
| 2148 | cursor: { updatedBefore: "2026-07-02T00:00:00Z", delivered: 10 }, |
| 2149 | }); |
| 2150 | |
| 2151 | // A repository that answered the search once answers it again, so an empty slice under a |
| 2152 | // cursor is the end of it rather than a repository search cannot reach. |
| 2153 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 2154 | }), |
| 2155 | ); |
| 2156 | |
| 2157 | /** The state a merge or branch update reads first: behind its base and blocked on checks. */ |
| 2158 | const actionState = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ |
| 2159 | data: { |
| 2160 | repository: { |
| 2161 | pullRequest: { |
| 2162 | id: "PR_7", |
| 2163 | headRefOid: "abc123", |
| 2164 | isMergeQueueEnabled: false, |
| 2165 | mergeStateStatus: "BLOCKED", |
| 2166 | baseRef: { compare: { behindBy: 2 } }, |
| 2167 | ...pullRequest, |
| 2168 | }, |
| 2169 | }, |
| 2170 | }, |
| 2171 | }); |
| 2172 | const mergeMessage = (body: string, isMergeQueueEnabled = false) => ({ |
| 2173 | data: { |
| 2174 | repository: { |
| 2175 | pullRequest: { isMergeQueueEnabled, headRefOid: "abc123", viewerMergeBodyText: body }, |
| 2176 | }, |
| 2177 | }, |
| 2178 | }); |
| 2179 | |
| 2180 | it.effect("updates a stale branch with a merge commit unless asked to rebase", () => |
| 2181 | Effect.gen(function* () { |
| 2182 | route(["query PullRequestActionState", actionState()]); |
| 2183 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2184 | |
| 2185 | yield* cli.runPullRequestAction({ |
| 2186 | cwd: "/w", |
| 2187 | repository: "acme/web", |
| 2188 | host: "github.com", |
| 2189 | number: 7, |
| 2190 | action: "update-branch", |
| 2191 | }); |
| 2192 | yield* cli.runPullRequestAction({ |
| 2193 | cwd: "/w", |
| 2194 | repository: "acme/web", |
| 2195 | host: "github.com", |
| 2196 | number: 7, |
| 2197 | action: "update-branch", |
| 2198 | updateMethod: "rebase", |
| 2199 | }); |
| 2200 | |
| 2201 | // GitHub's own default: a merge commit unless asked to rebase, pinned to the head it read. |
| 2202 | expect(variablesOf("updatePullRequestBranch(")).toEqual([ |
| 2203 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "MERGE" }, |
| 2204 | { pullRequestId: "PR_7", expectedHeadOid: "abc123", updateMethod: "REBASE" }, |
| 2205 | ]); |
| 2206 | }), |
| 2207 | ); |
| 2208 | |
| 2209 | it.effect("merges with the strategy it was asked for", () => |
| 2210 | Effect.gen(function* () { |
| 2211 | route(["query PullRequestActionState", actionState()]); |
| 2212 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2213 | |
| 2214 | yield* cli.runPullRequestAction({ |
| 2215 | cwd: "/w", |
| 2216 | repository: "acme/web", |
| 2217 | host: "github.com", |
| 2218 | number: 7, |
| 2219 | action: "merge", |
| 2220 | mergeMethod: "squash", |
| 2221 | }); |
| 2222 | |
| 2223 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2224 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, |
| 2225 | ]); |
| 2226 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); |
| 2227 | }), |
| 2228 | ); |
| 2229 | |
| 2230 | it.effect.each([ |
| 2231 | { |
| 2232 | description: "base-only write", |
| 2233 | basePermission: "WRITE", |
| 2234 | sourcePermission: "READ", |
| 2235 | headRef: { id: "REF" }, |
| 2236 | expected: false, |
| 2237 | actionAllowed: false, |
| 2238 | }, |
| 2239 | { |
| 2240 | description: "source-only write", |
| 2241 | basePermission: "READ", |
| 2242 | sourcePermission: "WRITE", |
| 2243 | headRef: { id: "REF" }, |
| 2244 | expected: true, |
| 2245 | actionAllowed: true, |
| 2246 | }, |
| 2247 | { |
| 2248 | description: "a deleted branch", |
| 2249 | basePermission: "READ", |
| 2250 | sourcePermission: "WRITE", |
| 2251 | headRef: null, |
| 2252 | expected: false, |
| 2253 | actionAllowed: true, |
| 2254 | }, |
| 2255 | { |
| 2256 | description: "an unavailable source repository", |
| 2257 | basePermission: "WRITE", |
| 2258 | sourcePermission: null, |
| 2259 | headRef: { id: "REF" }, |
| 2260 | expected: false, |
| 2261 | actionAllowed: false, |
| 2262 | }, |
| 2263 | ])( |
| 2264 | "uses source branch access in both permission reads for $description", |
| 2265 | ({ basePermission, sourcePermission, headRef, expected, actionAllowed }) => |
| 2266 | Effect.gen(function* () { |
| 2267 | const answer = coreResponse({ |
| 2268 | headRepository: sourcePermission === null ? null : { viewerPermission: sourcePermission }, |
| 2269 | headRef, |
| 2270 | }); |
| 2271 | answer.data.repository.viewerPermission = basePermission; |
| 2272 | mockedExecute.mockReturnValue(Effect.succeed(output(encodeJson(answer)))); |
| 2273 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2274 | const input = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; |
| 2275 | expect((yield* cli.getViewerAccess(input)).canDeleteBranch).toBe(actionAllowed); |
| 2276 | expect((yield* cli.getPullRequestDetail(input)).viewerAccess.canDeleteBranch).toBe( |
| 2277 | expected, |
| 2278 | ); |
| 2279 | for (const [call] of mockedExecute.mock.calls) { |
| 2280 | if ( |
| 2281 | call.kind === "graphql" && |
| 2282 | !call.query.includes("query PullRequestStackMemberships") |
| 2283 | ) { |
| 2284 | if (call.query.includes("$headRef: String!")) { |
| 2285 | expect(call.query).toContain("headRepository { nameWithOwner viewerPermission }"); |
| 2286 | expect(call.query).toContain("headRef { id }"); |
| 2287 | } else { |
| 2288 | expect(call.query).toContain("headRepository { viewerPermission }"); |
| 2289 | } |
| 2290 | } |
| 2291 | } |
| 2292 | }), |
| 2293 | ); |
| 2294 | |
| 2295 | it.effect("preserves the actual source fork name in provider detail", () => |
| 2296 | Effect.gen(function* () { |
| 2297 | const answer = coreResponse({ |
| 2298 | state: "MERGED", |
| 2299 | isCrossRepository: true, |
| 2300 | headRepository: { nameWithOwner: "contributor/web-fork", viewerPermission: "WRITE" }, |
| 2301 | headRef: { id: "REF" }, |
| 2302 | }); |
| 2303 | mockedExecute.mockReturnValue(Effect.succeed(output(encodeJson(answer)))); |
| 2304 | const provider = yield* GitHubPullRequestProvider.make; |
| 2305 | const detail = yield* provider.getChangeRequest({ |
| 2306 | cwd: "/w", |
| 2307 | repository: "acme/web", |
| 2308 | host: "github.com", |
| 2309 | number: 7, |
| 2310 | }); |
| 2311 | expect(detail.headRepositoryNameWithOwner).toBe("contributor/web-fork"); |
| 2312 | expect(detail.viewerPermissions.actions).toContain("delete-branch"); |
| 2313 | expect(queryAt(0)).toContain("headRepository { nameWithOwner viewerPermission }"); |
| 2314 | }), |
| 2315 | ); |
| 2316 | |
| 2317 | const branchToDelete = (pullRequest: Readonly<Record<string, unknown>> = {}) => ({ |
| 2318 | data: { |
| 2319 | repository: { |
| 2320 | pullRequest: { |
| 2321 | state: "MERGED", |
| 2322 | headRefName: "feat/delete", |
| 2323 | baseRefName: "main", |
| 2324 | headRefOid: "abc123", |
| 2325 | headRepository: { id: "FORK_REPO" }, |
| 2326 | ...pullRequest, |
| 2327 | }, |
| 2328 | }, |
| 2329 | }, |
| 2330 | }); |
| 2331 | const sourceBranch = (repository: Readonly<Record<string, unknown>> = {}) => ({ |
| 2332 | data: { |
| 2333 | node: { |
| 2334 | defaultBranchRef: { name: "main" }, |
| 2335 | ref: { target: { oid: "abc123" } }, |
| 2336 | ...repository, |
| 2337 | }, |
| 2338 | }, |
| 2339 | }); |
| 2340 | |
| 2341 | it.effect.each(["MERGED", "CLOSED"])("deletes the exact remote fork branch of a %s PR", (state) => |
| 2342 | Effect.gen(function* () { |
| 2343 | route( |
| 2344 | ["query PullRequestBranchToDelete", branchToDelete({ state })], |
| 2345 | ["query PullRequestSourceBranch", sourceBranch()], |
| 2346 | ); |
| 2347 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2348 | yield* cli.runPullRequestAction({ |
| 2349 | cwd: "/w", |
| 2350 | repository: "acme/web", |
| 2351 | host: "github.enterprise.test", |
| 2352 | number: 7, |
| 2353 | action: "delete-branch", |
| 2354 | }); |
| 2355 | expect(variablesOf("query PullRequestBranchToDelete")).toEqual([ |
| 2356 | { owner: "acme", name: "web", number: 7 }, |
| 2357 | ]); |
| 2358 | expect(variablesOf("query PullRequestSourceBranch")).toEqual([ |
| 2359 | { repositoryId: "FORK_REPO", ref: "refs/heads/feat/delete" }, |
| 2360 | ]); |
| 2361 | expect(variablesOf("updateRefs(")).toEqual([ |
| 2362 | { |
| 2363 | input: { |
| 2364 | repositoryId: "FORK_REPO", |
| 2365 | refUpdates: [ |
| 2366 | { |
| 2367 | name: "refs/heads/feat/delete", |
| 2368 | beforeOid: "abc123", |
| 2369 | afterOid: "0000000000000000000000000000000000000000", |
| 2370 | }, |
| 2371 | ], |
| 2372 | }, |
| 2373 | }, |
| 2374 | ]); |
| 2375 | expect(mockedExecute.mock.calls.at(-1)?.[0].host).toBe("github.enterprise.test"); |
| 2376 | expect(variablesOf("mergePullRequest(")).toEqual([]); |
| 2377 | expect(variablesOf("closePullRequest(")).toEqual([]); |
| 2378 | }), |
| 2379 | ); |
| 2380 | |
| 2381 | it.effect.each([ |
| 2382 | { description: "an open PR", head: { state: "OPEN" }, source: {} }, |
| 2383 | { description: "a missing source repository", head: { headRepository: null }, source: {} }, |
| 2384 | { description: "the base branch", head: { headRefName: "main" }, source: {} }, |
| 2385 | { |
| 2386 | description: "the default branch", |
| 2387 | head: {}, |
| 2388 | source: { defaultBranchRef: { name: "feat/delete" } }, |
| 2389 | }, |
| 2390 | { description: "a missing default branch", head: {}, source: { defaultBranchRef: null } }, |
| 2391 | { |
| 2392 | description: "a changed branch", |
| 2393 | head: {}, |
| 2394 | source: { ref: { target: { oid: "new-commit" } } }, |
| 2395 | }, |
| 2396 | { description: "an unavailable source repository", head: {}, source: null }, |
| 2397 | ])("refuses to delete $description", ({ head, source }) => |
| 2398 | Effect.gen(function* () { |
| 2399 | route( |
| 2400 | ["query PullRequestBranchToDelete", branchToDelete(head)], |
| 2401 | [ |
| 2402 | "query PullRequestSourceBranch", |
| 2403 | source === null ? { data: { node: null } } : sourceBranch(source), |
| 2404 | ], |
| 2405 | ); |
| 2406 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2407 | const error = yield* Effect.flip( |
| 2408 | cli.runPullRequestAction({ |
| 2409 | cwd: "/w", |
| 2410 | repository: "acme/web", |
| 2411 | host: "github.com", |
| 2412 | number: 7, |
| 2413 | action: "delete-branch", |
| 2414 | }), |
| 2415 | ); |
| 2416 | expect(error._tag).toBe("GitHubBranchDeletionRefusedError"); |
| 2417 | expect(variablesOf("updateRefs(")).toEqual([]); |
| 2418 | }), |
| 2419 | ); |
| 2420 | |
| 2421 | it.effect("accepts an already deleted source branch", () => |
| 2422 | Effect.gen(function* () { |
| 2423 | route( |
| 2424 | ["query PullRequestBranchToDelete", branchToDelete()], |
| 2425 | ["query PullRequestSourceBranch", sourceBranch({ ref: null })], |
| 2426 | ); |
| 2427 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2428 | yield* cli.runPullRequestAction({ |
| 2429 | cwd: "/w", |
| 2430 | repository: "acme/web", |
| 2431 | host: "github.com", |
| 2432 | number: 7, |
| 2433 | action: "delete-branch", |
| 2434 | }); |
| 2435 | expect(variablesOf("updateRefs(")).toEqual([]); |
| 2436 | }), |
| 2437 | ); |
| 2438 | |
| 2439 | it.effect("keeps the source branch when GitHub refuses deletion", () => |
| 2440 | Effect.gen(function* () { |
| 2441 | route( |
| 2442 | ["query PullRequestBranchToDelete", branchToDelete()], |
| 2443 | ["query PullRequestSourceBranch", sourceBranch()], |
| 2444 | ); |
| 2445 | const answer = mockedExecute.getMockImplementation()!; |
| 2446 | mockedExecute.mockImplementation((call) => |
| 2447 | call.kind === "graphql" && call.query.includes("updateRefs(") |
| 2448 | ? Effect.fail( |
| 2449 | new GitHubApi.GitHubApiResponseError({ |
| 2450 | host: call.host, |
| 2451 | operation: call.operation, |
| 2452 | status: 422, |
| 2453 | }), |
| 2454 | ) |
| 2455 | : answer(call), |
| 2456 | ); |
| 2457 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2458 | const error = yield* Effect.flip( |
| 2459 | cli.runPullRequestAction({ |
| 2460 | cwd: "/w", |
| 2461 | repository: "acme/web", |
| 2462 | host: "github.com", |
| 2463 | number: 7, |
| 2464 | action: "delete-branch", |
| 2465 | }), |
| 2466 | ); |
| 2467 | expect(error._tag).toBe("GitHubApiResponseError"); |
| 2468 | expect(variablesOf("mergePullRequest(")).toEqual([]); |
| 2469 | expect(variablesOf("closePullRequest(")).toEqual([]); |
| 2470 | }), |
| 2471 | ); |
| 2472 | |
| 2473 | it.effect.each(["merge", "enable-auto-merge"] as const)( |
| 2474 | "removes agent credits from the proposed message for %s", |
| 2475 | (action) => |
| 2476 | Effect.gen(function* () { |
| 2477 | route( |
| 2478 | [ |
| 2479 | "query PullRequestMergeMessage", |
| 2480 | mergeMessage( |
| 2481 | "Details\n\nCo-authored-by: Alice <alice@example.com>\nCo-authored-by: Claude <noreply@anthropic.com>", |
| 2482 | ), |
| 2483 | ], |
| 2484 | ["query PullRequestActionState", actionState()], |
| 2485 | ); |
| 2486 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2487 | yield* cli.runPullRequestAction({ |
| 2488 | cwd: "/w", |
| 2489 | repository: "acme/web", |
| 2490 | host: "github.com", |
| 2491 | number: 7, |
| 2492 | action, |
| 2493 | mergeMethod: "squash", |
| 2494 | removeAgentCreditsOnMerge: true, |
| 2495 | }); |
| 2496 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("SQUASH"); |
| 2497 | expect( |
| 2498 | variablesOf(action === "merge" ? "mergePullRequest(" : "enablePullRequestAutoMerge("), |
| 2499 | ).toEqual([ |
| 2500 | { |
| 2501 | input: { |
| 2502 | pullRequestId: "PR_7", |
| 2503 | mergeMethod: "SQUASH", |
| 2504 | // Pinned to the head the message was read from, so it cannot describe other commits. |
| 2505 | expectedHeadOid: "abc123", |
| 2506 | commitBody: "Details\n\nCo-authored-by: Alice <alice@example.com>", |
| 2507 | }, |
| 2508 | }, |
| 2509 | ]); |
| 2510 | }), |
| 2511 | ); |
| 2512 | |
| 2513 | it.effect.each([ |
| 2514 | { |
| 2515 | description: "an unchanged message", |
| 2516 | body: "Details\n\nCo-authored-by: Alice <alice@example.com>", |
| 2517 | queued: false, |
| 2518 | }, |
| 2519 | { |
| 2520 | description: "a merge queue", |
| 2521 | body: "Co-authored-by: Claude <noreply@anthropic.com>", |
| 2522 | queued: true, |
| 2523 | }, |
| 2524 | ] as const)("keeps GitHub's default message for $description", ({ body, queued }) => |
| 2525 | Effect.gen(function* () { |
| 2526 | route( |
| 2527 | ["query PullRequestMergeMessage", mergeMessage(body, queued)], |
| 2528 | ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], |
| 2529 | ); |
| 2530 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2531 | yield* cli.runPullRequestAction({ |
| 2532 | cwd: "/w", |
| 2533 | repository: "acme/web", |
| 2534 | host: "github.com", |
| 2535 | number: 7, |
| 2536 | action: "merge", |
| 2537 | removeAgentCreditsOnMerge: true, |
| 2538 | }); |
| 2539 | expect(variablesOf("query PullRequestMergeMessage")[0]?.["method"]).toBe("MERGE"); |
| 2540 | // A merge queue takes the pull request through auto-merge, with its own message. |
| 2541 | expect(variablesOf(queued ? "enablePullRequestAutoMerge(" : "mergePullRequest(")).toEqual([ |
| 2542 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, |
| 2543 | ]); |
| 2544 | }), |
| 2545 | ); |
| 2546 | |
| 2547 | it.effect("passes an explicitly empty body when the proposed message only credits an agent", () => |
| 2548 | Effect.gen(function* () { |
| 2549 | route( |
| 2550 | [ |
| 2551 | "query PullRequestMergeMessage", |
| 2552 | mergeMessage("Co-authored-by: Claude <noreply@anthropic.com>"), |
| 2553 | ], |
| 2554 | ["query PullRequestActionState", actionState()], |
| 2555 | ); |
| 2556 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2557 | yield* cli.runPullRequestAction({ |
| 2558 | cwd: "/w", |
| 2559 | repository: "acme/web", |
| 2560 | host: "github.com", |
| 2561 | number: 7, |
| 2562 | action: "merge", |
| 2563 | removeAgentCreditsOnMerge: true, |
| 2564 | }); |
| 2565 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2566 | { |
| 2567 | input: { |
| 2568 | pullRequestId: "PR_7", |
| 2569 | mergeMethod: "MERGE", |
| 2570 | expectedHeadOid: "abc123", |
| 2571 | commitBody: "", |
| 2572 | }, |
| 2573 | }, |
| 2574 | ]); |
| 2575 | }), |
| 2576 | ); |
| 2577 | |
| 2578 | it.effect("does not fetch a message for rebase merges", () => |
| 2579 | Effect.gen(function* () { |
| 2580 | route(["query PullRequestActionState", actionState()]); |
| 2581 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2582 | yield* cli.runPullRequestAction({ |
| 2583 | cwd: "/w", |
| 2584 | repository: "acme/web", |
| 2585 | host: "github.com", |
| 2586 | number: 7, |
| 2587 | action: "merge", |
| 2588 | mergeMethod: "rebase", |
| 2589 | removeAgentCreditsOnMerge: true, |
| 2590 | }); |
| 2591 | expect(variablesOf("query PullRequestMergeMessage")).toEqual([]); |
| 2592 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2593 | { input: { pullRequestId: "PR_7", mergeMethod: "REBASE" } }, |
| 2594 | ]); |
| 2595 | }), |
| 2596 | ); |
| 2597 | |
| 2598 | it.effect("refuses to merge when the proposed message cannot be read", () => |
| 2599 | Effect.gen(function* () { |
| 2600 | route( |
| 2601 | ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], |
| 2602 | ["query PullRequestActionState", actionState()], |
| 2603 | ); |
| 2604 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2605 | const result = yield* Effect.result( |
| 2606 | cli.runPullRequestAction({ |
| 2607 | cwd: "/w", |
| 2608 | repository: "acme/web", |
| 2609 | host: "github.com", |
| 2610 | number: 7, |
| 2611 | action: "merge", |
| 2612 | removeAgentCreditsOnMerge: true, |
| 2613 | }), |
| 2614 | ); |
| 2615 | expect(result._tag).toBe("Failure"); |
| 2616 | expect(variablesOf("mergePullRequest(")).toEqual([]); |
| 2617 | }), |
| 2618 | ); |
| 2619 | |
| 2620 | it.effect("arms auto-merge with the same strategy a merge would have used", () => |
| 2621 | Effect.gen(function* () { |
| 2622 | route(["query PullRequestActionState", actionState()]); |
| 2623 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2624 | |
| 2625 | yield* cli.runPullRequestAction({ |
| 2626 | cwd: "/w", |
| 2627 | repository: "acme/web", |
| 2628 | host: "github.com", |
| 2629 | number: 7, |
| 2630 | action: "enable-auto-merge", |
| 2631 | mergeMethod: "squash", |
| 2632 | }); |
| 2633 | // No strategy asked for is GitHub's own default, exactly as it is for a merge now. |
| 2634 | yield* cli.runPullRequestAction({ |
| 2635 | cwd: "/w", |
| 2636 | repository: "acme/web", |
| 2637 | host: "github.com", |
| 2638 | number: 7, |
| 2639 | action: "enable-auto-merge", |
| 2640 | }); |
| 2641 | |
| 2642 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([ |
| 2643 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, |
| 2644 | { input: { pullRequestId: "PR_7", mergeMethod: "MERGE" } }, |
| 2645 | ]); |
| 2646 | expect(variablesOf("mergePullRequest(")).toEqual([]); |
| 2647 | }), |
| 2648 | ); |
| 2649 | |
| 2650 | it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => |
| 2651 | Effect.gen(function* () { |
| 2652 | route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); |
| 2653 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2654 | |
| 2655 | yield* cli.runPullRequestAction({ |
| 2656 | cwd: "/w", |
| 2657 | repository: "acme/web", |
| 2658 | host: "github.com", |
| 2659 | number: 7, |
| 2660 | action: "enable-auto-merge", |
| 2661 | mergeMethod: "squash", |
| 2662 | }); |
| 2663 | |
| 2664 | expect(variablesOf("mergePullRequest(")).toEqual([ |
| 2665 | { input: { pullRequestId: "PR_7", mergeMethod: "SQUASH" } }, |
| 2666 | ]); |
| 2667 | expect(variablesOf("enablePullRequestAutoMerge(")).toEqual([]); |
| 2668 | }), |
| 2669 | ); |
| 2670 | |
| 2671 | it.effect("takes auto-merge back off without naming a strategy", () => |
| 2672 | Effect.gen(function* () { |
| 2673 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2674 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2675 | |
| 2676 | yield* cli.runPullRequestAction({ |
| 2677 | cwd: "/w", |
| 2678 | repository: "acme/web", |
| 2679 | host: "github.com", |
| 2680 | number: 7, |
| 2681 | action: "disable-auto-merge", |
| 2682 | mergeMethod: "squash", |
| 2683 | }); |
| 2684 | |
| 2685 | expect(variablesOf("disablePullRequestAutoMerge(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 2686 | }), |
| 2687 | ); |
| 2688 | |
| 2689 | it.effect("opens a pull request that reverts a merged pull request", () => |
| 2690 | Effect.gen(function* () { |
| 2691 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 2692 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2693 | |
| 2694 | yield* cli.runPullRequestAction({ |
| 2695 | cwd: "/w", |
| 2696 | repository: "acme/web", |
| 2697 | host: "github.com", |
| 2698 | number: 7, |
| 2699 | action: "revert", |
| 2700 | }); |
| 2701 | |
| 2702 | expect(variablesOf("revertPullRequest(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 2703 | }), |
| 2704 | ); |
| 2705 | |
| 2706 | it.effect("does not approve action-required runs for a same-repository pull request", () => |
| 2707 | Effect.gen(function* () { |
| 2708 | mockedExecute.mockReturnValueOnce( |
| 2709 | Effect.succeed( |
| 2710 | output( |
| 2711 | encodeJson( |
| 2712 | coreResponse({ |
| 2713 | number: 7, |
| 2714 | title: "Pull request 7", |
| 2715 | url: "https://github.com/acme/web/pull/7", |
| 2716 | headRefName: "feat/page", |
| 2717 | headRefOid: "abc123", |
| 2718 | isCrossRepository: false, |
| 2719 | headRepositoryOwner: { login: "acme" }, |
| 2720 | baseRefName: "main", |
| 2721 | createdAt: "2026-07-01T00:00:00Z", |
| 2722 | updatedAt: "2026-07-02T00:00:00Z", |
| 2723 | }), |
| 2724 | ), |
| 2725 | ), |
| 2726 | ), |
| 2727 | ); |
| 2728 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2729 | |
| 2730 | yield* cli.runPullRequestAction({ |
| 2731 | cwd: "/w", |
| 2732 | repository: "acme/web", |
| 2733 | host: "github.com", |
| 2734 | number: 7, |
| 2735 | action: "approve-workflows", |
| 2736 | }); |
| 2737 | |
| 2738 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 2739 | }), |
| 2740 | ); |
| 2741 | |
| 2742 | it.effect("finds and approves every workflow waiting on a maintainer", () => |
| 2743 | Effect.gen(function* () { |
| 2744 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); |
| 2745 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2746 | |
| 2747 | yield* cli.runPullRequestAction({ |
| 2748 | cwd: "/w", |
| 2749 | repository: "acme/web", |
| 2750 | host: "github.com", |
| 2751 | number: 7, |
| 2752 | action: "approve-workflows", |
| 2753 | }); |
| 2754 | |
| 2755 | // Open pull requests on this head branch, and runs scoped to this exact head. |
| 2756 | expect(variablesOf("headRefName: $head")[0]).toEqual({ |
| 2757 | owner: "acme", |
| 2758 | name: "web", |
| 2759 | head: "feat/page", |
| 2760 | after: null, |
| 2761 | }); |
| 2762 | expect(restCallsTo("actions/runs?")[0]?.path).toBe( |
| 2763 | "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fpage&event=pull_request&status=action_required&per_page=100&page=1", |
| 2764 | ); |
| 2765 | // Each run is approved only after the head is read again and still lists it. |
| 2766 | expect(restCallsTo("/approve").map((call) => [call.method, call.path])).toEqual([ |
| 2767 | ["POST", "repos/acme/web/actions/runs/10/approve"], |
| 2768 | ["POST", "repos/acme/web/actions/runs/11/approve"], |
| 2769 | ]); |
| 2770 | assert.strictEqual(restCallsTo("actions/runs?").length, 3); |
| 2771 | }), |
| 2772 | ); |
| 2773 | |
| 2774 | it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => |
| 2775 | Effect.gen(function* () { |
| 2776 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); |
| 2777 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2778 | |
| 2779 | const runs = yield* cli |
| 2780 | .listWorkflowRunsRequiringApproval({ |
| 2781 | cwd: "/w", |
| 2782 | repository: "acme/web", |
| 2783 | host: "github.com", |
| 2784 | number: 7, |
| 2785 | headSha: "abc123", |
| 2786 | headBranch: "feat/page", |
| 2787 | headRepositoryOwner: "octocat", |
| 2788 | isCrossRepository: true, |
| 2789 | }) |
| 2790 | .pipe( |
| 2791 | Effect.provideService(KnownWorkflowRuns, { |
| 2792 | headSha: "abc123", |
| 2793 | runs: [ |
| 2794 | // Live shape: waiting on approval is completed + action_required. |
| 2795 | { |
| 2796 | id: 10, |
| 2797 | status: "completed", |
| 2798 | conclusion: "action_required", |
| 2799 | head_branch: "feat/page", |
| 2800 | }, |
| 2801 | { id: 11, status: "completed", conclusion: "success", head_branch: "feat/page" }, |
| 2802 | ], |
| 2803 | }), |
| 2804 | ); |
| 2805 | |
| 2806 | expect(runs.map((run) => run.id)).toEqual([10]); |
| 2807 | assert.strictEqual(restCallsTo("actions/runs?").length, 0); |
| 2808 | }), |
| 2809 | ); |
| 2810 | |
| 2811 | it.effect("refuses a stale workflow approval after the pull request head changes", () => |
| 2812 | Effect.gen(function* () { |
| 2813 | let detailReads = 0; |
| 2814 | workflowApprovalRoutes( |
| 2815 | () => crossRepositoryDetail(++detailReads === 1 ? "abc123" : "def456"), |
| 2816 | heads([7]), |
| 2817 | workflowRuns([10]), |
| 2818 | ); |
| 2819 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2820 | |
| 2821 | const error = yield* Effect.flip( |
| 2822 | cli.runPullRequestAction({ |
| 2823 | cwd: "/w", |
| 2824 | repository: "acme/web", |
| 2825 | host: "github.com", |
| 2826 | number: 7, |
| 2827 | action: "approve-workflows", |
| 2828 | }), |
| 2829 | ); |
| 2830 | |
| 2831 | expect(error).toMatchObject({ |
| 2832 | _tag: "GitHubWorkflowApprovalHeadChangedError", |
| 2833 | number: 7, |
| 2834 | }); |
| 2835 | assert.strictEqual(detailReads, 2); |
| 2836 | expect(restCallsTo("/approve")).toEqual([]); |
| 2837 | }), |
| 2838 | ); |
| 2839 | |
| 2840 | it.effect("reads workflow runs and their pull request scope concurrently", () => |
| 2841 | Effect.gen(function* () { |
| 2842 | const headsStarted = yield* Deferred.make<void>(); |
| 2843 | const runsStarted = yield* Deferred.make<void>(); |
| 2844 | mockedExecute.mockImplementation((call) => |
| 2845 | call.kind === "graphql" |
| 2846 | ? Deferred.succeed(headsStarted, undefined).pipe( |
| 2847 | Effect.andThen(Deferred.await(runsStarted)), |
| 2848 | Effect.as(output(encodeJson(heads([7])))), |
| 2849 | ) |
| 2850 | : Deferred.succeed(runsStarted, undefined).pipe( |
| 2851 | Effect.andThen(Deferred.await(headsStarted)), |
| 2852 | Effect.as(output(encodeJson(workflowRuns([10])))), |
| 2853 | ), |
| 2854 | ); |
| 2855 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2856 | |
| 2857 | const runs = yield* cli.listWorkflowRunsRequiringApproval({ |
| 2858 | cwd: "/w", |
| 2859 | repository: "acme/web", |
| 2860 | host: "github.com", |
| 2861 | number: 7, |
| 2862 | headSha: "abc123", |
| 2863 | headBranch: "feat/page", |
| 2864 | headRepositoryOwner: "octocat", |
| 2865 | isCrossRepository: true, |
| 2866 | }); |
| 2867 | |
| 2868 | expect(runs).toEqual([{ id: 10, name: "build", url: "https://example.com/10" }]); |
| 2869 | }), |
| 2870 | ); |
| 2871 | |
| 2872 | it.effect("refuses workflow approval when one head belongs to several pull requests", () => |
| 2873 | Effect.gen(function* () { |
| 2874 | workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); |
| 2875 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2876 | |
| 2877 | const error = yield* Effect.flip( |
| 2878 | cli.listWorkflowRunsRequiringApproval({ |
| 2879 | cwd: "/w", |
| 2880 | repository: "acme/web", |
| 2881 | host: "github.com", |
| 2882 | number: 7, |
| 2883 | headSha: "abc123", |
| 2884 | headBranch: "feat/page", |
| 2885 | headRepositoryOwner: "octocat", |
| 2886 | isCrossRepository: true, |
| 2887 | }), |
| 2888 | ); |
| 2889 | |
| 2890 | expect(error).toMatchObject({ |
| 2891 | _tag: "GitHubWorkflowApprovalRefusedError", |
| 2892 | reason: "head-not-unique", |
| 2893 | number: 7, |
| 2894 | observedCount: 2, |
| 2895 | limit: 1_000, |
| 2896 | }); |
| 2897 | expect(error.message).toContain("instead of uniquely matching #7"); |
| 2898 | }), |
| 2899 | ); |
| 2900 | |
| 2901 | it.effect("refuses workflow approval when the head list stops before its end", () => |
| 2902 | Effect.gen(function* () { |
| 2903 | // GitHub hands the same cursor back, so the second page is never read past. #7 looks |
| 2904 | // unique on what was read, and an unread page could still hold another head like it. |
| 2905 | const repeating = heads([7]); |
| 2906 | repeating.data.repository.pullRequests.pageInfo = { |
| 2907 | hasNextPage: true, |
| 2908 | endCursor: "same", |
| 2909 | } as never; |
| 2910 | workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); |
| 2911 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2912 | |
| 2913 | const error = yield* Effect.flip( |
| 2914 | cli.listWorkflowRunsRequiringApproval({ |
| 2915 | cwd: "/w", |
| 2916 | repository: "acme/web", |
| 2917 | host: "github.com", |
| 2918 | number: 7, |
| 2919 | headSha: "abc123", |
| 2920 | headBranch: "feat/page", |
| 2921 | headRepositoryOwner: "octocat", |
| 2922 | isCrossRepository: true, |
| 2923 | }), |
| 2924 | ); |
| 2925 | |
| 2926 | expect(error).toMatchObject({ |
| 2927 | _tag: "GitHubWorkflowApprovalRefusedError", |
| 2928 | reason: "head-list-truncated", |
| 2929 | }); |
| 2930 | }), |
| 2931 | ); |
| 2932 | |
| 2933 | it.effect("refuses workflow approval when GitHub omits the head repository", () => |
| 2934 | Effect.gen(function* () { |
| 2935 | mockedExecute.mockReturnValueOnce( |
| 2936 | Effect.succeed( |
| 2937 | output( |
| 2938 | encodeJson( |
| 2939 | coreResponse({ |
| 2940 | number: 7, |
| 2941 | title: "Pull request 7", |
| 2942 | url: "https://github.com/acme/web/pull/7", |
| 2943 | headRefName: "feat/page", |
| 2944 | headRefOid: "abc123", |
| 2945 | isCrossRepository: true, |
| 2946 | headRepositoryOwner: null, |
| 2947 | baseRefName: "main", |
| 2948 | createdAt: "2026-07-01T00:00:00Z", |
| 2949 | updatedAt: "2026-07-02T00:00:00Z", |
| 2950 | }), |
| 2951 | ), |
| 2952 | ), |
| 2953 | ), |
| 2954 | ); |
| 2955 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2956 | |
| 2957 | const error = yield* Effect.flip( |
| 2958 | cli.runPullRequestAction({ |
| 2959 | cwd: "/w", |
| 2960 | repository: "acme/web", |
| 2961 | host: "github.com", |
| 2962 | number: 7, |
| 2963 | action: "approve-workflows", |
| 2964 | }), |
| 2965 | ); |
| 2966 | |
| 2967 | expect(error).toMatchObject({ |
| 2968 | _tag: "GitHubWorkflowApprovalHeadUnavailableError", |
| 2969 | number: 7, |
| 2970 | }); |
| 2971 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 2972 | }), |
| 2973 | ); |
| 2974 | |
| 2975 | it.effect("surfaces a workflow run list beyond the safe approval bound", () => |
| 2976 | Effect.gen(function* () { |
| 2977 | let runPages = 0; |
| 2978 | mockedExecute.mockImplementation((call) => { |
| 2979 | if (call.kind === "graphql") return Effect.succeed(output(encodeJson(heads([7])))); |
| 2980 | const page = runPages++; |
| 2981 | return Effect.succeed( |
| 2982 | output( |
| 2983 | encodeJson( |
| 2984 | workflowRuns(Array.from({ length: 100 }, (_, index) => page * 100 + index + 1)), |
| 2985 | ), |
| 2986 | ), |
| 2987 | ); |
| 2988 | }); |
| 2989 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 2990 | |
| 2991 | const error = yield* Effect.flip( |
| 2992 | cli.listWorkflowRunsRequiringApproval({ |
| 2993 | cwd: "/w", |
| 2994 | repository: "acme/web", |
| 2995 | host: "github.com", |
| 2996 | number: 7, |
| 2997 | headSha: "abc123", |
| 2998 | headBranch: "feat/page", |
| 2999 | headRepositoryOwner: "octocat", |
| 3000 | isCrossRepository: true, |
| 3001 | }), |
| 3002 | ); |
| 3003 | |
| 3004 | // Paged a hundred at a time, it stops at the first page past the bound. |
| 3005 | expect(error).toMatchObject({ |
| 3006 | _tag: "GitHubWorkflowApprovalRefusedError", |
| 3007 | reason: "run-list-truncated", |
| 3008 | number: 7, |
| 3009 | observedCount: 1_100, |
| 3010 | limit: 1_000, |
| 3011 | }); |
| 3012 | expect(error.message).toContain("more than 1000 workflow runs"); |
| 3013 | assert.strictEqual(runPages, 11); |
| 3014 | }), |
| 3015 | ); |
| 3016 | |
| 3017 | it.effect("returns a pull request to draft by converting it", () => |
| 3018 | Effect.gen(function* () { |
| 3019 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 3020 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3021 | |
| 3022 | yield* cli.runPullRequestAction({ |
| 3023 | cwd: "/w", |
| 3024 | repository: "acme/web", |
| 3025 | host: "github.com", |
| 3026 | number: 7, |
| 3027 | action: "draft", |
| 3028 | }); |
| 3029 | |
| 3030 | expect(variablesOf("convertPullRequestToDraft(")).toEqual([{ pullRequestId: "PR_7" }]); |
| 3031 | expect(variablesOf("markPullRequestReadyForReview(")).toEqual([]); |
| 3032 | }), |
| 3033 | ); |
| 3034 | |
| 3035 | it.effect("sends a comment body as a variable, never inside the document", () => |
| 3036 | Effect.gen(function* () { |
| 3037 | route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); |
| 3038 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3039 | |
| 3040 | yield* cli.commentOnPullRequest({ |
| 3041 | cwd: "/w", |
| 3042 | repository: "acme/web", |
| 3043 | host: "github.com", |
| 3044 | number: 7, |
| 3045 | body: "Looks good.", |
| 3046 | }); |
| 3047 | |
| 3048 | expect(variablesOf("addComment(")).toEqual([{ subjectId: "PR_7", body: "Looks good." }]); |
| 3049 | expect(queryAt(mockedExecute.mock.calls.length - 1)).not.toContain("Looks good."); |
| 3050 | }), |
| 3051 | ); |
| 3052 | |
| 3053 | it.effect("names the host on every repository it addresses", () => |
| 3054 | Effect.gen(function* () { |
| 3055 | mockedExecute.mockImplementation((call) => |
| 3056 | Effect.succeed( |
| 3057 | call.kind === "graphql" && call.query.includes("pullRequests(") |
| 3058 | ? emptyList() |
| 3059 | : emptySearch(), |
| 3060 | ), |
| 3061 | ); |
| 3062 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3063 | |
| 3064 | yield* cli.listPullRequests({ |
| 3065 | cwd: "/w", |
| 3066 | repository: "acme/web", |
| 3067 | host: "github.acme.dev", |
| 3068 | state: "open", |
| 3069 | involvement: "all", |
| 3070 | viewer: "bilal", |
| 3071 | limit: 10, |
| 3072 | }); |
| 3073 | |
| 3074 | // A request sent to github.com would read a different repository of the same name. |
| 3075 | assert.isAbove(mockedExecute.mock.calls.length, 0); |
| 3076 | expect(new Set(mockedExecute.mock.calls.map(([call]) => call.host))).toEqual( |
| 3077 | new Set(["github.acme.dev"]), |
| 3078 | ); |
| 3079 | expect(searchOfCall(0)).toContain("repo:acme/web"); |
| 3080 | }), |
| 3081 | ); |
| 3082 | |
| 3083 | it.effect("asks a GitHub Enterprise host for its own review threads", () => |
| 3084 | Effect.gen(function* () { |
| 3085 | mockedExecute.mockReturnValueOnce( |
| 3086 | Effect.succeed( |
| 3087 | output( |
| 3088 | encodeJson({ |
| 3089 | data: { |
| 3090 | repository: { pullRequest: { reviewThreads: { totalCount: 0, nodes: [] } } }, |
| 3091 | }, |
| 3092 | }), |
| 3093 | ), |
| 3094 | ), |
| 3095 | ); |
| 3096 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3097 | |
| 3098 | yield* cli.listReviewThreadComments({ |
| 3099 | cwd: "/w", |
| 3100 | repository: "acme/web", |
| 3101 | host: "github.acme.dev", |
| 3102 | number: 7, |
| 3103 | }); |
| 3104 | |
| 3105 | expect(callAt(0).host).toBe("github.acme.dev"); |
| 3106 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 3107 | }), |
| 3108 | ); |
| 3109 | |
| 3110 | it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => |
| 3111 | Effect.gen(function* () { |
| 3112 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); |
| 3113 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3114 | |
| 3115 | const diff = yield* cli.getPullRequestDiff({ |
| 3116 | cwd: "/w", |
| 3117 | repository: "acme/web", |
| 3118 | host: "github.com", |
| 3119 | number: 7, |
| 3120 | }); |
| 3121 | |
| 3122 | assert.strictEqual(diff.patch, "diff --git a/a b/a"); |
| 3123 | assert.isNull(diff.nextCursor); |
| 3124 | assert.isFalse(diff.truncated); |
| 3125 | // The common case pays for one request and not the files API on top of it. |
| 3126 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3127 | // GitHub's combined pull-request diff: one section per changed file, not one per commit. |
| 3128 | const call = callAt(0); |
| 3129 | assert.strictEqual(call.kind, "rest"); |
| 3130 | if (call.kind === "rest") { |
| 3131 | assert.strictEqual(call.path, "repos/acme/web/pulls/7"); |
| 3132 | assert.strictEqual(call.accept, "application/vnd.github.diff"); |
| 3133 | } |
| 3134 | }), |
| 3135 | ); |
| 3136 | |
| 3137 | it.effect("reads one files page when GitHub refuses the diff, and says it is the last", () => |
| 3138 | Effect.gen(function* () { |
| 3139 | // GitHub answers 406 rather than a diff past 300 changed files. |
| 3140 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 3141 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); |
| 3142 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3143 | |
| 3144 | const diff = yield* cli.getPullRequestDiff({ |
| 3145 | cwd: "/w", |
| 3146 | repository: "acme/web", |
| 3147 | host: "github.acme.dev", |
| 3148 | number: 7, |
| 3149 | }); |
| 3150 | |
| 3151 | assert.isFalse(diff.truncated); |
| 3152 | // A short page is the end of the change set, so there is nothing to carry on from. |
| 3153 | assert.isNull(diff.nextCursor); |
| 3154 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 3155 | expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); |
| 3156 | assert.strictEqual(callAt(1).host, "github.acme.dev"); |
| 3157 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); |
| 3158 | }), |
| 3159 | ); |
| 3160 | |
| 3161 | it.effect("hands back a cursor for the next page rather than walking on by itself", () => |
| 3162 | Effect.gen(function* () { |
| 3163 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 3164 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); |
| 3165 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3166 | |
| 3167 | const diff = yield* cli.getPullRequestDiff({ |
| 3168 | cwd: "/w", |
| 3169 | repository: "acme/web", |
| 3170 | host: "github.com", |
| 3171 | number: 7, |
| 3172 | }); |
| 3173 | |
| 3174 | // A full page means more files, which the reader asks for; it is not a truncated slice. |
| 3175 | assert.isFalse(diff.truncated); |
| 3176 | assert.isNotNull(diff.nextCursor); |
| 3177 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3178 | }), |
| 3179 | ); |
| 3180 | |
| 3181 | it.effect("carries on from a cursor without asking for the whole diff again", () => |
| 3182 | Effect.gen(function* () { |
| 3183 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 3184 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); |
| 3185 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3186 | const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; |
| 3187 | |
| 3188 | const first = yield* cli.getPullRequestDiff(target); |
| 3189 | assert.isNotNull(first.nextCursor); |
| 3190 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(4, 100)))); |
| 3191 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); |
| 3192 | |
| 3193 | assert.isNull(second.nextCursor); |
| 3194 | expect(second.patch).toContain("diff --git a/src/file100.ts b/src/file100.ts"); |
| 3195 | // The second slice is one request: the cursor already says where to read. |
| 3196 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 3197 | assert.strictEqual(pathAt(2), "repos/acme/web/pulls/7/files?per_page=100&page=2"); |
| 3198 | }), |
| 3199 | ); |
| 3200 | |
| 3201 | it.effect("refuses a cursor it never handed out rather than reading it into a request", () => |
| 3202 | Effect.gen(function* () { |
| 3203 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3204 | |
| 3205 | const error = yield* Effect.flip( |
| 3206 | cli.getPullRequestDiff({ |
| 3207 | cwd: "/w", |
| 3208 | repository: "acme/web", |
| 3209 | host: "github.com", |
| 3210 | number: 7, |
| 3211 | cursor: "1&per_page=1", |
| 3212 | }), |
| 3213 | ); |
| 3214 | |
| 3215 | assert.strictEqual(error._tag, "GitHubDiffCursorError"); |
| 3216 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 3217 | }), |
| 3218 | ); |
| 3219 | |
| 3220 | it.effect("reads a named commit from the commit endpoint rather than the whole diff", () => |
| 3221 | Effect.gen(function* () { |
| 3222 | mockedExecute.mockReturnValueOnce( |
| 3223 | Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), |
| 3224 | ); |
| 3225 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3226 | |
| 3227 | const diff = yield* cli.getPullRequestDiff({ |
| 3228 | cwd: "/w", |
| 3229 | repository: "acme/web", |
| 3230 | host: "github.com", |
| 3231 | number: 7, |
| 3232 | commit: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", |
| 3233 | }); |
| 3234 | |
| 3235 | // One request: the commit's own changes never take the whole-diff road. |
| 3236 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3237 | assert.isNull(diff.nextCursor); |
| 3238 | // The commit endpoint wraps its files in an object, which the decoder unwraps. |
| 3239 | expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); |
| 3240 | assert.strictEqual( |
| 3241 | pathAt(0), |
| 3242 | "repos/acme/web/commits/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0?per_page=100&page=1", |
| 3243 | ); |
| 3244 | }), |
| 3245 | ); |
| 3246 | |
| 3247 | it.effect("pages inside a commit the way it pages the pull request's own files", () => |
| 3248 | Effect.gen(function* () { |
| 3249 | mockedExecute.mockReturnValueOnce( |
| 3250 | Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), |
| 3251 | ); |
| 3252 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3253 | const target = { |
| 3254 | cwd: "/w", |
| 3255 | repository: "acme/web", |
| 3256 | host: "github.com", |
| 3257 | number: 7, |
| 3258 | commit: "a1b2c3d", |
| 3259 | }; |
| 3260 | |
| 3261 | const first = yield* cli.getPullRequestDiff(target); |
| 3262 | assert.isNotNull(first.nextCursor); |
| 3263 | mockedExecute.mockReturnValueOnce( |
| 3264 | Effect.succeed(output(`{"files":${pullRequestFiles(4, 100)}}`)), |
| 3265 | ); |
| 3266 | const second = yield* cli.getPullRequestDiff({ ...target, cursor: first.nextCursor }); |
| 3267 | |
| 3268 | assert.isNull(second.nextCursor); |
| 3269 | expect(second.patch).toContain("src/file100.ts"); |
| 3270 | assert.strictEqual(pathAt(1), "repos/acme/web/commits/a1b2c3d?per_page=100&page=2"); |
| 3271 | }), |
| 3272 | ); |
| 3273 | |
| 3274 | it.effect("refuses a commit that is not a sha rather than reading it into a request", () => |
| 3275 | Effect.gen(function* () { |
| 3276 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3277 | |
| 3278 | const error = yield* Effect.flip( |
| 3279 | cli.getPullRequestDiff({ |
| 3280 | cwd: "/w", |
| 3281 | repository: "acme/web", |
| 3282 | host: "github.com", |
| 3283 | number: 7, |
| 3284 | commit: "../../pulls/8/files", |
| 3285 | }), |
| 3286 | ); |
| 3287 | |
| 3288 | assert.strictEqual(error._tag, "GitHubDiffCommitError"); |
| 3289 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 3290 | }), |
| 3291 | ); |
| 3292 | |
| 3293 | it.effect("expands a new file from a root commit without requiring a parent", () => |
| 3294 | Effect.gen(function* () { |
| 3295 | mockedExecute.mockReturnValueOnce( |
| 3296 | Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), |
| 3297 | ); |
| 3298 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); |
| 3299 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3300 | |
| 3301 | const contents = yield* cli.getPullRequestDiffFileContents({ |
| 3302 | cwd: "/w", |
| 3303 | repository: "acme/web", |
| 3304 | host: "github.com", |
| 3305 | number: 7, |
| 3306 | commit: "a1b2c3d", |
| 3307 | changeType: "new", |
| 3308 | oldPath: "src/root.ts", |
| 3309 | newPath: "src/root.ts", |
| 3310 | }); |
| 3311 | |
| 3312 | expect(contents).toEqual({ oldContents: "", newContents: "root contents\n" }); |
| 3313 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3314 | assert.strictEqual(pathAt(0), "repos/acme/web/commits/a1b2c3d"); |
| 3315 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/root.ts?ref=a1b2c3d"); |
| 3316 | }), |
| 3317 | ); |
| 3318 | |
| 3319 | it.effect("reports unusable diff revisions as a structured error", () => |
| 3320 | Effect.gen(function* () { |
| 3321 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); |
| 3322 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3323 | |
| 3324 | const error = yield* Effect.flip( |
| 3325 | cli.getPullRequestDiffFileContents({ |
| 3326 | cwd: "/w", |
| 3327 | repository: "acme/web", |
| 3328 | host: "github.com", |
| 3329 | number: 7, |
| 3330 | commit: "a1b2c3d", |
| 3331 | changeType: "change", |
| 3332 | oldPath: "src/a.ts", |
| 3333 | newPath: "src/a.ts", |
| 3334 | }), |
| 3335 | ); |
| 3336 | |
| 3337 | assert.strictEqual(error._tag, "GitHubDiffRevisionsUnavailableError"); |
| 3338 | if (error._tag === "GitHubDiffRevisionsUnavailableError") { |
| 3339 | assert.strictEqual(error.number, 7); |
| 3340 | assert.strictEqual(error.commit, "a1b2c3d"); |
| 3341 | } |
| 3342 | }), |
| 3343 | ); |
| 3344 | |
| 3345 | it.effect("reports an oversized diff file with its path and reason", () => |
| 3346 | Effect.gen(function* () { |
| 3347 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 3348 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); |
| 3349 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3350 | |
| 3351 | const error = yield* Effect.flip( |
| 3352 | cli.getPullRequestDiffFileContents({ |
| 3353 | cwd: "/w", |
| 3354 | repository: "acme/web", |
| 3355 | host: "github.com", |
| 3356 | number: 7, |
| 3357 | changeType: "deleted", |
| 3358 | oldPath: "src/large.ts", |
| 3359 | newPath: "src/large.ts", |
| 3360 | }), |
| 3361 | ); |
| 3362 | |
| 3363 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); |
| 3364 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { |
| 3365 | assert.strictEqual(error.path, "src/large.ts"); |
| 3366 | assert.strictEqual(error.reason, "oversized"); |
| 3367 | } |
| 3368 | // A deleted file is read at the base revision only. |
| 3369 | assert.strictEqual(pathAt(1), "repos/acme/web/contents/src/large.ts?ref=a1b2c3d"); |
| 3370 | }), |
| 3371 | ); |
| 3372 | |
| 3373 | it.effect("reports undecodable diff file contents as binary", () => |
| 3374 | Effect.gen(function* () { |
| 3375 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 3376 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); |
| 3377 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3378 | |
| 3379 | const error = yield* Effect.flip( |
| 3380 | cli.getPullRequestDiffFileContents({ |
| 3381 | cwd: "/w", |
| 3382 | repository: "acme/web", |
| 3383 | host: "github.com", |
| 3384 | number: 7, |
| 3385 | changeType: "deleted", |
| 3386 | oldPath: "assets/logo.png", |
| 3387 | newPath: "assets/logo.png", |
| 3388 | }), |
| 3389 | ); |
| 3390 | |
| 3391 | assert.strictEqual(error._tag, "GitHubDiffFileContentsUnavailableError"); |
| 3392 | if (error._tag === "GitHubDiffFileContentsUnavailableError") { |
| 3393 | assert.strictEqual(error.path, "assets/logo.png"); |
| 3394 | assert.strictEqual(error.reason, "binary"); |
| 3395 | } |
| 3396 | }), |
| 3397 | ); |
| 3398 | |
| 3399 | it.effect("returns valid text containing a literal replacement character", () => |
| 3400 | Effect.gen(function* () { |
| 3401 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); |
| 3402 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); |
| 3403 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3404 | |
| 3405 | const contents = yield* cli.getPullRequestDiffFileContents({ |
| 3406 | cwd: "/w", |
| 3407 | repository: "acme/web", |
| 3408 | host: "github.com", |
| 3409 | number: 7, |
| 3410 | changeType: "deleted", |
| 3411 | oldPath: "docs/encoding.md", |
| 3412 | newPath: "docs/encoding.md", |
| 3413 | }); |
| 3414 | |
| 3415 | assert.strictEqual(contents.oldContents, "before�after"); |
| 3416 | }), |
| 3417 | ); |
| 3418 | |
| 3419 | it.effect("ends the diff on a page with no files rather than asking for it again", () => |
| 3420 | Effect.gen(function* () { |
| 3421 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); |
| 3422 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3423 | |
| 3424 | const diff = yield* cli.getPullRequestDiff({ |
| 3425 | cwd: "/w", |
| 3426 | repository: "acme/web", |
| 3427 | host: "github.com", |
| 3428 | number: 7, |
| 3429 | cursor: "4", |
| 3430 | }); |
| 3431 | |
| 3432 | assert.strictEqual(diff.patch, ""); |
| 3433 | assert.isNull(diff.nextCursor); |
| 3434 | }), |
| 3435 | ); |
| 3436 | |
| 3437 | it.effect("reports the refused diff when the files API cannot answer either", () => |
| 3438 | Effect.gen(function* () { |
| 3439 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 3440 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); |
| 3441 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3442 | |
| 3443 | const error = yield* Effect.flip( |
| 3444 | cli.getPullRequestDiff({ |
| 3445 | cwd: "/w", |
| 3446 | repository: "acme/web", |
| 3447 | host: "github.com", |
| 3448 | number: 7, |
| 3449 | }), |
| 3450 | ); |
| 3451 | |
| 3452 | assert.strictEqual(error, diffRefused); |
| 3453 | }), |
| 3454 | ); |
| 3455 | |
| 3456 | it.effect("skips the avatar lookup when a listing named nobody", () => |
| 3457 | Effect.gen(function* () { |
| 3458 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3459 | |
| 3460 | const avatars = yield* cli.listActorAvatars({ |
| 3461 | cwd: "/w", |
| 3462 | repository: "acme/web", |
| 3463 | host: "github.com", |
| 3464 | ids: [], |
| 3465 | }); |
| 3466 | |
| 3467 | assert.strictEqual(avatars.size, 0); |
| 3468 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 3469 | }), |
| 3470 | ); |
| 3471 | |
| 3472 | it.effect("looks up avatars by node id in one GraphQL read", () => |
| 3473 | Effect.gen(function* () { |
| 3474 | mockedExecute.mockReturnValueOnce( |
| 3475 | Effect.succeed( |
| 3476 | output( |
| 3477 | encodeJson({ |
| 3478 | data: { |
| 3479 | nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], |
| 3480 | }, |
| 3481 | }), |
| 3482 | ), |
| 3483 | ), |
| 3484 | ); |
| 3485 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3486 | |
| 3487 | const avatars = yield* cli.listActorAvatars({ |
| 3488 | cwd: "/w", |
| 3489 | repository: "acme/web", |
| 3490 | host: "github.com", |
| 3491 | ids: ["MDQ6VXNlcjE="], |
| 3492 | }); |
| 3493 | |
| 3494 | expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); |
| 3495 | expect(avatars.get("octocat")).toBe("https://avatars/octocat"); |
| 3496 | }), |
| 3497 | ); |
| 3498 | |
| 3499 | it.effect("fails when the authenticated account has no login", () => |
| 3500 | Effect.gen(function* () { |
| 3501 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); |
| 3502 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3503 | |
| 3504 | const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); |
| 3505 | |
| 3506 | assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); |
| 3507 | }), |
| 3508 | ); |
| 3509 | |
| 3510 | it.effect("looks up the authenticated account on the requested enterprise host", () => |
| 3511 | Effect.gen(function* () { |
| 3512 | mockedExecute.mockReturnValueOnce( |
| 3513 | Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), |
| 3514 | ); |
| 3515 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3516 | |
| 3517 | const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); |
| 3518 | |
| 3519 | expect(login).toBe("enterprise-user"); |
| 3520 | expect(mockedCredential).toHaveBeenCalledWith("github.acme.com"); |
| 3521 | expect(callAt(0)).toMatchObject({ kind: "rest", host: "github.acme.com", path: "user" }); |
| 3522 | }), |
| 3523 | ); |
| 3524 | |
| 3525 | it.effect("reuses verified credentials offline and refuses an unverified replacement", () => |
| 3526 | Effect.gen(function* () { |
| 3527 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3528 | const input = { cwd: "/w", host: "github.identity-cache.test" }; |
| 3529 | mockedCredential.mockImplementation((host) => |
| 3530 | Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), |
| 3531 | ); |
| 3532 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); |
| 3533 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3534 | accountId: "123", |
| 3535 | viewer: "maria-rcks", |
| 3536 | }); |
| 3537 | |
| 3538 | // The same credential again is answered from what was verified, without asking GitHub. |
| 3539 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3540 | accountId: "123", |
| 3541 | viewer: "maria-rcks", |
| 3542 | }); |
| 3543 | expect(mockedExecute).toHaveBeenCalledTimes(1); |
| 3544 | |
| 3545 | // A switched credential is not trusted on the strength of the old one's answer. |
| 3546 | mockedCredential.mockImplementation((host) => |
| 3547 | Effect.succeed({ token: Redacted.make("test-credential-b"), fingerprint: `${host}:b` }), |
| 3548 | ); |
| 3549 | mockedExecute.mockReturnValueOnce( |
| 3550 | Effect.fail( |
| 3551 | new GitHubApi.GitHubApiResponseError({ |
| 3552 | host: "github.com", |
| 3553 | operation: "getRoutingIdentity", |
| 3554 | status: 502, |
| 3555 | }), |
| 3556 | ), |
| 3557 | ); |
| 3558 | // GitHub's own refusal is reported as itself, so the page can say what went wrong. |
| 3559 | const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); |
| 3560 | expect(failure._tag).toBe("GitHubApiResponseError"); |
| 3561 | expect(String(failure)).not.toContain("test-credential-b"); |
| 3562 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 3563 | |
| 3564 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); |
| 3565 | expect(yield* cli.getRoutingIdentity(input)).toEqual({ |
| 3566 | accountId: "456", |
| 3567 | viewer: "maria-rcks", |
| 3568 | }); |
| 3569 | }), |
| 3570 | ); |
| 3571 | |
| 3572 | it.effect("sends a whole review as one request", () => |
| 3573 | Effect.gen(function* () { |
| 3574 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3575 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3576 | |
| 3577 | yield* cli.submitReview({ |
| 3578 | cwd: "/w", |
| 3579 | repository: "acme/web", |
| 3580 | host: "github.com", |
| 3581 | number: 7, |
| 3582 | verdict: "approve", |
| 3583 | body: "Looks right.", |
| 3584 | comments: [{ path: "src/a.ts", position: { kind: "added", newLine: 4 }, body: "nit" }], |
| 3585 | }); |
| 3586 | |
| 3587 | // One request, so nothing is on the pull request until the verdict is. |
| 3588 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3589 | expect(restCallsTo("/reviews")).toMatchObject([ |
| 3590 | { |
| 3591 | method: "POST", |
| 3592 | path: "repos/acme/web/pulls/7/reviews", |
| 3593 | body: { |
| 3594 | event: "APPROVE", |
| 3595 | body: "Looks right.", |
| 3596 | comments: [{ path: "src/a.ts", line: 4, side: "RIGHT", body: "nit" }], |
| 3597 | }, |
| 3598 | }, |
| 3599 | ]); |
| 3600 | }), |
| 3601 | ); |
| 3602 | |
| 3603 | it.effect("sends a reply body as a variable, never inside the document", () => |
| 3604 | Effect.gen(function* () { |
| 3605 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3606 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3607 | |
| 3608 | yield* cli.replyToReviewThread({ |
| 3609 | cwd: "/w", |
| 3610 | repository: "acme/web", |
| 3611 | host: "github.com", |
| 3612 | threadId: "PRRT_1", |
| 3613 | body: "Fixed in 42ff8ec.", |
| 3614 | }); |
| 3615 | |
| 3616 | expect(variablesOf("addPullRequestReviewThreadReply(")).toEqual([ |
| 3617 | { threadId: "PRRT_1", body: "Fixed in 42ff8ec." }, |
| 3618 | ]); |
| 3619 | expect(queryAt(0)).not.toContain("Fixed in 42ff8ec."); |
| 3620 | }), |
| 3621 | ); |
| 3622 | |
| 3623 | it.effect("resolves and unresolves through the mutation each one needs", () => |
| 3624 | Effect.gen(function* () { |
| 3625 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 3626 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3627 | |
| 3628 | yield* cli.setReviewThreadResolution({ |
| 3629 | cwd: "/w", |
| 3630 | repository: "acme/web", |
| 3631 | host: "github.acme.dev", |
| 3632 | threadId: "PRRT_1", |
| 3633 | resolved: true, |
| 3634 | }); |
| 3635 | yield* cli.setReviewThreadResolution({ |
| 3636 | cwd: "/w", |
| 3637 | repository: "acme/web", |
| 3638 | host: "github.acme.dev", |
| 3639 | threadId: "PRRT_1", |
| 3640 | resolved: false, |
| 3641 | }); |
| 3642 | |
| 3643 | expect(queryAt(0)).toContain("resolveReviewThread("); |
| 3644 | expect(queryAt(0)).not.toContain("unresolveReviewThread("); |
| 3645 | expect(queryAt(1)).toContain("unresolveReviewThread("); |
| 3646 | expect([varsAt(0), varsAt(1)]).toEqual([{ threadId: "PRRT_1" }, { threadId: "PRRT_1" }]); |
| 3647 | // A GitHub Enterprise thread is resolved on its own host, not on github.com. |
| 3648 | expect([callAt(0).host, callAt(1).host]).toEqual(["github.acme.dev", "github.acme.dev"]); |
| 3649 | }), |
| 3650 | ); |
| 3651 | |
| 3652 | it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => |
| 3653 | Effect.gen(function* () { |
| 3654 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3655 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3656 | |
| 3657 | yield* cli.setReaction({ |
| 3658 | cwd: "/w", |
| 3659 | repository: "acme/web", |
| 3660 | host: "github.com", |
| 3661 | number: 7, |
| 3662 | subjectId: "IC_1", |
| 3663 | content: "heart", |
| 3664 | reacted: true, |
| 3665 | }); |
| 3666 | |
| 3667 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3668 | expect(variablesOf(SUBJECT_SCOPE_QUERY)).toEqual([ |
| 3669 | { owner: "acme", name: "web", number: 7, subjectId: "IC_1" }, |
| 3670 | ]); |
| 3671 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); |
| 3672 | }), |
| 3673 | ); |
| 3674 | |
| 3675 | it.effect("refuses a given subject that belongs to a different pull request", () => |
| 3676 | Effect.gen(function* () { |
| 3677 | mockedExecute.mockReturnValueOnce( |
| 3678 | Effect.succeed( |
| 3679 | output( |
| 3680 | encodeJson({ |
| 3681 | data: { |
| 3682 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3683 | // A comment on pull request #99 of a different repository, named as though it |
| 3684 | // belonged to #7 here. |
| 3685 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, |
| 3686 | }, |
| 3687 | }), |
| 3688 | ), |
| 3689 | ), |
| 3690 | ); |
| 3691 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3692 | |
| 3693 | const error = yield* Effect.flip( |
| 3694 | cli.setReaction({ |
| 3695 | cwd: "/w", |
| 3696 | repository: "acme/web", |
| 3697 | host: "github.com", |
| 3698 | number: 7, |
| 3699 | subjectId: "IC_99", |
| 3700 | content: "heart", |
| 3701 | reacted: true, |
| 3702 | }), |
| 3703 | ); |
| 3704 | |
| 3705 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); |
| 3706 | // Refused before any mutation was sent. |
| 3707 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3708 | }), |
| 3709 | ); |
| 3710 | |
| 3711 | it.effect("looks up the pull request's own node id when no subject was given", () => |
| 3712 | Effect.gen(function* () { |
| 3713 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); |
| 3714 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3715 | |
| 3716 | // Its own pull request: a node id looked up once is remembered for the life of the service. |
| 3717 | yield* cli.setReaction({ |
| 3718 | cwd: "/w", |
| 3719 | repository: "acme/web", |
| 3720 | host: "github.com", |
| 3721 | number: 21, |
| 3722 | content: "rocket", |
| 3723 | reacted: true, |
| 3724 | }); |
| 3725 | |
| 3726 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3727 | expect(variablesOf(NODE_ID_QUERY)).toEqual([{ owner: "acme", name: "web", number: 21 }]); |
| 3728 | expect(variablesOf("addReaction(")).toEqual([{ subjectId: "PR_kwDOA", content: "ROCKET" }]); |
| 3729 | }), |
| 3730 | ); |
| 3731 | |
| 3732 | it.effect("takes a reaction back through the remove mutation", () => |
| 3733 | Effect.gen(function* () { |
| 3734 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3735 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3736 | |
| 3737 | yield* cli.setReaction({ |
| 3738 | cwd: "/w", |
| 3739 | repository: "acme/web", |
| 3740 | host: "github.com", |
| 3741 | number: 7, |
| 3742 | subjectId: "IC_1", |
| 3743 | content: "heart", |
| 3744 | reacted: false, |
| 3745 | }); |
| 3746 | |
| 3747 | expect(variablesOf("removeReaction(")).toEqual([{ subjectId: "IC_1", content: "HEART" }]); |
| 3748 | expect(variablesOf("addReaction(")).toEqual([]); |
| 3749 | }), |
| 3750 | ); |
| 3751 | |
| 3752 | it.effect("rewrites only the words a request named", () => |
| 3753 | Effect.gen(function* () { |
| 3754 | route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); |
| 3755 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3756 | const rewrite = (fields: { readonly title?: string; readonly body?: string }) => |
| 3757 | cli.updatePullRequest({ |
| 3758 | cwd: "/w", |
| 3759 | repository: "acme/web", |
| 3760 | host: "github.com", |
| 3761 | number: 22, |
| 3762 | ...fields, |
| 3763 | }); |
| 3764 | |
| 3765 | yield* rewrite({ title: "A better title" }); |
| 3766 | yield* rewrite({ body: "A better description." }); |
| 3767 | yield* rewrite({ title: "Both", body: "at once." }); |
| 3768 | |
| 3769 | // One node id lookup for the pull request, then a mutation per rewrite. |
| 3770 | assert.strictEqual(variablesOf(NODE_ID_QUERY).length, 1); |
| 3771 | expect(variablesOf("updatePullRequest(")).toEqual([ |
| 3772 | { pullRequestId: "PR_kwDOA", title: "A better title" }, |
| 3773 | { pullRequestId: "PR_kwDOA", body: "A better description." }, |
| 3774 | { pullRequestId: "PR_kwDOA", title: "Both", body: "at once." }, |
| 3775 | ]); |
| 3776 | }), |
| 3777 | ); |
| 3778 | |
| 3779 | it.effect("rewrites a remark through the mutation its kind needs", () => |
| 3780 | Effect.gen(function* () { |
| 3781 | route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); |
| 3782 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3783 | const rewrite = (kind: "issue-comment" | "review-comment") => |
| 3784 | cli.updateComment({ |
| 3785 | cwd: "/w", |
| 3786 | repository: "acme/web", |
| 3787 | host: "github.com", |
| 3788 | number: 7, |
| 3789 | commentId: "IC_1", |
| 3790 | kind, |
| 3791 | body: "Reworded.", |
| 3792 | }); |
| 3793 | |
| 3794 | yield* rewrite("issue-comment"); |
| 3795 | yield* rewrite("review-comment"); |
| 3796 | |
| 3797 | expect(variablesOf(SUBJECT_SCOPE_QUERY).map((variables) => variables["subjectId"])).toEqual([ |
| 3798 | "IC_1", |
| 3799 | "IC_1", |
| 3800 | ]); |
| 3801 | expect(variablesOf("updateIssueComment(")).toEqual([ |
| 3802 | { commentId: "IC_1", body: "Reworded." }, |
| 3803 | ]); |
| 3804 | expect(variablesOf("updatePullRequestReviewComment(")).toEqual([ |
| 3805 | { commentId: "IC_1", body: "Reworded." }, |
| 3806 | ]); |
| 3807 | }), |
| 3808 | ); |
| 3809 | |
| 3810 | it.effect("refuses a comment that belongs to a different pull request", () => |
| 3811 | Effect.gen(function* () { |
| 3812 | mockedExecute.mockReturnValueOnce( |
| 3813 | Effect.succeed( |
| 3814 | output( |
| 3815 | encodeJson({ |
| 3816 | data: { |
| 3817 | repository: { pullRequest: { id: "PR_thisOne" } }, |
| 3818 | node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, |
| 3819 | }, |
| 3820 | }), |
| 3821 | ), |
| 3822 | ), |
| 3823 | ); |
| 3824 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3825 | |
| 3826 | const error = yield* Effect.flip( |
| 3827 | cli.updateComment({ |
| 3828 | cwd: "/w", |
| 3829 | repository: "acme/web", |
| 3830 | host: "github.com", |
| 3831 | number: 7, |
| 3832 | commentId: "IC_99", |
| 3833 | kind: "issue-comment", |
| 3834 | body: "Reworded.", |
| 3835 | }), |
| 3836 | ); |
| 3837 | |
| 3838 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); |
| 3839 | if (error._tag === "GitHubSubjectScopeError") |
| 3840 | assert.strictEqual(error.operation, "updateComment"); |
| 3841 | // Refused before any mutation was sent. |
| 3842 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 3843 | }), |
| 3844 | ); |
| 3845 | |
| 3846 | it.effect("fails the read when gh returns something unreadable", () => |
| 3847 | Effect.gen(function* () { |
| 3848 | mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); |
| 3849 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3850 | |
| 3851 | const error = yield* Effect.flip( |
| 3852 | cli.getPullRequestDetail({ |
| 3853 | cwd: "/w", |
| 3854 | repository: "acme/web", |
| 3855 | host: "github.com", |
| 3856 | number: 7, |
| 3857 | }), |
| 3858 | ); |
| 3859 | |
| 3860 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); |
| 3861 | }), |
| 3862 | ); |
| 3863 | |
| 3864 | it.effect("keeps the core detail read separate from conversation activity", () => |
| 3865 | Effect.gen(function* () { |
| 3866 | mockedExecute.mockReturnValueOnce( |
| 3867 | Effect.succeed( |
| 3868 | output( |
| 3869 | encodeJson( |
| 3870 | coreResponse({ |
| 3871 | title: "Progressive detail", |
| 3872 | author: { login: "octocat" }, |
| 3873 | body: "Core body", |
| 3874 | changedFiles: 2, |
| 3875 | }), |
| 3876 | ), |
| 3877 | ), |
| 3878 | ), |
| 3879 | ); |
| 3880 | mockedExecute.mockReturnValueOnce( |
| 3881 | Effect.succeed( |
| 3882 | output( |
| 3883 | encodeJson({ |
| 3884 | data: { |
| 3885 | repository: { |
| 3886 | pullRequest: { |
| 3887 | author: { __typename: "User", login: "octocat", avatarUrl: "https://a/o" }, |
| 3888 | commits: { nodes: [] }, |
| 3889 | comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, |
| 3890 | reviews: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, |
| 3891 | }, |
| 3892 | }, |
| 3893 | }, |
| 3894 | }), |
| 3895 | ), |
| 3896 | ), |
| 3897 | ); |
| 3898 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3899 | const input = { |
| 3900 | cwd: "/w", |
| 3901 | repository: "acme/web", |
| 3902 | host: "github.com", |
| 3903 | number: 7, |
| 3904 | } as const; |
| 3905 | |
| 3906 | const detail = yield* cli.getPullRequestDetail(input); |
| 3907 | const activity = yield* cli.getPullRequestActivity(input); |
| 3908 | |
| 3909 | expect(detail.body).toBe("Core body"); |
| 3910 | expect(activity.author?.login).toBe("octocat"); |
| 3911 | expect(varsAt(0)).toMatchObject({ number: 7, headRef: "refs/pull/7/head" }); |
| 3912 | expect(queryAt(0)).toContain("viewerCanUpdateBranch"); |
| 3913 | expect(queryAt(0)).not.toContain("reviews("); |
| 3914 | expect(detail.viewerAccess.mergeCapabilities).toEqual({ |
| 3915 | merge: true, |
| 3916 | squash: false, |
| 3917 | rebase: true, |
| 3918 | }); |
| 3919 | expect(detail.comparison).toEqual({ behindBy: 2, viewerCanUpdate: true }); |
| 3920 | // Conversation activity is its own read, and asks for the head of the conversation once. |
| 3921 | expect(queryAt(1)).toContain("reviews("); |
| 3922 | expect(varsAt(1)).toMatchObject({ head: true, withComments: true, withReviews: true }); |
| 3923 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 3924 | }), |
| 3925 | ); |
| 3926 | |
| 3927 | it.effect("decodes reviewers, labels and workflow checks without another detail read", () => |
| 3928 | Effect.gen(function* () { |
| 3929 | mockedExecute.mockReturnValueOnce( |
| 3930 | Effect.succeed( |
| 3931 | output( |
| 3932 | encodeJson( |
| 3933 | coreResponse({ |
| 3934 | baseRef: null, |
| 3935 | reviewRequests: { |
| 3936 | nodes: [ |
| 3937 | { requestedReviewer: { login: "reviewer" } }, |
| 3938 | { |
| 3939 | requestedReviewer: { |
| 3940 | slug: "maintainers", |
| 3941 | combinedSlug: "octo/maintainers", |
| 3942 | name: "Maintainers", |
| 3943 | avatarUrl: "https://avatars.githubusercontent.com/t/1", |
| 3944 | }, |
| 3945 | }, |
| 3946 | ], |
| 3947 | }, |
| 3948 | labels: { nodes: [{ name: "bug", color: "ff0000" }] }, |
| 3949 | commits: { |
| 3950 | nodes: [ |
| 3951 | { |
| 3952 | commit: { |
| 3953 | statusCheckRollup: { |
| 3954 | contexts: { |
| 3955 | nodes: [ |
| 3956 | { |
| 3957 | __typename: "CheckRun", |
| 3958 | name: "build", |
| 3959 | status: "COMPLETED", |
| 3960 | conclusion: "SUCCESS", |
| 3961 | checkSuite: { workflowRun: { workflow: { name: "linux" } } }, |
| 3962 | }, |
| 3963 | { |
| 3964 | __typename: "CheckRun", |
| 3965 | name: "build", |
| 3966 | status: "COMPLETED", |
| 3967 | conclusion: "FAILURE", |
| 3968 | checkSuite: { workflowRun: { workflow: { name: "windows" } } }, |
| 3969 | }, |
| 3970 | ], |
| 3971 | pageInfo: { hasNextPage: false }, |
| 3972 | }, |
| 3973 | }, |
| 3974 | }, |
| 3975 | }, |
| 3976 | ], |
| 3977 | }, |
| 3978 | }), |
| 3979 | ), |
| 3980 | ), |
| 3981 | ), |
| 3982 | ); |
| 3983 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 3984 | const detail = yield* cli.getPullRequestDetail({ |
| 3985 | cwd: "/w", |
| 3986 | repository: "acme/web", |
| 3987 | host: "github.com", |
| 3988 | number: 7, |
| 3989 | }); |
| 3990 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 3991 | expect(detail.comparison).toBeNull(); |
| 3992 | expect(detail.reviewRequestLogins).toEqual(["reviewer"]); |
| 3993 | expect(detail.hasTeamReviewRequest).toBe(true); |
| 3994 | expect(detail.reviewRequestTeams).toEqual([ |
| 3995 | { |
| 3996 | login: "octo/maintainers", |
| 3997 | name: "Maintainers", |
| 3998 | avatarUrl: "https://avatars.githubusercontent.com/t/1", |
| 3999 | }, |
| 4000 | ]); |
| 4001 | expect(detail.labels).toEqual([{ name: "bug", color: "ff0000" }]); |
| 4002 | expect(detail.checks).toHaveLength(2); |
| 4003 | expect(detail.checksState).toBe("failing"); |
| 4004 | }), |
| 4005 | ); |
| 4006 | |
| 4007 | it.effect("reads every check when the combined response has another page", () => |
| 4008 | Effect.gen(function* () { |
| 4009 | mockedExecute.mockReturnValueOnce( |
| 4010 | Effect.succeed( |
| 4011 | output( |
| 4012 | encodeJson( |
| 4013 | coreResponse({ |
| 4014 | commits: { |
| 4015 | nodes: [ |
| 4016 | { |
| 4017 | commit: { |
| 4018 | statusCheckRollup: { |
| 4019 | contexts: { |
| 4020 | nodes: [{ name: "first", status: "COMPLETED", conclusion: "SUCCESS" }], |
| 4021 | pageInfo: { hasNextPage: true, endCursor: "c1" }, |
| 4022 | }, |
| 4023 | }, |
| 4024 | }, |
| 4025 | }, |
| 4026 | ], |
| 4027 | }, |
| 4028 | }), |
| 4029 | ), |
| 4030 | ), |
| 4031 | ), |
| 4032 | ); |
| 4033 | mockedExecute.mockReturnValueOnce( |
| 4034 | Effect.succeed( |
| 4035 | output( |
| 4036 | checkContextsPage( |
| 4037 | [ |
| 4038 | { name: "first", status: "COMPLETED", conclusion: "SUCCESS" }, |
| 4039 | { name: "last", status: "COMPLETED", conclusion: "FAILURE" }, |
| 4040 | ], |
| 4041 | null, |
| 4042 | ), |
| 4043 | ), |
| 4044 | ), |
| 4045 | ); |
| 4046 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4047 | const detail = yield* cli.getPullRequestDetail({ |
| 4048 | cwd: "/w", |
| 4049 | repository: "acme/web", |
| 4050 | host: "github.com", |
| 4051 | number: 7, |
| 4052 | }); |
| 4053 | expect(detail.checks).toHaveLength(2); |
| 4054 | expect(detail.checksState).toBe("failing"); |
| 4055 | expect(detail.checksTruncated).toBe(false); |
| 4056 | // The whole rollup is walked from its start, so no check is counted twice or skipped. |
| 4057 | expect(varsAt(1)).toMatchObject({ number: 7, after: null }); |
| 4058 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4059 | }), |
| 4060 | ); |
| 4061 | |
| 4062 | it.effect("refuses to combine checks from different head revisions", () => |
| 4063 | Effect.gen(function* () { |
| 4064 | const response = coreResponse({ |
| 4065 | commits: { |
| 4066 | nodes: [ |
| 4067 | { |
| 4068 | commit: { |
| 4069 | statusCheckRollup: { |
| 4070 | contexts: { |
| 4071 | nodes: [], |
| 4072 | pageInfo: { hasNextPage: true }, |
| 4073 | }, |
| 4074 | }, |
| 4075 | }, |
| 4076 | }, |
| 4077 | ], |
| 4078 | }, |
| 4079 | }); |
| 4080 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(encodeJson(response)))); |
| 4081 | mockedExecute.mockReturnValueOnce( |
| 4082 | Effect.succeed( |
| 4083 | output( |
| 4084 | encodeJson({ |
| 4085 | ...response.data.repository.pullRequest, |
| 4086 | headRefOid: "new-head", |
| 4087 | reviewRequests: [], |
| 4088 | labels: [], |
| 4089 | statusCheckRollup: [], |
| 4090 | }), |
| 4091 | ), |
| 4092 | ), |
| 4093 | ); |
| 4094 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4095 | const error = yield* Effect.flip( |
| 4096 | cli.getPullRequestDetail({ |
| 4097 | cwd: "/w", |
| 4098 | repository: "acme/web", |
| 4099 | host: "github.com", |
| 4100 | number: 7, |
| 4101 | }), |
| 4102 | ); |
| 4103 | expect(error._tag).toBe("GitHubPullRequestReadError"); |
| 4104 | }), |
| 4105 | ); |
| 4106 | |
| 4107 | it.effect("preserves the reserve for automatic detail reads and allows manual checks", () => |
| 4108 | Effect.gen(function* () { |
| 4109 | const response = coreResponse(); |
| 4110 | mockedExecute.mockReturnValue( |
| 4111 | Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), |
| 4112 | ); |
| 4113 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4114 | const input = { |
| 4115 | cwd: "/w", |
| 4116 | repository: "acme/web", |
| 4117 | host: "github.core-reserve.test", |
| 4118 | number: 7, |
| 4119 | }; |
| 4120 | yield* cli.getPullRequestDetail(input); |
| 4121 | const error = yield* Effect.flip(cli.getPullRequestDetail(input)); |
| 4122 | expect(error._tag).toBe("SourceControlRateLimitPausedError"); |
| 4123 | expect(mockedExecute).toHaveBeenCalledOnce(); |
| 4124 | yield* cli |
| 4125 | .getPullRequestDetail(input) |
| 4126 | .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)); |
| 4127 | expect(mockedExecute).toHaveBeenCalledTimes(2); |
| 4128 | }), |
| 4129 | ); |
| 4130 | |
| 4131 | it.effect("fails a files page too large to read rather than calling the diff whole", () => |
| 4132 | Effect.gen(function* () { |
| 4133 | mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); |
| 4134 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); |
| 4135 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4136 | |
| 4137 | const error = yield* Effect.flip( |
| 4138 | cli.getPullRequestDiff({ |
| 4139 | cwd: "/w", |
| 4140 | repository: "acme/web", |
| 4141 | host: "github.com", |
| 4142 | number: 7, |
| 4143 | }), |
| 4144 | ); |
| 4145 | |
| 4146 | // What matters is that it fails at all: an empty patch with no cursor would render as a |
| 4147 | // change with no files and report the rest of it as already read. The refusal that sent |
| 4148 | // the read down this road is the one reported, by design. |
| 4149 | assert.strictEqual(error, diffRefused); |
| 4150 | }), |
| 4151 | ); |
| 4152 | |
| 4153 | it.effect("pages an oversized patch by file rather than handing back a severed one", () => |
| 4154 | Effect.gen(function* () { |
| 4155 | // The whole diff came back, but cut at a byte, which lands mid-file. |
| 4156 | mockedExecute.mockReturnValueOnce( |
| 4157 | Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), |
| 4158 | ); |
| 4159 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); |
| 4160 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4161 | |
| 4162 | const slice = yield* cli.getPullRequestDiff({ |
| 4163 | cwd: "/w", |
| 4164 | repository: "acme/web", |
| 4165 | host: "github.com", |
| 4166 | number: 7, |
| 4167 | }); |
| 4168 | |
| 4169 | // The severed patch is thrown away; what comes back is assembled from whole files. |
| 4170 | assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); |
| 4171 | expect(slice.patch).toContain("src/file1.ts"); |
| 4172 | expect(slice.patch).not.toContain("diff --git a/a b/a"); |
| 4173 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4174 | }), |
| 4175 | ); |
| 4176 | |
| 4177 | it.effect("follows the cursor to the review threads the first page left behind", () => |
| 4178 | Effect.gen(function* () { |
| 4179 | mockedExecute.mockReturnValueOnce( |
| 4180 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), |
| 4181 | ); |
| 4182 | mockedExecute.mockReturnValueOnce( |
| 4183 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), |
| 4184 | ); |
| 4185 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4186 | |
| 4187 | const conversation = yield* cli.listReviewThreadComments({ |
| 4188 | cwd: "/w", |
| 4189 | repository: "acme/web", |
| 4190 | host: "github.com", |
| 4191 | number: 7, |
| 4192 | }); |
| 4193 | |
| 4194 | // The first page asks from the beginning; the next carries on from where it stopped. |
| 4195 | expect(varsAt(0)["cursor"]).toBeNull(); |
| 4196 | expect(varsAt(1)["cursor"]).toBe("Y3Vyc29yOjE"); |
| 4197 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1", "c2"]); |
| 4198 | assert.isFalse(conversation.truncated); |
| 4199 | }), |
| 4200 | ); |
| 4201 | |
| 4202 | it.effect("stops at the thread bound and says the conversation was cut short", () => |
| 4203 | Effect.gen(function* () { |
| 4204 | // A host that never runs out of pages: the walk has to end itself. |
| 4205 | mockedExecute.mockReturnValue( |
| 4206 | Effect.succeed( |
| 4207 | output( |
| 4208 | reviewThreadsPage( |
| 4209 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], |
| 4210 | "Y3Vyc29yOjE", |
| 4211 | ), |
| 4212 | ), |
| 4213 | ), |
| 4214 | ); |
| 4215 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4216 | |
| 4217 | const conversation = yield* cli.listReviewThreadComments({ |
| 4218 | cwd: "/w", |
| 4219 | repository: "acme/web", |
| 4220 | host: "github.com", |
| 4221 | number: 7, |
| 4222 | }); |
| 4223 | |
| 4224 | assert.strictEqual(mockedExecute.mock.calls.length, 10); |
| 4225 | assert.isTrue(conversation.truncated); |
| 4226 | assert.isTrue(conversation.reviewThreadsTruncated); |
| 4227 | }), |
| 4228 | ); |
| 4229 | |
| 4230 | it.effect("leaves a long thread paged until the reader asks for more", () => |
| 4231 | Effect.gen(function* () { |
| 4232 | mockedExecute.mockReturnValueOnce( |
| 4233 | Effect.succeed( |
| 4234 | output( |
| 4235 | reviewThreadsPage( |
| 4236 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], |
| 4237 | null, |
| 4238 | ), |
| 4239 | ), |
| 4240 | ), |
| 4241 | ); |
| 4242 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4243 | |
| 4244 | const conversation = yield* cli.listReviewThreadComments({ |
| 4245 | cwd: "/w", |
| 4246 | repository: "acme/web", |
| 4247 | host: "github.com", |
| 4248 | number: 7, |
| 4249 | }); |
| 4250 | |
| 4251 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4252 | expect(conversation.comments.map((comment) => comment.id)).toEqual(["c1"]); |
| 4253 | expect(conversation.reviewThreads[0]).toMatchObject({ |
| 4254 | commentCount: 3, |
| 4255 | nextCommentsCursor: "Y3Vyc29yOjI", |
| 4256 | }); |
| 4257 | assert.isTrue(conversation.truncated); |
| 4258 | assert.isFalse(conversation.reviewThreadsTruncated); |
| 4259 | }), |
| 4260 | ); |
| 4261 | |
| 4262 | it.effect("reads one requested page from a review thread cursor", () => |
| 4263 | Effect.gen(function* () { |
| 4264 | mockedExecute.mockReturnValueOnce( |
| 4265 | Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), |
| 4266 | ); |
| 4267 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4268 | |
| 4269 | const page = yield* cli.getReviewThreadComments({ |
| 4270 | cwd: "/w", |
| 4271 | repository: "acme/web", |
| 4272 | host: "github.com", |
| 4273 | number: 7, |
| 4274 | threadId: "PRRT_1", |
| 4275 | cursor: "Y3Vyc29yOjI", |
| 4276 | }); |
| 4277 | |
| 4278 | expect(varsAt(0)).toMatchObject({ |
| 4279 | owner: "acme", |
| 4280 | name: "web", |
| 4281 | number: 7, |
| 4282 | threadId: "PRRT_1", |
| 4283 | cursor: "Y3Vyc29yOjI", |
| 4284 | }); |
| 4285 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4286 | expect(page.comments.map((comment) => comment.id)).toEqual(["c2", "c3"]); |
| 4287 | expect(page.nextCursor).toBeNull(); |
| 4288 | }), |
| 4289 | ); |
| 4290 | |
| 4291 | it.effect("refuses a review thread from another pull request", () => |
| 4292 | Effect.gen(function* () { |
| 4293 | mockedExecute.mockReturnValueOnce( |
| 4294 | Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), |
| 4295 | ); |
| 4296 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4297 | |
| 4298 | const error = yield* Effect.flip( |
| 4299 | cli.getReviewThreadComments({ |
| 4300 | cwd: "/w", |
| 4301 | repository: "acme/web", |
| 4302 | host: "github.com", |
| 4303 | number: 7, |
| 4304 | threadId: "PRRT_FOREIGN", |
| 4305 | cursor: "Y3Vyc29yOjI", |
| 4306 | }), |
| 4307 | ); |
| 4308 | |
| 4309 | assert.strictEqual(error._tag, "GitHubSubjectScopeError"); |
| 4310 | }), |
| 4311 | ); |
| 4312 | |
| 4313 | it.effect( |
| 4314 | "asks for the reader's standing on the repository and on the pull request at once", |
| 4315 | () => |
| 4316 | Effect.gen(function* () { |
| 4317 | mockedExecute.mockReturnValue( |
| 4318 | Effect.succeed( |
| 4319 | output( |
| 4320 | encodeJson({ |
| 4321 | data: { |
| 4322 | repository: { |
| 4323 | mergeCommitAllowed: true, |
| 4324 | squashMergeAllowed: false, |
| 4325 | rebaseMergeAllowed: true, |
| 4326 | viewerPermission: "READ", |
| 4327 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 4328 | }, |
| 4329 | }, |
| 4330 | }), |
| 4331 | ), |
| 4332 | ), |
| 4333 | ); |
| 4334 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4335 | |
| 4336 | const access = yield* cli.getViewerAccess({ |
| 4337 | cwd: "/w", |
| 4338 | repository: "acme/web", |
| 4339 | host: "github.com", |
| 4340 | number: 7, |
| 4341 | }); |
| 4342 | |
| 4343 | // One request, because both answers hang off the same repository object. |
| 4344 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4345 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 4346 | expect(queryAt(0)).toContain("mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed"); |
| 4347 | expect(access).toEqual({ |
| 4348 | mergeCapabilities: { merge: true, squash: false, rebase: true }, |
| 4349 | canWrite: false, |
| 4350 | canDeleteBranch: false, |
| 4351 | canTriage: false, |
| 4352 | canUpdate: true, |
| 4353 | didAuthor: true, |
| 4354 | }); |
| 4355 | }), |
| 4356 | ); |
| 4357 | |
| 4358 | it.effect("stops GraphQL reads at the protected reserve until reset", () => |
| 4359 | Effect.gen(function* () { |
| 4360 | mockedExecute.mockReturnValue( |
| 4361 | Effect.succeed( |
| 4362 | output( |
| 4363 | encodeJson({ |
| 4364 | data: { |
| 4365 | repository: { |
| 4366 | mergeCommitAllowed: true, |
| 4367 | squashMergeAllowed: false, |
| 4368 | rebaseMergeAllowed: true, |
| 4369 | viewerPermission: "READ", |
| 4370 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 4371 | }, |
| 4372 | }, |
| 4373 | }), |
| 4374 | false, |
| 4375 | false, |
| 4376 | graphqlQuota(499), |
| 4377 | ), |
| 4378 | ), |
| 4379 | ); |
| 4380 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4381 | const input = { |
| 4382 | cwd: "/w", |
| 4383 | repository: "acme/web", |
| 4384 | host: "github.com", |
| 4385 | number: 7, |
| 4386 | } as const; |
| 4387 | |
| 4388 | yield* cli.getViewerAccess(input); |
| 4389 | |
| 4390 | const error = yield* Effect.flip(cli.getViewerAccess(input)); |
| 4391 | |
| 4392 | assert.strictEqual(error._tag, "SourceControlRateLimitPausedError"); |
| 4393 | if (error._tag !== "SourceControlRateLimitPausedError") return; |
| 4394 | assert.strictEqual(error.host, "github.com"); |
| 4395 | assert.strictEqual(error.retryAt, Date.parse("2099-08-13T14:00:00Z")); |
| 4396 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4397 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); |
| 4398 | }), |
| 4399 | ); |
| 4400 | |
| 4401 | it.effect("lets an interactive permission read use the protected reserve", () => |
| 4402 | Effect.gen(function* () { |
| 4403 | mockedExecute |
| 4404 | .mockReturnValueOnce( |
| 4405 | Effect.succeed( |
| 4406 | output( |
| 4407 | encodeJson({ |
| 4408 | data: { |
| 4409 | repository: { |
| 4410 | mergeCommitAllowed: true, |
| 4411 | squashMergeAllowed: false, |
| 4412 | rebaseMergeAllowed: true, |
| 4413 | viewerPermission: "READ", |
| 4414 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 4415 | }, |
| 4416 | }, |
| 4417 | }), |
| 4418 | false, |
| 4419 | false, |
| 4420 | graphqlQuota(499), |
| 4421 | ), |
| 4422 | ), |
| 4423 | ) |
| 4424 | .mockReturnValueOnce( |
| 4425 | Effect.succeed( |
| 4426 | output( |
| 4427 | encodeJson({ |
| 4428 | data: { |
| 4429 | repository: { |
| 4430 | mergeCommitAllowed: true, |
| 4431 | squashMergeAllowed: false, |
| 4432 | rebaseMergeAllowed: true, |
| 4433 | viewerPermission: "READ", |
| 4434 | pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, |
| 4435 | }, |
| 4436 | }, |
| 4437 | }), |
| 4438 | ), |
| 4439 | ), |
| 4440 | ); |
| 4441 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4442 | |
| 4443 | yield* cli.getViewerAccess({ |
| 4444 | cwd: "/w", |
| 4445 | repository: "acme/web", |
| 4446 | host: "github.com", |
| 4447 | number: 7, |
| 4448 | }); |
| 4449 | const access = yield* cli.getViewerAccess({ |
| 4450 | cwd: "/w", |
| 4451 | repository: "acme/web", |
| 4452 | host: "github.com", |
| 4453 | number: 7, |
| 4454 | allowReserve: true, |
| 4455 | }); |
| 4456 | |
| 4457 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4458 | expect(access).toEqual({ |
| 4459 | mergeCapabilities: { merge: true, squash: false, rebase: true }, |
| 4460 | canWrite: false, |
| 4461 | canDeleteBranch: false, |
| 4462 | canTriage: false, |
| 4463 | canUpdate: true, |
| 4464 | didAuthor: true, |
| 4465 | }); |
| 4466 | yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); |
| 4467 | }), |
| 4468 | ); |
| 4469 | |
| 4470 | it.effect("asks GitHub to review, naming the collection a request is added to", () => |
| 4471 | Effect.gen(function* () { |
| 4472 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 4473 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4474 | |
| 4475 | yield* cli.setReviewerRequest({ |
| 4476 | cwd: "/w", |
| 4477 | repository: "acme/web", |
| 4478 | host: "github.com", |
| 4479 | number: 7, |
| 4480 | reviewers: [ |
| 4481 | { id: "octocat", kind: "user" }, |
| 4482 | { id: "reviewers", kind: "team" }, |
| 4483 | ], |
| 4484 | requested: true, |
| 4485 | }); |
| 4486 | |
| 4487 | expect(callAt(0)).toMatchObject({ |
| 4488 | kind: "rest", |
| 4489 | method: "POST", |
| 4490 | path: "repos/acme/web/pulls/7/requested_reviewers", |
| 4491 | body: { reviewers: ["octocat"], team_reviewers: ["reviewers"] }, |
| 4492 | }); |
| 4493 | }), |
| 4494 | ); |
| 4495 | |
| 4496 | it.effect("takes a request back by deleting from the same collection it was added to", () => |
| 4497 | Effect.gen(function* () { |
| 4498 | mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); |
| 4499 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4500 | |
| 4501 | yield* cli.setReviewerRequest({ |
| 4502 | cwd: "/w", |
| 4503 | repository: "acme/web", |
| 4504 | host: "github.com", |
| 4505 | number: 7, |
| 4506 | reviewers: [{ id: "octocat", kind: "user" }], |
| 4507 | requested: false, |
| 4508 | }); |
| 4509 | |
| 4510 | expect(callAt(0)).toMatchObject({ |
| 4511 | kind: "rest", |
| 4512 | method: "DELETE", |
| 4513 | path: "repos/acme/web/pulls/7/requested_reviewers", |
| 4514 | body: { reviewers: ["octocat"], team_reviewers: [] }, |
| 4515 | }); |
| 4516 | }), |
| 4517 | ); |
| 4518 | |
| 4519 | it.effect("reads who may review and who already has in one request", () => |
| 4520 | Effect.gen(function* () { |
| 4521 | mockedExecute.mockReturnValue( |
| 4522 | Effect.succeed( |
| 4523 | output( |
| 4524 | encodeJson({ |
| 4525 | data: { |
| 4526 | repository: { |
| 4527 | assignableUsers: { |
| 4528 | pageInfo: { hasNextPage: false }, |
| 4529 | nodes: [{ login: "bilal" }, { login: "octocat" }, { login: "hubot" }], |
| 4530 | }, |
| 4531 | pullRequest: { |
| 4532 | author: { login: "bilal" }, |
| 4533 | reviewRequests: { nodes: [{ requestedReviewer: { login: "octocat" } }] }, |
| 4534 | }, |
| 4535 | }, |
| 4536 | }, |
| 4537 | }), |
| 4538 | ), |
| 4539 | ), |
| 4540 | ); |
| 4541 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4542 | |
| 4543 | const list = yield* cli.listReviewerCandidates({ |
| 4544 | cwd: "/w", |
| 4545 | repository: "acme/web", |
| 4546 | host: "github.com", |
| 4547 | number: 7, |
| 4548 | }); |
| 4549 | |
| 4550 | // The people, who has been asked and who opened the pull request all hang off the same |
| 4551 | // repository object, so the menu costs one request. |
| 4552 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4553 | expect(varsAt(0)).toMatchObject({ owner: "acme", name: "web", number: 7 }); |
| 4554 | expect(list.candidates.map((candidate) => [candidate.login, candidate.isRequested])).toEqual([ |
| 4555 | ["octocat", true], |
| 4556 | ["hubot", false], |
| 4557 | ]); |
| 4558 | }), |
| 4559 | ); |
| 4560 | |
| 4561 | it.effect("puts labels on by posting to the issue's own collection, all at once", () => |
| 4562 | Effect.gen(function* () { |
| 4563 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); |
| 4564 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4565 | |
| 4566 | yield* cli.setLabels({ |
| 4567 | cwd: "/w", |
| 4568 | repository: "acme/web", |
| 4569 | host: "github.com", |
| 4570 | number: 7, |
| 4571 | labels: ["bug", "size:XL"], |
| 4572 | applied: true, |
| 4573 | }); |
| 4574 | |
| 4575 | assert.strictEqual(mockedExecute.mock.calls.length, 1); |
| 4576 | expect(callAt(0)).toMatchObject({ |
| 4577 | kind: "rest", |
| 4578 | method: "POST", |
| 4579 | path: "repos/acme/web/issues/7/labels", |
| 4580 | body: { labels: ["bug", "size:XL"] }, |
| 4581 | }); |
| 4582 | }), |
| 4583 | ); |
| 4584 | |
| 4585 | it.effect("takes labels off one at a time, naming each in the path encoded", () => |
| 4586 | Effect.gen(function* () { |
| 4587 | mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); |
| 4588 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4589 | |
| 4590 | yield* cli.setLabels({ |
| 4591 | cwd: "/w", |
| 4592 | repository: "acme/web", |
| 4593 | host: "github.com", |
| 4594 | number: 7, |
| 4595 | labels: ["good first issue", "area/web"], |
| 4596 | applied: false, |
| 4597 | }); |
| 4598 | |
| 4599 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4600 | expect(callAt(0)).toMatchObject({ |
| 4601 | method: "DELETE", |
| 4602 | path: "repos/acme/web/issues/7/labels/good%20first%20issue", |
| 4603 | }); |
| 4604 | expect(callAt(1)).toMatchObject({ |
| 4605 | method: "DELETE", |
| 4606 | path: "repos/acme/web/issues/7/labels/area%2Fweb", |
| 4607 | }); |
| 4608 | }), |
| 4609 | ); |
| 4610 | |
| 4611 | it.effect("reads every page of viewed files, and says so when there are too many", () => |
| 4612 | Effect.gen(function* () { |
| 4613 | const page = (index: number, hasNextPage: boolean) => |
| 4614 | Effect.succeed( |
| 4615 | output( |
| 4616 | encodeJson({ |
| 4617 | data: { |
| 4618 | repository: { |
| 4619 | pullRequest: { |
| 4620 | files: { |
| 4621 | pageInfo: { hasNextPage, endCursor: `cursor-${index}` }, |
| 4622 | nodes: [ |
| 4623 | { path: `src/file${index}.ts`, viewerViewedState: "VIEWED" }, |
| 4624 | { path: `src/other${index}.ts`, viewerViewedState: "UNVIEWED" }, |
| 4625 | ], |
| 4626 | }, |
| 4627 | }, |
| 4628 | }, |
| 4629 | }, |
| 4630 | }), |
| 4631 | ), |
| 4632 | ); |
| 4633 | mockedExecute |
| 4634 | .mockReturnValueOnce(page(0, true)) |
| 4635 | .mockReturnValueOnce(page(1, true)) |
| 4636 | .mockReturnValueOnce(page(2, false)); |
| 4637 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4638 | |
| 4639 | const viewed = yield* cli.getPullRequestFilesViewed({ |
| 4640 | cwd: "/w", |
| 4641 | repository: "acme/web", |
| 4642 | host: "github.com", |
| 4643 | number: 7, |
| 4644 | }); |
| 4645 | |
| 4646 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4647 | // The first page asks from the start; each one after it carries the cursor before it. |
| 4648 | expect([varsAt(0)["after"], varsAt(1)["after"], varsAt(2)["after"]]).toEqual([ |
| 4649 | null, |
| 4650 | "cursor-0", |
| 4651 | "cursor-1", |
| 4652 | ]); |
| 4653 | assert.isFalse(viewed.truncated); |
| 4654 | expect(viewed.files.map((file) => [file.path, file.state])).toEqual([ |
| 4655 | ["src/file0.ts", "viewed"], |
| 4656 | ["src/other0.ts", "unviewed"], |
| 4657 | ["src/file1.ts", "viewed"], |
| 4658 | ["src/other1.ts", "unviewed"], |
| 4659 | ["src/file2.ts", "viewed"], |
| 4660 | ["src/other2.ts", "unviewed"], |
| 4661 | ]); |
| 4662 | }), |
| 4663 | ); |
| 4664 | |
| 4665 | it.effect("stops paging viewed files rather than following a change without end", () => |
| 4666 | Effect.gen(function* () { |
| 4667 | mockedExecute.mockReturnValue( |
| 4668 | Effect.succeed( |
| 4669 | output( |
| 4670 | encodeJson({ |
| 4671 | data: { |
| 4672 | repository: { |
| 4673 | pullRequest: { |
| 4674 | files: { |
| 4675 | pageInfo: { hasNextPage: true, endCursor: "cursor" }, |
| 4676 | nodes: [{ path: "src/file.ts", viewerViewedState: "VIEWED" }], |
| 4677 | }, |
| 4678 | }, |
| 4679 | }, |
| 4680 | }, |
| 4681 | }), |
| 4682 | ), |
| 4683 | ), |
| 4684 | ); |
| 4685 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4686 | |
| 4687 | const viewed = yield* cli.getPullRequestFilesViewed({ |
| 4688 | cwd: "/w", |
| 4689 | repository: "acme/web", |
| 4690 | host: "github.com", |
| 4691 | number: 7, |
| 4692 | }); |
| 4693 | |
| 4694 | // The same cursor twice is a page GitHub already gave, so reading stops there. |
| 4695 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4696 | assert.isTrue(viewed.truncated); |
| 4697 | }), |
| 4698 | ); |
| 4699 | |
| 4700 | it.effect("clears and restores a burst of files in one request", () => |
| 4701 | Effect.gen(function* () { |
| 4702 | mockedExecute |
| 4703 | .mockReturnValueOnce( |
| 4704 | Effect.succeed( |
| 4705 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_1" } } } })), |
| 4706 | ), |
| 4707 | ) |
| 4708 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| 4709 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4710 | |
| 4711 | yield* cli.setPullRequestFilesViewed({ |
| 4712 | cwd: "/w", |
| 4713 | repository: "acme/web", |
| 4714 | host: "github.com", |
| 4715 | number: 23, |
| 4716 | files: [ |
| 4717 | { path: "src/a.ts", viewed: true }, |
| 4718 | { path: "src/b.ts", viewed: false }, |
| 4719 | ], |
| 4720 | }); |
| 4721 | |
| 4722 | // One request to learn the pull request's node id, one for every press together. |
| 4723 | assert.strictEqual(mockedExecute.mock.calls.length, 2); |
| 4724 | expect(queryAt(1)).toContain("f0: markFileAsViewed"); |
| 4725 | expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); |
| 4726 | expect(varsAt(1)).toEqual({ |
| 4727 | pullRequestId: "PR_1", |
| 4728 | f0_path: "src/a.ts", |
| 4729 | f1_path: "src/b.ts", |
| 4730 | }); |
| 4731 | }), |
| 4732 | ); |
| 4733 | |
| 4734 | it.effect("asks the host nothing when nothing was pressed", () => |
| 4735 | Effect.gen(function* () { |
| 4736 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4737 | |
| 4738 | yield* cli.setPullRequestFilesViewed({ |
| 4739 | cwd: "/w", |
| 4740 | repository: "acme/web", |
| 4741 | host: "github.com", |
| 4742 | number: 7, |
| 4743 | files: [], |
| 4744 | }); |
| 4745 | |
| 4746 | assert.strictEqual(mockedExecute.mock.calls.length, 0); |
| 4747 | }), |
| 4748 | ); |
| 4749 | |
| 4750 | it.effect("looks a pull request's node id up once, however often it is written to", () => |
| 4751 | Effect.gen(function* () { |
| 4752 | mockedExecute |
| 4753 | .mockReturnValueOnce( |
| 4754 | Effect.succeed( |
| 4755 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_24" } } } })), |
| 4756 | ), |
| 4757 | ) |
| 4758 | .mockReturnValue(Effect.succeed(output("{}"))); |
| 4759 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4760 | const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; |
| 4761 | |
| 4762 | yield* cli.setPullRequestFilesViewed({ |
| 4763 | ...pullRequest, |
| 4764 | files: [{ path: "src/a.ts", viewed: true }], |
| 4765 | }); |
| 4766 | yield* cli.setPullRequestFilesViewed({ |
| 4767 | ...pullRequest, |
| 4768 | files: [{ path: "src/b.ts", viewed: true }], |
| 4769 | }); |
| 4770 | yield* cli.updatePullRequest({ ...pullRequest, title: "Ticked through" }); |
| 4771 | |
| 4772 | // One lookup, then a mutation per write, every one of them addressed by the id it answered. |
| 4773 | assert.strictEqual(mockedExecute.mock.calls.length, 4); |
| 4774 | expect(varsAt(0)).toEqual({ owner: "acme", name: "web", number: 24 }); |
| 4775 | expect([1, 2, 3].map((index) => varsAt(index)["pullRequestId"])).toEqual([ |
| 4776 | "PR_24", |
| 4777 | "PR_24", |
| 4778 | "PR_24", |
| 4779 | ]); |
| 4780 | }), |
| 4781 | ); |
| 4782 | |
| 4783 | it.effect("does not remember a node id lookup that failed", () => |
| 4784 | Effect.gen(function* () { |
| 4785 | mockedExecute |
| 4786 | .mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))) |
| 4787 | .mockReturnValueOnce( |
| 4788 | Effect.succeed( |
| 4789 | output(encodeJson({ data: { repository: { pullRequest: { id: "PR_25" } } } })), |
| 4790 | ), |
| 4791 | ) |
| 4792 | .mockReturnValueOnce(Effect.succeed(output("{}"))); |
| 4793 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4794 | const write = () => |
| 4795 | cli.setPullRequestFilesViewed({ |
| 4796 | cwd: "/w", |
| 4797 | repository: "acme/web", |
| 4798 | host: "github.com", |
| 4799 | number: 25, |
| 4800 | files: [{ path: "src/a.ts", viewed: true }], |
| 4801 | }); |
| 4802 | |
| 4803 | const error = yield* Effect.flip(write()); |
| 4804 | assert.strictEqual(error._tag, "GitHubPullRequestReadError"); |
| 4805 | |
| 4806 | yield* write(); |
| 4807 | |
| 4808 | assert.strictEqual(mockedExecute.mock.calls.length, 3); |
| 4809 | expect(varsAt(2)["pullRequestId"]).toEqual("PR_25"); |
| 4810 | }), |
| 4811 | ); |
| 4812 | it.effect("keeps the pull request being ticked through, not the one looked up first", () => |
| 4813 | Effect.gen(function* () { |
| 4814 | // Ordered by insertion alone, a hit does not renew its entry, so the review the reader is |
| 4815 | // working down is the first thing evicted once a listing has walked a cache's worth of cold |
| 4816 | // pull requests, and every press after that pays a round trip again. |
| 4817 | // This block shares one cache, so these numbers are its own and it runs last. |
| 4818 | const HOT = 9_000; |
| 4819 | const lookupsOf = new Map<number, number>(); |
| 4820 | mockedExecute.mockImplementation((input) => { |
| 4821 | if (input.kind !== "graphql" || !input.query.includes(NODE_ID_QUERY)) { |
| 4822 | return Effect.succeed(output("{}")); |
| 4823 | } |
| 4824 | const number = Number(input.variables?.["number"]); |
| 4825 | lookupsOf.set(number, (lookupsOf.get(number) ?? 0) + 1); |
| 4826 | return Effect.succeed( |
| 4827 | output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), |
| 4828 | ); |
| 4829 | }); |
| 4830 | const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; |
| 4831 | const tick = (number: number) => |
| 4832 | cli.setPullRequestFilesViewed({ |
| 4833 | cwd: "/w", |
| 4834 | repository: "acme/web", |
| 4835 | host: "github.com", |
| 4836 | number, |
| 4837 | files: [{ path: "src/a.ts", viewed: true }], |
| 4838 | }); |
| 4839 | |
| 4840 | yield* tick(HOT); |
| 4841 | // A cache's worth of cold pull requests, with the open one pressed in between each of them. |
| 4842 | for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { |
| 4843 | yield* tick(HOT + 1 + filled); |
| 4844 | yield* tick(HOT); |
| 4845 | } |
| 4846 | |
| 4847 | assert.strictEqual(lookupsOf.get(HOT), 1); |
| 4848 | }), |
| 4849 | ); |
| 4850 | }); |