1import { RegistryContext, useAtomValue } from "@effect/atom-react";
2import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts";
4import * as Cause from "effect/Cause";
5import * as Option from "effect/Option";
6import { AsyncResult, Atom } from "effect/reactivity";
7import { use, useCallback, useEffect, useMemo, useRef, useState, useLayoutEffect } from "react";
8import { composerPullRequests } from "../../state/pull-requests";
9import {
10 appendPullRequestListPage,
11 mergeEnvironmentPullRequestPages,
12 nextPullRequestListInput,
13 pullRequestListHead,
14 pullRequestListKey,
15 scopedPullRequestListKey,
16 type EnvironmentPullRequestListEntry,
17 type PullRequestListPages,
18} from "./pullRequestList";
19
20// Retain each environment's pages when moving between tabs or changing list scope.
21const pagesFamily = Atom.family((_key: string) =>
22 Atom.make<PullRequestListPages | null>(null).pipe(Atom.setIdleTTL(5 * 60_000)),
23);
24const statsFamily = Atom.family((_environmentId: string) =>
25 Atom.make<ReadonlyMap<string, PullRequestDiffStat>>(new Map()).pipe(Atom.setIdleTTL(5 * 60_000)),
26);
27const statKey = (row: Pick<PullRequestDiffStat, "projectId" | "repository" | "number">) =>
28 `${row.projectId}:${row.repository}:${row.number}`;
29
30type ListTarget = { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };
31const sourcesFamily = Atom.family((key: string) => {
32 const targets: readonly ListTarget[] = JSON.parse(key);
33 return Atom.make((get) =>
34 targets.map((target) => {
35 const result = get(composerPullRequests.list(target));
36 const head = Option.getOrNull(AsyncResult.value(result));
37 const cached = get(pagesFamily(JSON.stringify(target)));
38 return {
39 target,
40 result,
41 pages: head ? pullRequestListHead(cached, head) : cached,
42 stats: get(statsFamily(target.environmentId)),
43 };
44 }),
45 );
46});
47
48export function usePullRequestList(targets: readonly ListTarget[]) {
49 const registry = use(RegistryContext);
50 const scopeKey = JSON.stringify(targets);
51 const activeScope = useRef(scopeKey);
52 const sources = useAtomValue(sourcesFamily(scopeKey));
53 const requests = useRef<{ page?: AbortController; refresh?: AbortController }>({});
54 const [loading, setLoading] = useState({
55 key: scopeKey,
56 page: false,
57 refresh: false,
58 error: null as string | null,
59 });
60 const state = loading.key === scopeKey ? loading : { page: false, refresh: false, error: null };
61 useLayoutEffect(() => {
62 activeScope.current = scopeKey;
63 return () => {
64 requests.current.page?.abort();
65 requests.current.refresh?.abort();
66 requests.current = {};
67 };
68 }, [scopeKey, activeScope]);
69 useEffect(() => {
70 for (const { target, pages } of sources)
71 if (pages) registry.set(pagesFamily(JSON.stringify(target)), pages);
72 }, [registry, sources]);
73
74 const loadMore = useCallback(
75 async (retry = false) => {
76 if (requests.current.page || requests.current.refresh || (!retry && state.error)) return;
77 const continuations = targets.flatMap((target) => {
78 const pagesAtom = pagesFamily(JSON.stringify(target));
79 const pages = registry.get(pagesAtom);
80 const input = pages && nextPullRequestListInput(pages, target.input);
81 return pages && input ? [{ target, pagesAtom, pages, input }] : [];
82 });
83 if (!continuations.length) return;
84 const controller = new AbortController();
85 requests.current.page = controller;
86 setLoading({ key: scopeKey, page: true, refresh: false, error: null });
87 const responses = await Promise.allSettled(
88 continuations.map(async ({ target, pagesAtom, pages, input }) => {
89 const response = await executeAtomQuery(
90 registry,
91 composerPullRequests.list({ ...target, input }),
92 {
93 signal: controller.signal,
94 refresh: retry,
95 reportFailure: false,
96 },
97 );
98 if (controller.signal.aborted || activeScope.current !== scopeKey) return;
99 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
100 if (response.value.errors.length)
101 throw new Error(response.value.errors.map((error) => error.message).join("\n"));
102 const next = appendPullRequestListPage(
103 registry.get(pagesAtom) ?? pages,
104 response.value,
105 input,
106 );
107 registry.set(pagesAtom, next);
108 if (
109 input.cursors &&
110 Object.keys(next.nextCursors).length &&
111 JSON.stringify(Object.entries(input.cursors).sort()) ===
112 JSON.stringify(Object.entries(next.nextCursors).sort())
113 )
114 throw new Error("Couldn't advance the list. Try again.");
115 }),
116 );
117 if (requests.current.page === controller) delete requests.current.page;
118 if (!controller.signal.aborted && activeScope.current === scopeKey)
119 setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) });
120 },
121 [targets, registry, scopeKey, state.error, activeScope],
122 );
123
124 const onRefresh = async () => {
125 if (!targets.length || requests.current.refresh) return;
126 requests.current.page?.abort();
127 delete requests.current.page;
128 const controller = new AbortController();
129 requests.current.refresh = controller;
130 setLoading({ key: scopeKey, page: false, refresh: true, error: null });
131 const responses = await Promise.allSettled(
132 targets.map(async (target) => {
133 await composerPullRequests.invalidate.run(registry, {
134 environmentId: target.environmentId,
135 input: {},
136 });
137 if (controller.signal.aborted) return;
138 const response = await executeAtomQuery(registry, composerPullRequests.list(target), {
139 refresh: true,
140 signal: controller.signal,
141 reportFailure: false,
142 });
143 if (controller.signal.aborted || activeScope.current !== scopeKey) return;
144 if (response._tag === "Failure") throw squashAtomCommandFailure(response);
145 registry.set(
146 pagesFamily(JSON.stringify(target)),
147 pullRequestListHead(null, response.value),
148 );
149 }),
150 );
151 if (requests.current.refresh === controller) delete requests.current.refresh;
152 if (!controller.signal.aborted && activeScope.current === scopeKey)
153 setLoading({ key: scopeKey, page: false, refresh: false, error: requestErrors(responses) });
154 };
155
156 const data = useMemo(
157 () =>
158 mergeEnvironmentPullRequestPages(
159 sources.flatMap(({ target, pages, stats }) =>
160 pages
161 ? [
162 {
163 environmentId: target.environmentId,
164 pages: {
165 ...pages,
166 entries: pages.entries.map((entry) => {
167 const stat = stats.get(statKey(entry));
168 return stat
169 ? { ...entry, additions: stat.additions, deletions: stat.deletions }
170 : entry;
171 }),
172 },
173 },
174 ]
175 : [],
176 ),
177 ),
178 [sources],
179 );
180 const [visible, setVisible] = useState<{
181 key: string;
182 rows: readonly EnvironmentPullRequestListEntry[];
183 }>({ key: scopeKey, rows: [] });
184 const onViewableItemsChanged = useCallback(
185 ({
186 viewableItems,
187 }: {
188 viewableItems: readonly { item: EnvironmentPullRequestListEntry; isViewable: boolean }[];
189 }) => {
190 const rows = viewableItems.filter((token) => token.isViewable).map((token) => token.item);
191 setVisible((previous) =>
192 previous.key === scopeKey &&
193 previous.rows.map(scopedPullRequestListKey).join() ===
194 rows.map(scopedPullRequestListKey).join()
195 ? previous
196 : { key: scopeKey, rows },
197 );
198 },
199 [scopeKey],
200 );
201 const visibleRows =
202 visible.key === scopeKey && visible.rows.length ? visible.rows : data.entries.slice(0, 8);
203 const statsKey = JSON.stringify(
204 targets.flatMap(({ environmentId }) => {
205 const refs = visibleRows
206 .filter(
207 (row) =>
208 row.environmentId === environmentId &&
209 row.provider === "github" &&
210 row.additions + row.deletions === 0,
211 )
212 .map(({ projectId, host, repository, number }) => ({
213 projectId,
214 host,
215 repository,
216 number,
217 }));
218 return refs.length ? [{ environmentId, input: { refs } }] : [];
219 }),
220 );
221 const statsAtom = useMemo(() => {
222 const queries: readonly Parameters<typeof composerPullRequests.listStats>[0][] =
223 JSON.parse(statsKey);
224 return Atom.make((get) =>
225 queries.map((target) => ({
226 environmentId: target.environmentId,
227 result: get(composerPullRequests.listStats(target)),
228 })),
229 );
230 }, [statsKey]);
231 const counts = useAtomValue(statsAtom);
232 useEffect(() => {
233 for (const { environmentId, result } of counts) {
234 if (result._tag !== "Success" || result.waiting) continue;
235 registry.update(statsFamily(environmentId), (previous) => {
236 const next = new Map(previous);
237 for (const row of result.value.stats) next.set(statKey(row), row);
238 return next;
239 });
240 }
241 }, [registry, counts]);
242
243 const updateEntry = useCallback(
244 (entry: EnvironmentPullRequestListEntry, patch: Partial<EnvironmentPullRequestListEntry>) => {
245 const target = targets.find((target) => target.environmentId === entry.environmentId);
246 if (!target) return;
247 registry.update(pagesFamily(JSON.stringify(target)), (previous) =>
248 previous
249 ? {
250 ...previous,
251 entries: previous.entries.map((row) =>
252 pullRequestListKey(row) === pullRequestListKey(entry)
253 ? { ...row, ...patch, observedAt: Date.now() }
254 : row,
255 ),
256 }
257 : previous,
258 );
259 registry.refresh(composerPullRequests.list(target));
260 },
261 [registry, targets],
262 );
263 const errors = useMemo(
264 () =>
265 sources.flatMap(({ target, result }) => {
266 if (result._tag !== "Failure") return [];
267 const cause = Cause.squash(result.cause);
268 return [
269 {
270 environmentId: target.environmentId,
271 projectTitle: "",
272 message: cause instanceof Error ? cause.message : "Couldn't load pull requests.",
273 },
274 ];
275 }),
276 [sources],
277 );
278 const listing = useMemo(() => ({ ...data, errors: [...data.errors, ...errors] }), [data, errors]);
279 return {
280 data: listing,
281 entries: data.entries,
282 error: errors.map((error) => error.message).join("\n") || null,
283 isPending: sources.some(({ result }) => result.waiting) && !data.entries.length,
284 continued: data.continued,
285 loadMore,
286 onRefresh,
287 onViewableItemsChanged,
288 updateEntry,
289 refreshing: state.refresh,
290 loadingMore: state.page,
291 loadMoreError: state.error,
292 hasMore: sources.some(
293 ({ pages, target }) => pages && nextPullRequestListInput(pages, target.input),
294 ),
295 };
296}
297
298function requestErrors(results: readonly PromiseSettledResult<unknown>[]) {
299 return (
300 results
301 .flatMap((result) =>
302 result.status === "rejected"
303 ? [
304 result.reason instanceof Error
305 ? result.reason.message
306 : "Couldn't load pull requests.",
307 ]
308 : [],
309 )
310 .join("\n") || null
311 );
312}