1import * as Clock from "effect/Clock";
2import * as Cache from "effect/Cache";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts";
9
10import * as GitHubApi from "./GitHubApi.ts";
11import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts";
12import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts";
13import type { ProviderRepositoryRef } from "@t3tools/source-control-core/server/PullRequestProvider";
14
15const HeadSchema = Schema.Struct({
16 head: Schema.Struct({
17 sha: Schema.String.check(Schema.isPattern(/^[a-f0-9]{40,64}$/)),
18 // Null once the fork a pull request came from has been deleted.
19 repo: Schema.NullOr(Schema.Struct({ id: PositiveInt })),
20 }),
21 base: Schema.Struct({ repo: Schema.Struct({ id: PositiveInt }) }),
22});
23const decodeHead = Schema.decodeUnknownEffect(Schema.fromJsonString(HeadSchema));
24
25const WorkflowRunSchema = Schema.Struct({
26 id: Schema.Int,
27 name: Schema.optional(Schema.NullOr(Schema.String)),
28 html_url: Schema.optional(Schema.NullOr(Schema.String)),
29 status: Schema.optional(Schema.NullOr(Schema.String)),
30 conclusion: Schema.optional(Schema.NullOr(Schema.String)),
31 head_branch: Schema.optional(Schema.NullOr(Schema.String)),
32});
33const decodeWorkflowRuns = Schema.decodeUnknownOption(
34 Schema.fromJsonString(Schema.Struct({ workflow_runs: Schema.Array(WorkflowRunSchema) })),
35);
36
37export type KnownWorkflowRun = typeof WorkflowRunSchema.Type;
38
39/**
40 * Every `pull_request` workflow run of one head, as the revalidator just confirmed them against
41 * GitHub. A read running under it filters these rather than asking for the runs again.
42 */
43export const KnownWorkflowRuns = Context.Reference<{
44 readonly headSha: string;
45 readonly runs: ReadonlyArray<KnownWorkflowRun>;
46} | null>("@t3tools/source-control-github/server/gitHubConditionalChecks/KnownWorkflowRuns", {
47 defaultValue: () => null,
48});
49
50type Validator = { etag: string | undefined; next: boolean; body: string };
51
52/** A host that answers these with one of them has no conditional REST for it at all. */
53const UNSUPPORTED_STATUSES = new Set([404, 405, 501]);
54
55export const makeChecksRevalidator = Effect.gen(function* () {
56 const api = yield* GitHubApi.GitHubApi;
57 const entries = yield* Cache.makeWith(
58 (_key: string) =>
59 Effect.sync(() => ({
60 gate: Semaphore.makeUnsafe(1),
61 validators: new Map<string, Validator>(),
62 head: null as typeof HeadSchema.Type | null,
63 value: null as PullRequestChecks | null,
64 readAt: 0,
65 supported: true,
66 })),
67 { capacity: 128, timeToLive: () => "30 minutes" },
68 );
69 return (
70 input: ProviderRepositoryRef & { readonly number: number },
71 read: Effect.Effect<
72 Pick<GitHubPullRequestDetail, "state" | "checks" | "headSha"> & {
73 workflowApprovalsRequired?: number;
74 },
75 GitHubPullRequestApiError
76 >,
77 ) =>
78 Effect.gen(function* () {
79 const credential = yield* GitHubApi.PinnedGitHubCredential;
80 if (credential === null) return yield* read;
81 const key = `${credential.credentialFingerprint}\0${input.host}\0${input.repository}\0${input.number}`;
82 const entry = yield* Cache.get(entries, key);
83 return yield* entry.gate.withPermit(
84 Effect.gen(function* () {
85 if (!entry.supported) return yield* read;
86 const fail = (status: number) =>
87 new GitHubApi.GitHubApiResponseError({
88 host: input.host,
89 operation: "revalidateChecks",
90 status,
91 });
92 /** Whether the endpoint has a next page, or null where the host cannot say. */
93 const get = (endpoint: string, head = false) =>
94 Effect.gen(function* () {
95 const previous = entry.validators.get(endpoint);
96 const response = yield* api
97 .rest({
98 host: input.host,
99 operation: "revalidateChecks",
100 path: endpoint,
101 ...(previous?.etag ? { ifNoneMatch: previous.etag } : {}),
102 })
103 .pipe(
104 Effect.catchTags({
105 GitHubApiNotFoundError: () =>
106 Effect.sync(() => {
107 entry.supported = false;
108 return null;
109 }),
110 GitHubApiResponseError: (error) =>
111 Effect.sync(() => {
112 entry.supported = !UNSUPPORTED_STATUSES.has(error.status);
113 return null;
114 }),
115 }),
116 );
117 if (response === null) {
118 entry.value = null;
119 return null;
120 }
121 if (response.status === 304 && previous) return previous.next;
122 if (response.status !== 200 || response.truncated) {
123 return yield* fail(response.status);
124 }
125 entry.value = null;
126 if (head) {
127 const decoded = yield* decodeHead(response.body).pipe(
128 Effect.mapError(() => fail(response.status)),
129 );
130 if (entry.head?.head.sha !== decoded.head.sha) entry.validators.clear();
131 entry.head = decoded;
132 }
133 const validator = {
134 etag: response.headers["etag"]?.trim(),
135 next: /rel="next"/.test(response.headers["link"] ?? ""),
136 body: response.body,
137 };
138 entry.validators.set(endpoint, validator);
139 return validator.next;
140 });
141 const root = `repos/${input.repository}`;
142 if ((yield* get(`${root}/pulls/${input.number}`, true)) === null) return yield* read;
143 const head = entry.head;
144 if (head === null) return yield* fail(200);
145 const runsEndpoint =
146 head.head.repo?.id !== head.base.repo.id
147 ? `${root}/actions/runs?head_sha=${head.head.sha}&event=pull_request`
148 : null;
149 const endpoints = [
150 `${root}/commits/${head.head.sha}/check-runs?filter=all`,
151 `${root}/commits/${head.head.sha}/status`,
152 ...(runsEndpoint === null ? [] : [runsEndpoint]),
153 ];
154 const runPages: Array<string> = [];
155 for (const endpoint of endpoints) {
156 for (let page = 1; ; page++) {
157 if (page > 100) return yield* fail(200);
158 const paged = `${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100&page=${page}`;
159 const next = yield* get(paged);
160 if (next === null) return yield* read;
161 if (endpoint === runsEndpoint) runPages.push(paged);
162 if (!next) break;
163 }
164 }
165 const now = yield* Clock.currentTimeMillis;
166 if (entry.value !== null && now - entry.readAt < 5 * 60_000) return entry.value;
167 // Every page of the head's runs was just confirmed current, so the read filters them
168 // rather than listing the runs a second time.
169 const pages = runPages.map((endpoint) =>
170 decodeWorkflowRuns(entry.validators.get(endpoint)?.body ?? ""),
171 );
172 const known =
173 runsEndpoint !== null && pages.every(Option.isSome)
174 ? {
175 headSha: head.head.sha,
176 runs: pages.flatMap((page) =>
177 Option.isSome(page) ? page.value.workflow_runs : [],
178 ),
179 }
180 : null;
181 const fresh = yield* read.pipe(Effect.provideService(KnownWorkflowRuns, known));
182 const value = {
183 state: fresh.state,
184 checks: fresh.checks,
185 };
186 entry.value =
187 fresh.workflowApprovalsRequired !== undefined && fresh.headSha === head.head.sha
188 ? value
189 : null;
190 entry.readAt = now;
191 return value;
192 }),
193 );
194 });
195});