| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as TestClock from "effect/testing/TestClock"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import type { PullRequestCheck } from "@t3tools/contracts"; |
| 8 | |
| 9 | import * as GitHubApi from "./GitHubApi.ts"; |
| 10 | import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; |
| 11 | |
| 12 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 13 | |
| 14 | const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 }; |
| 15 | const credential = { |
| 16 | host: "github.com", |
| 17 | token: Redacted.make("token"), |
| 18 | credentialFingerprint: "one", |
| 19 | }; |
| 20 | |
| 21 | it.effect( |
| 22 | "reuses unchanged checks and catches reruns, later pages, fork approvals, pushes and account changes", |
| 23 | () => |
| 24 | Effect.gen(function* () { |
| 25 | let sha = "a".repeat(40); |
| 26 | let changed = ""; |
| 27 | let reads = 0; |
| 28 | const requests: string[] = []; |
| 29 | const known: Array<unknown> = []; |
| 30 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 31 | Effect.provide( |
| 32 | Layer.mock(GitHubApi.GitHubApi)({ |
| 33 | rest: ({ path, ifNoneMatch }) => |
| 34 | Effect.sync(() => { |
| 35 | requests.push(path); |
| 36 | const head = path.endsWith("/pulls/1"); |
| 37 | const modified = |
| 38 | ifNoneMatch === undefined || (path.includes(changed) && changed !== ""); |
| 39 | const next = path.includes("check-runs") && path.endsWith("page=1"); |
| 40 | const runs = path.includes("/actions/runs"); |
| 41 | return { |
| 42 | status: modified ? 200 : 304, |
| 43 | headers: modified |
| 44 | ? { |
| 45 | etag: `"${sha}-${changed}"`, |
| 46 | ...(next ? { link: '<https://api.github.com/next>; rel="next"' } : {}), |
| 47 | } |
| 48 | : {}, |
| 49 | body: !modified |
| 50 | ? "" |
| 51 | : head |
| 52 | ? encodeJson({ head: { sha, repo: { id: 2 } }, base: { repo: { id: 1 } } }) |
| 53 | : runs |
| 54 | ? encodeJson({ |
| 55 | workflow_runs: [ |
| 56 | { id: 9, status: "completed", conclusion: "action_required" }, |
| 57 | ], |
| 58 | }) |
| 59 | : "{}", |
| 60 | truncated: false, |
| 61 | invalidUtf8: false, |
| 62 | }; |
| 63 | }), |
| 64 | }), |
| 65 | ), |
| 66 | ); |
| 67 | const read = Effect.gen(function* () { |
| 68 | known.push(yield* KnownWorkflowRuns); |
| 69 | reads++; |
| 70 | return { |
| 71 | state: "open" as const, |
| 72 | checks: [ |
| 73 | { name: "build", status: "success", description: null, url: null }, |
| 74 | ] satisfies PullRequestCheck[], |
| 75 | headSha: sha, |
| 76 | workflowApprovalsRequired: 0, |
| 77 | }; |
| 78 | }); |
| 79 | const poll = (identity = credential) => |
| 80 | revalidate(reference, read).pipe( |
| 81 | Effect.provideService(GitHubApi.PinnedGitHubCredential, identity), |
| 82 | ); |
| 83 | yield* poll(); |
| 84 | expect(reads).toBe(1); |
| 85 | // The fork's runs were just confirmed, so the read is handed them instead of listing them. |
| 86 | expect(known[0]).toEqual({ |
| 87 | headSha: sha, |
| 88 | runs: [{ id: 9, status: "completed", conclusion: "action_required" }], |
| 89 | }); |
| 90 | requests.length = 0; |
| 91 | yield* poll(); |
| 92 | expect(reads).toBe(1); |
| 93 | expect(requests).toHaveLength(5); |
| 94 | for (const endpoint of [ |
| 95 | "check-runs?filter=all&per_page=100&page=2", |
| 96 | "/status", |
| 97 | "/actions/runs", |
| 98 | "/pulls/1", |
| 99 | ]) { |
| 100 | changed = endpoint; |
| 101 | yield* poll(); |
| 102 | changed = ""; |
| 103 | yield* poll(); |
| 104 | } |
| 105 | expect(reads).toBe(5); |
| 106 | sha = "b".repeat(40); |
| 107 | changed = "/pulls/1"; |
| 108 | requests.length = 0; |
| 109 | yield* poll(); |
| 110 | expect(reads).toBe(6); |
| 111 | expect( |
| 112 | requests |
| 113 | .filter((endpoint) => endpoint.includes("/commits/")) |
| 114 | .every((endpoint) => endpoint.includes(sha)), |
| 115 | ).toBe(true); |
| 116 | changed = ""; |
| 117 | yield* poll({ ...credential, credentialFingerprint: "two" }); |
| 118 | expect(reads).toBe(7); |
| 119 | yield* TestClock.adjust("5 minutes"); |
| 120 | yield* poll(); |
| 121 | expect(reads).toBe(8); |
| 122 | }), |
| 123 | ); |
| 124 | |
| 125 | it.effect("does not retain failed or incomplete reads, and supports hosts without ETags", () => |
| 126 | Effect.gen(function* () { |
| 127 | const sha = "a".repeat(40); |
| 128 | let etags = true; |
| 129 | let unavailable = false; |
| 130 | let fail = true; |
| 131 | let complete = true; |
| 132 | let reads = 0; |
| 133 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 134 | Effect.provide( |
| 135 | Layer.mock(GitHubApi.GitHubApi)({ |
| 136 | rest: ({ path, ifNoneMatch }) => |
| 137 | unavailable |
| 138 | ? Effect.fail( |
| 139 | new GitHubApi.GitHubApiResponseError({ |
| 140 | host: "github.com", |
| 141 | operation: "revalidateChecks", |
| 142 | status: 502, |
| 143 | }), |
| 144 | ) |
| 145 | : Effect.succeed( |
| 146 | ifNoneMatch !== undefined && etags |
| 147 | ? { status: 304, headers: {}, body: "", truncated: false, invalidUtf8: false } |
| 148 | : { |
| 149 | status: 200, |
| 150 | headers: etags ? { etag: '"one"' } : {}, |
| 151 | body: path.endsWith("/pulls/1") |
| 152 | ? encodeJson({ |
| 153 | head: { sha, repo: { id: 1 } }, |
| 154 | base: { repo: { id: 1 } }, |
| 155 | }) |
| 156 | : "{}", |
| 157 | truncated: false, |
| 158 | invalidUtf8: false, |
| 159 | }, |
| 160 | ), |
| 161 | }), |
| 162 | ), |
| 163 | ); |
| 164 | const read = Effect.suspend(() => { |
| 165 | reads++; |
| 166 | return fail |
| 167 | ? Effect.fail( |
| 168 | new GitHubApi.GitHubApiResponseError({ |
| 169 | host: "github.com", |
| 170 | operation: "read", |
| 171 | status: 500, |
| 172 | }), |
| 173 | ) |
| 174 | : Effect.succeed({ |
| 175 | state: "open" as const, |
| 176 | checks: [], |
| 177 | headSha: sha, |
| 178 | ...(complete ? { workflowApprovalsRequired: 0 } : {}), |
| 179 | }); |
| 180 | }); |
| 181 | const poll = () => |
| 182 | revalidate(reference, read).pipe( |
| 183 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), |
| 184 | ); |
| 185 | yield* poll().pipe(Effect.flip); |
| 186 | fail = false; |
| 187 | complete = false; |
| 188 | yield* poll(); |
| 189 | yield* poll(); |
| 190 | expect(reads).toBe(3); |
| 191 | complete = true; |
| 192 | yield* poll(); |
| 193 | yield* poll(); |
| 194 | expect(reads).toBe(4); |
| 195 | yield* TestClock.adjust("5 minutes"); |
| 196 | complete = false; |
| 197 | yield* poll(); |
| 198 | yield* poll(); |
| 199 | expect(reads).toBe(6); |
| 200 | complete = true; |
| 201 | unavailable = true; |
| 202 | yield* poll(); |
| 203 | expect(reads).toBe(7); |
| 204 | unavailable = false; |
| 205 | yield* poll(); |
| 206 | yield* poll(); |
| 207 | expect(reads).toBe(8); |
| 208 | etags = false; |
| 209 | yield* poll(); |
| 210 | yield* poll(); |
| 211 | expect(reads).toBe(10); |
| 212 | }), |
| 213 | ); |
| 214 | |
| 215 | it.effect("falls back when REST checks are unavailable without retrying unsupported probes", () => |
| 216 | Effect.gen(function* () { |
| 217 | let probes = 0; |
| 218 | let reads = 0; |
| 219 | const revalidate = yield* makeChecksRevalidator.pipe( |
| 220 | Effect.provide( |
| 221 | Layer.mock(GitHubApi.GitHubApi)({ |
| 222 | rest: () => { |
| 223 | probes++; |
| 224 | return Effect.fail( |
| 225 | new GitHubApi.GitHubApiNotFoundError({ |
| 226 | host: "github.com", |
| 227 | operation: "revalidateChecks", |
| 228 | }), |
| 229 | ); |
| 230 | }, |
| 231 | }), |
| 232 | ), |
| 233 | ); |
| 234 | const read = Effect.sync(() => { |
| 235 | reads++; |
| 236 | return { state: "open" as const, checks: [] }; |
| 237 | }); |
| 238 | for (let tick = 0; tick < 2; tick++) |
| 239 | yield* revalidate(reference, read).pipe( |
| 240 | Effect.provideService(GitHubApi.PinnedGitHubCredential, credential), |
| 241 | ); |
| 242 | expect(probes).toBe(1); |
| 243 | expect(reads).toBe(2); |
| 244 | }), |
| 245 | ); |