1import { expect, it } from "@effect/vitest";
2import * as TestClock from "effect/testing/TestClock";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Schema from "effect/Schema";
6import * as Redacted from "effect/Redacted";
7import type { PullRequestCheck } from "@t3tools/contracts";
8
9import * as GitHubApi from "./GitHubApi.ts";
10import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts";
11
12const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
13
14const reference = { cwd: "/repo", repository: "acme/web", host: "github.com", number: 1 };
15const credential = {
16 host: "github.com",
17 token: Redacted.make("token"),
18 credentialFingerprint: "one",
19};
20
21it.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
125it.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
215it.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);