Skip to content
Source
Sign inSign up

pullRequests.ts

709 lines26.3 KB

1import { useAtomValue } from "@effect/atom-react";
2import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
3import {
4 createLinkedPullRequestSummaryAtomFamily,
5 createPullRequestEnvironmentAtoms,
6 createPullRequestStackAtomFamily,
7} from "@t3tools/client-runtime/state/pull-requests";
8import {
9 PullRequestListResult,
10 PullRequestOperationError,
11 PullRequestUnavailableError,
12 type EnvironmentId,
13 type ProjectId,
14 type PullRequestChecksState,
15 type PullRequestListInput,
16 type PullRequestFilterOptionsInput,
17 type PullRequestListStatsInput,
18 type PullRequestListEntry,
19 type PullRequestRef,
20 type PullRequestSummary,
21 type ThreadPullRequestLink,
22} from "@t3tools/contracts";
23import {
24 threadPullRequestKeyOf,
25 visibleThreadPullRequests,
26} from "@t3tools/client-runtime/thread-pull-requests";
27import * as Cause from "effect/Cause";
28import * as Clock from "effect/Clock";
29import * as Effect from "effect/Effect";
30import * as Option from "effect/Option";
31import * as Schema from "effect/Schema";
32import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";
33​
34import { connectionAtomRuntime } from "../connection/runtime";
35import { appAtomRegistry } from "../rpc/atomRegistry";
36import {
37 mergePullRequestLists,
38 type EnvironmentPullRequestStat,
39 type MergedPullRequestList,
40} from "../components/pullRequest/pullRequestList.logic";
41import { writePullRequestDetailSnapshot } from "../components/pullRequest/pullRequestDetail.logic";
42import { pullRequestChecksState } from "../components/pullRequest/pullRequestPresentation";
43import { useGuideRunStore } from "../components/pullRequest/guide/guideRunStore";
44import { formatEnvironmentQueryError } from "./query";
45import { environmentThreadShells } from "./threads";
46​
47export interface ObservedPullRequestSummary {
48 readonly summary: PullRequestSummary;
49 /** Client arrival time, the only ordering older servers leave us for same-dated snapshots. */
50 readonly observedAt: number;
52​
53function compareSummaries(current: PullRequestSummary, incoming: PullRequestSummary): number {
54 const merged = Number(incoming.state === "merged") - Number(current.state === "merged");
55 if (merged !== 0) return merged;
56 const updated = Date.parse(incoming.updatedAt) - Date.parse(current.updatedAt);
57 if (updated !== 0) return updated;
58 if (current.observedAt === undefined && incoming.observedAt === undefined) return 0;
59 return (incoming.observedAt ?? -Infinity) - (current.observedAt ?? -Infinity);
61​
62export function newestPullRequestSummary(
63 current: PullRequestSummary | null,
64 observed: PullRequestSummary | null,
65): PullRequestSummary | null {
66 if (current === null) return observed;
67 if (observed === null) return current;
68 return compareSummaries(current, observed) >= 0 ? observed : current;
70​
71export function pullRequestListEntryToSummary(entry: PullRequestListEntry): PullRequestSummary {
72 return {
73 provider: entry.provider,
74 projectId: entry.projectId,
75 repository: entry.repository,
76 number: entry.number,
77 title: entry.title,
78 url: entry.url,
79 state: entry.state,
80 isDraft: entry.isDraft,
81 headBranch: entry.headBranch,
82 baseBranch: entry.baseBranch,
83 updatedAt: entry.updatedAt,
84 ...(entry.observedAt === undefined ? {} : { observedAt: entry.observedAt }),
85 author: entry.author,
86 ...(entry.reviewDecision === undefined ? {} : { reviewDecision: entry.reviewDecision }),
87 ...(entry.checksState === undefined ? {} : { checksState: entry.checksState }),
88 mergeability: entry.mergeability,
89 };
91​
92// Project ids pin the remote host; omit host so legacy, URL, and list references share entries.
93function pullRequestSummaryKey(environmentId: EnvironmentId, reference: PullRequestRef): string {
94 return JSON.stringify([
95 environmentId,
96 reference.projectId,
97 reference.repository.toLowerCase(),
98 reference.number,
99 ]);
101​
102export function newestPullRequestObservation(
103 current: ObservedPullRequestSummary | null,
104 incoming: ObservedPullRequestSummary | null,
105): ObservedPullRequestSummary | null {
106 if (current === null) return incoming;
107 if (incoming === null) return current;
108 let order = compareSummaries(current.summary, incoming.summary);
109 if (
110 order === 0 &&
111 current.summary.observedAt === undefined &&
112 incoming.summary.observedAt === undefined
113 ) {
114 order = incoming.observedAt - current.observedAt;
115 }
116 if (!(order > 0)) return current;
117 // A sparse summary must not erase known status, or carry old detail stats into a new list read.
118 return {
119 ...incoming,
120 summary: {
121 ...incoming.summary,
122 isDraft: incoming.summary.isDraft ?? current.summary.isDraft,
123 mergeability: incoming.summary.mergeability ?? current.summary.mergeability,
124 reviewDecision:
125 incoming.summary.reviewDecision === undefined
126 ? current.summary.reviewDecision
127 : incoming.summary.reviewDecision,
128 checksState:
129 incoming.summary.checksState === undefined
130 ? current.summary.checksState
131 : incoming.summary.checksState,
132 },
133 };
135​
136export interface ObservedPullRequestChecks {
137 readonly checksState: PullRequestChecksState | null;
138 /** Server read time, when the server reports one. */
139 readonly readAt: number | undefined;
140 /** Client arrival time. */
141 readonly observedAt: number;
143​
144/**
145 * Checks are reread far more often than the rest of a pull request, so they keep their own
146 * freshness: a checks read must not carry old metadata forward, and a list read must not take
147 * back newer checks.
148 */
149export interface PullRequestObservations {
150 readonly metadata: ObservedPullRequestSummary | null;
151 readonly checks: ObservedPullRequestChecks | null;
153​
154const noObservations: PullRequestObservations = { metadata: null, checks: null };
155​
156const pullRequestObservationsAtom = Atom.family((key: string) =>
157 Atom.make<PullRequestObservations>(noObservations).pipe(
158 Atom.setIdleTTL(5 * 60_000),
159 Atom.withLabel(`web-pull-requests:observations:${key}`),
160 ),
161);
162​
163export function observePullRequestChecks(
164 current: PullRequestObservations,
165 incoming: ObservedPullRequestChecks,
166): PullRequestObservations {
167 const previous = current.checks;
168 const newer =
169 previous === null ||
170 (previous.readAt !== undefined && incoming.readAt !== undefined
171 ? incoming.readAt >= previous.readAt
172 : incoming.observedAt >= previous.observedAt);
173 return newer ? { ...current, checks: incoming } : current;
175​
176export function observePullRequestMetadata(
177 current: PullRequestObservations,
178 incoming: ObservedPullRequestSummary,
179): PullRequestObservations {
180 const metadata = newestPullRequestObservation(current.metadata, incoming);
181 const next = metadata === current.metadata ? current : { ...current, metadata };
182 const { checksState } = incoming.summary;
183 return checksState === undefined
184 ? next
185 : observePullRequestChecks(next, {
186 checksState,
187 readAt: incoming.summary.observedAt,
188 observedAt: incoming.observedAt,
189 });
191​
192export function observedPullRequestSummary(
193 observations: PullRequestObservations,
194): PullRequestSummary | null {
195 const { metadata, checks } = observations;
196 if (metadata === null) return null;
197 return checks === null
198 ? metadata.summary
199 : { ...metadata.summary, checksState: checks.checksState };
201​
202function recordObservation(
203 registry: AtomRegistry.AtomRegistry,
204 key: string,
205 observe: (current: PullRequestObservations) => PullRequestObservations,
206): void {
207 registry.modify(pullRequestObservationsAtom(key), (previous) => {
208 const next = observe(previous);
209 return next === previous ? [false, previous] : [true, next];
210 });
212​
213function observePullRequestSummary(
214 registry: AtomRegistry.AtomRegistry,
215 key: string,
216 summary: PullRequestSummary,
217 observedAt: number,
218): void {
219 recordObservation(registry, key, (current) =>
220 observePullRequestMetadata(current, { summary, observedAt }),
221 );
223​
224// Reading localStorage throws where the browser blocks storage; that must not break the page.
225function snapshotStorage(): Storage | undefined {
226 if (typeof window === "undefined") return undefined;
227 try {
228 return window.localStorage;
229 } catch {
230 return undefined;
231 }
233​
234type ListAnswerStorage = Pick<Storage, "getItem" | "setItem" | "removeItem" | "key" | "length">;
235​
236const LIST_SCOPE_INDEX_KEY = "t3.pullRequests.listScopes";
237const LIST_SCOPE_KEY_PREFIX = "t3.pullRequests.listScope:";
238const LEGACY_SNAPSHOT_KEY_PREFIX = "t3.pullRequests.list:";
239const LIST_ANSWER_MAX_ENTRIES = 99;
240/**
241 * A scope holds a page of rows per environment, so only the scopes visited most recently are
242 * kept. Eviction drops a whole scope, never one environment's answer, so a restored list is never
243 * missing environments that did answer.
244 */
245const LIST_SCOPE_MAX_KEPT = 8;
246const listScopeCodec = Schema.fromJsonString(
247 Schema.Record(Schema.String, Schema.toCodecJson(PullRequestListResult)),
248);
249const decodeListScope = Schema.decodeUnknownOption(listScopeCodec);
250const encodeListScope = Schema.encodeOption(listScopeCodec);
251const decodeListScopeIndex = Schema.decodeUnknownOption(
252 Schema.fromJsonString(Schema.Array(Schema.String)),
253);
254const sweptStorages = new WeakSet<ListAnswerStorage>();
255​
256/**
257 * The project assignment is left out: a cold start asks before the project shells load, when
258 * every environment is asked for all of its projects, while the answers were remembered after
259 * overlapping projects were split between environments. Each environment's answer covers only
260 * its assigned projects, so the scope's answers together still cover the whole scope.
261 */
262const listScopeKey = ({ projectIds: _projectIds, ...scope }: PullRequestListInput) =>
263 `${LIST_SCOPE_KEY_PREFIX}${JSON.stringify(scope)}`;
264​
265function readListScope(storage: ListAnswerStorage | undefined, key: string) {
266 let raw: string | null = null;
267 try {
268 raw = storage?.getItem(key) ?? null;
269 } catch {}
270 return Option.getOrElse(decodeListScope(raw), (): Record<string, PullRequestListResult> => ({}));
272​
273/** The single composite snapshot this replaced is never read again. */
274function sweepLegacySnapshots(storage: ListAnswerStorage): void {
275 if (sweptStorages.has(storage)) return;
276 sweptStorages.add(storage);
277 const legacy: string[] = [];
278 for (let index = 0; index < storage.length; index++) {
279 const key = storage.key(index);
280 if (key?.startsWith(LEGACY_SNAPSHOT_KEY_PREFIX)) legacy.push(key);
281 }
282 for (const key of legacy) storage.removeItem(key);
284​
285/**
286 * Keeps what a cold start can show before the host answers again. Searches, continuation pages
287 * and grown pages are never shown that way, so only first-page reads are kept. Failures and
288 * cursors are dropped: they describe the moment of the read, and a restored list must not
289 * continue from it.
290 */
291export function rememberPullRequestListAnswer(
292 storage: ListAnswerStorage | undefined,
293 target: EnvironmentQueryTarget<PullRequestListInput>,
294 answer: PullRequestListResult,
295): void {
296 const { input } = target;
297 if (storage === undefined || input.query !== undefined || input.cursors !== undefined) return;
298 if (input.limit !== undefined && input.limit > LIST_ANSWER_MAX_ENTRIES) return;
299 const key = listScopeKey(input);
300 try {
301 sweepLegacySnapshots(storage);
302 const scope = readListScope(storage, key);
303 // An answer for some projects replaces only their rows. A repository this environment no
304 // longer owns stays covered until the environment that took it over answers; the next answer
305 // for all of the environment's projects, which every cold start asks for, drops them.
306 const assigned = input.projectIds === undefined ? undefined : new Set(input.projectIds);
307 const retained =
308 assigned === undefined
309 ? []
310 : (scope[target.environmentId]?.entries ?? []).filter(
311 (entry) => !assigned.has(entry.projectId),
312 );
313 const encoded = encodeListScope({
314 ...scope,
315 [target.environmentId]: {
316 ...answer,
317 // The fresh rows come first, so retained rows only fill what is left of the page.
318 entries: [...answer.entries, ...retained].slice(0, LIST_ANSWER_MAX_ENTRIES),
319 errors: [],
320 nextCursors: {},
321 },
322 });
323 if (Option.isNone(encoded)) return;
324 const index = Option.getOrElse(
325 decodeListScopeIndex(storage.getItem(LIST_SCOPE_INDEX_KEY)),
326 () => [],
327 );
328 const kept = [key, ...index.filter((held) => held !== key)];
329 for (const evicted of kept.slice(LIST_SCOPE_MAX_KEPT)) storage.removeItem(evicted);
330 storage.setItem(key, encoded.value);
331 storage.setItem(LIST_SCOPE_INDEX_KEY, JSON.stringify(kept.slice(0, LIST_SCOPE_MAX_KEPT)));
332 } catch {}
334​
335/**
336 * The kept answers for these reads, merged the way live answers are. An environment without a
337 * kept answer is left out rather than failing the restore: one whose projects were all assigned
338 * to another environment was never asked, and its pull requests are in that environment's answer.
339 */
340export function restorePullRequestList(
341 storage: ListAnswerStorage | undefined,
342 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
343): MergedPullRequestList | null {
344 const scopes = new Map<string, Record<string, PullRequestListResult>>();
345 const answers: Array<readonly [EnvironmentId, PullRequestListResult]> = [];
346 for (const target of targets) {
347 const key = listScopeKey(target.input);
348 const scope = scopes.get(key) ?? readListScope(storage, key);
349 scopes.set(key, scope);
350 const answer = scope[target.environmentId];
351 if (answer !== undefined) answers.push([target.environmentId, answer]);
352 }
353 const merged = mergePullRequestLists(answers);
354 if (merged === null) return null;
355 // Answers kept before the projects were reassigned between environments can overlap.
356 const seen = new Set<string>();
357 const entries = merged.entries.filter((entry) => {
358 const key = `${entry.host}:${entry.repository}#${entry.number}`;
359 if (seen.has(key)) return false;
360 seen.add(key);
361 return true;
362 });
363 return { ...merged, entries };
365​
366export function restoredPullRequestList(
367 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
368): MergedPullRequestList | null {
369 return restorePullRequestList(snapshotStorage(), targets);
371​
372// Answers are stamped on arrival: older servers leave no other ordering for same-dated reads.
373export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime, {
374 list: ({ environmentId, input }, answer, registry) =>
375 Effect.map(Clock.currentTimeMillis, (observedAt) => {
376 rememberPullRequestListAnswer(snapshotStorage(), { environmentId, input }, answer);
377 for (const entry of answer.entries) {
378 observePullRequestSummary(
379 registry,
380 pullRequestSummaryKey(environmentId, entry),
381 pullRequestListEntryToSummary(entry),
382 observedAt,
383 );
384 }
385 }),
386 detail: ({ environmentId, input }, detail, registry) =>
387 Effect.map(Clock.currentTimeMillis, (at) => {
388 // Only fresh reads seed the cold-start snapshot; a stale one could replace a newer record.
389 if (input.allowStale === false) {
390 writePullRequestDetailSnapshot(snapshotStorage(), environmentId, input, detail);
391 }
392 observePullRequestSummary(
393 registry,
394 pullRequestSummaryKey(environmentId, input),
395 { ...detail, checksState: pullRequestChecksState(detail.checks) },
396 at,
397 );
398 }),
399 checks: ({ environmentId, input }, checks, registry) =>
400 Effect.map(Clock.currentTimeMillis, (observedAt) => {
401 if (checks === null) return;
402 recordObservation(registry, pullRequestSummaryKey(environmentId, input), (current) =>
403 observePullRequestChecks(current, {
404 checksState: pullRequestChecksState(checks.checks),
405 readAt: checks.observedAt,
406 observedAt,
407 }),
408 );
409 }),
410 // Covers runs started elsewhere (another client, an agent) as well as this client's own.
411 reviewGuide: ({ environmentId, input }, guide) =>
412 Effect.map(Clock.currentTimeMillis, (since) => {
413 if (guide.run?._tag !== "Running") return;
414 useGuideRunStore.getState().watch({
415 environmentId,
416 reference: input,
417 title: null,
418 previousGuideAt: guide.guide?.generatedAt ?? null,
419 previousFailedAt: null,
420 since,
421 answeredRunning: true,
422 });
423 }),
424});
425​
426export const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(
427 connectionAtomRuntime,
428 pullRequestEnvironment.refreshes,
429 ({ environmentId, input }, summary, registry) =>
430 Effect.map(Clock.currentTimeMillis, (observedAt) =>
431 observePullRequestSummary(
432 registry,
433 pullRequestSummaryKey(environmentId, input),
434 summary,
435 observedAt,
436 ),
437 ),
438);
439​
440export function threadPullRequestLinkToSummary(
441 link: ThreadPullRequestLink,
442 projectId: ProjectId,
443): PullRequestSummary | null {
444 const updatedAt = link.snapshot?.updatedAt;
445 if (link.snapshot === null || updatedAt == null) return null;
446 return {
447 ...link.snapshot,
448 provider: (
449 sourceControlClients.findByChangeRequestUrl(link.url) ?? sourceControlClients.get(undefined)
450 ).kind,
451 projectId,
452 repository: link.repository,
453 number: link.number,
454 url: link.url,
455 updatedAt,
456 };
458​
459function savedPullRequestKey(
460 environmentId: EnvironmentId,
461 reference: Pick<PullRequestRef, "host" | "repository" | "number">,
462): string {
463 return reference.host === undefined
464 ? ""
465 : `${environmentId}:${threadPullRequestKeyOf({ ...reference, host: reference.host })}`;
467​
468const savedPullRequestSummariesAtom = Atom.make((get) => {
469 const summaries = new Map<string, PullRequestSummary>();
470 for (const thread of get(environmentThreadShells.threadShellsAtom)) {
471 for (const link of visibleThreadPullRequests(thread.pullRequests)) {
472 const summary = threadPullRequestLinkToSummary(link, thread.projectId);
473 if (summary === null) continue;
474 const key = savedPullRequestKey(thread.environmentId, link);
475 summaries.set(key, newestPullRequestSummary(summaries.get(key) ?? null, summary) ?? summary);
476 }
477 }
478 return summaries;
479});
480​
481const savedPullRequestSummaryAtom = Atom.family((key: string) =>
482 Atom.make((get) => get(savedPullRequestSummariesAtom).get(key) ?? null).pipe(
483 Atom.withEquality((previous, next) => JSON.stringify(previous) === JSON.stringify(next)),
484 Atom.setIdleTTL(5 * 60_000),
485 ),
486);
487​
488export function observedPullRequestTitle(
489 registry: AtomRegistry.AtomRegistry,
490 environmentId: EnvironmentId,
491 reference: PullRequestRef,
492): string | null {
493 return (
494 registry.get(pullRequestObservationsAtom(pullRequestSummaryKey(environmentId, reference)))
495 .metadata?.summary.title ?? null
496 );
498​
499/** The newest of every list, detail, checks and linked-summary answer and the thread's saved link. */
500export function useSharedPullRequestSummary(
501 environmentId: EnvironmentId | null,
502 reference: PullRequestRef | null,
503): PullRequestSummary | null {
504 const key =
505 environmentId === null || reference === null
506 ? "none"
507 : pullRequestSummaryKey(environmentId, reference);
508 const observations = useAtomValue(pullRequestObservationsAtom(key));
509 const saved = useAtomValue(
510 savedPullRequestSummaryAtom(
511 environmentId === null || reference === null
512 ? ""
513 : savedPullRequestKey(environmentId, reference),
514 ),
515 );
516 return newestPullRequestSummary(observedPullRequestSummary(observations), saved);
518export const pullRequestStackAtom = createPullRequestStackAtomFamily(
519 connectionAtomRuntime,
520 pullRequestEnvironment.refreshes,
521);
522​
523export interface EnvironmentQueryTarget<Input> {
524 readonly environmentId: EnvironmentId;
525 readonly input: Input;
527​
528interface MergedEnvironmentQueryView<A> {
529 readonly observations: ReadonlyArray<readonly [EnvironmentId, A, number]>;
530 readonly values: ReadonlyArray<readonly [EnvironmentId, A]>;
531 readonly error: string | null;
532 readonly isPending: boolean;
534​
535const failedListObservedAt = new WeakMap<object, number>();
536const isUnavailableList = Schema.is(PullRequestUnavailableError);
537const isFailedList = Schema.is(PullRequestOperationError);
538​
539export function observeFailedPullRequestList(
540 result: AsyncResult.Failure<PullRequestListResult, unknown>,
541): { readonly value: PullRequestListResult; readonly observedAt: number } | undefined {
542 if (result.waiting) return undefined;
543 const error = Option.getOrNull(Cause.findErrorOption(result.cause));
544 if ((!isUnavailableList(error) && !isFailedList(error)) || error.listResult === undefined) {
545 return undefined;
546 }
547 let observedAt = failedListObservedAt.get(result.cause);
548 if (observedAt === undefined) {
549 observedAt = Date.now();
550 failedListObservedAt.set(result.cause, observedAt);
551 }
552 return { value: error.listResult, observedAt };
554​
555// Use one derived atom for variable fan-out; unreachable environments must not hide other rows.
556function createMergedEnvironmentQuery<Input, A>(
557 label: string,
558 atomFor: (
559 target: EnvironmentQueryTarget<Input>,
560 ) => Atom.Atom<AsyncResult.AsyncResult<A, unknown>>,
561 observeFailure?: (
562 result: AsyncResult.Failure<A, unknown>,
563 ) => { readonly value: A; readonly observedAt: number } | undefined,
564) {
565 const family = Atom.family((key: string) =>
566 Atom.make((get): MergedEnvironmentQueryView<A> => {
567 const targets = JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>;
568 const values: Array<readonly [EnvironmentId, A]> = [];
569 const observations: Array<readonly [EnvironmentId, A, number]> = [];
570 const pendingEnvironments = new Set<EnvironmentId>();
571 let error: string | null = null;
572 let isPending = false;
573 for (const target of targets) {
574 const result = get(atomFor(target));
575 isPending ||= result.waiting;
576 if (result.waiting) pendingEnvironments.add(target.environmentId);
577 if (result._tag === "Failure" && error === null) {
578 error = formatEnvironmentQueryError(result.cause);
579 }
580 const value = AsyncResult.getOrElse(result, () => null);
581 if (value !== null) values.push([target.environmentId, value]);
582 if (result._tag === "Success") {
583 observations.push([target.environmentId, result.value, result.timestamp]);
584 } else if (result._tag === "Failure") {
585 const observation = observeFailure?.(result);
586 if (observation !== undefined) {
587 observations.push([target.environmentId, observation.value, observation.observedAt]);
588 }
589 }
590 }
591 return {
592 values,
593 error,
594 isPending,
595 // Another batch can still find a readable checkout on this environment.
596 observations: observations.filter(
597 ([environmentId]) => !pendingEnvironments.has(environmentId),
598 ),
599 };
600 }).pipe(Atom.withLabel(`${label}:${key}`)),
601 );
602 const empty = Atom.make<MergedEnvironmentQueryView<A>>({
603 observations: [],
604 values: [],
605 error: null,
606 isPending: false,
607 }).pipe(Atom.withLabel(`${label}:empty`));
608 return function useMergedQuery(targets: ReadonlyArray<EnvironmentQueryTarget<Input>>) {
609 const key = JSON.stringify(targets);
610 const view = useAtomValue(targets.length === 0 ? empty : family(key));
611 const refresh = (override?: ReadonlyArray<EnvironmentQueryTarget<Input>>) => {
612 const refreshTargets =
613 override ?? (JSON.parse(key) as ReadonlyArray<EnvironmentQueryTarget<Input>>);
614 for (const atom of new Set(refreshTargets.map(atomFor))) {
615 appAtomRegistry.refresh(atom);
616 }
617 };
618 return { ...view, refresh };
619 };
621​
622const usePullRequestListsQuery = createMergedEnvironmentQuery(
623 "web-pull-requests:list",
624 pullRequestEnvironment.list,
625 observeFailedPullRequestList,
626);
627​
628const usePullRequestStatsQuery = createMergedEnvironmentQuery(
629 "web-pull-requests:list-stats",
630 pullRequestEnvironment.listStats,
631);
632​
633const usePullRequestFilterOptionsQuery = createMergedEnvironmentQuery(
634 "web-pull-requests:filter-options",
635 pullRequestEnvironment.filterOptions,
636);
637​
638const usePullRequestTurnRefreshQuery = createMergedEnvironmentQuery(
639 "web-pull-requests:turn-refreshes",
640 ({ environmentId }: EnvironmentQueryTarget<Readonly<Record<string, never>>>) =>
641 pullRequestEnvironment.refreshes({ environmentId, input: {} }),
642);
643​
644export function usePullRequestTurnRefreshes(
645 environmentIds: ReadonlyArray<EnvironmentId>,
646): ReadonlyArray<readonly [EnvironmentId, number]> {
647 return usePullRequestTurnRefreshQuery(
648 environmentIds.map((environmentId) => ({ environmentId, input: {} })),
649 ).values;
651​
652export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null {
653 const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} }));
654 return AsyncResult.getOrElse(result, () => null);
656​
657export interface MergedPullRequestListView {
658 readonly data: MergedPullRequestList | null;
659 readonly error: string | null;
660 readonly isPending: boolean;
661 readonly observations: ReadonlyArray<readonly [EnvironmentId, PullRequestListResult, number]>;
662 readonly refresh: (targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>) => void;
664​
665export function usePullRequestList(
666 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListInput>>,
667): MergedPullRequestListView {
668 const query = usePullRequestListsQuery(targets);
669 const data = mergePullRequestLists(query.values);
670 return {
671 data,
672 error: query.error,
673 isPending: query.isPending,
674 observations: query.observations,
675 refresh: query.refresh,
676 };
678​
679export function usePullRequestListStats(
680 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
681): {
682 readonly stats: ReadonlyArray<EnvironmentPullRequestStat> | null;
683 readonly isPending: boolean;
684 readonly refresh: (
685 targets?: ReadonlyArray<EnvironmentQueryTarget<PullRequestListStatsInput>>,
686 ) => void;
687} {
688 const query = usePullRequestStatsQuery(targets);
689 const stats =
690 query.values.length === 0
691 ? null
692 : query.values.flatMap(([environmentId, result]) =>
693 result.stats.map((stat) => ({ ...stat, environmentId })),
694 );
695 return { stats, isPending: query.isPending, refresh: query.refresh };
697​
698export function usePullRequestFilterOptions(
699 targets: ReadonlyArray<EnvironmentQueryTarget<PullRequestFilterOptionsInput>>,
700) {
701 const query = usePullRequestFilterOptionsQuery(targets);
702 return {
703 labels: query.values.flatMap(([, options]) => options.labels),
704 authors: query.values.flatMap(([, options]) => options.authors),
705 references: query.values.flatMap(([, options]) => options.references),
706 error: query.error,
707 isPending: query.isPending,
708 };