1import type {
2 PullRequestReviewerCandidate,
3 PullRequestReviewerCandidateList,
4} from "@t3tools/contracts";
5
6// Loaded rows retain the request state updated by mutations.
7export function mergeReviewerCandidates(
8 loaded: ReadonlyArray<PullRequestReviewerCandidate>,
9 searched: ReadonlyArray<PullRequestReviewerCandidate>,
10 query: string,
11): ReadonlyArray<PullRequestReviewerCandidate> {
12 const needle = query.trim().toLowerCase();
13 const key = (candidate: PullRequestReviewerCandidate) => `${candidate.kind}:${candidate.id}`;
14 const known = new Set(loaded.map(key));
15 const hostMatches = new Set(searched.map(key));
16 return [
17 ...loaded.filter(
18 (candidate) =>
19 hostMatches.has(key(candidate)) ||
20 needle.length === 0 ||
21 candidate.login.toLowerCase().includes(needle) ||
22 (candidate.name ?? "").toLowerCase().includes(needle),
23 ),
24 // The host matched these on its own terms, an email for one, so they are not narrowed again.
25 ...searched.filter((candidate) => !known.has(key(candidate))),
26 ];
27}
28
29export interface ReviewerSearchInput {
30 readonly loaded: PullRequestReviewerCandidateList | null;
31 /** The host declared `reviewers.searchCandidates`. */
32 readonly searchable: boolean;
33 readonly query: string;
34 readonly debouncedQuery: string;
35}
36
37/** What to ask the host for, or null when the loaded page already answers the query. */
38export function hostReviewerQuery(input: ReviewerSearchInput): string | null {
39 const debounced = input.debouncedQuery.trim();
40 return input.searchable && input.loaded?.truncated === true && debounced.length > 0
41 ? debounced
42 : null;
43}
44
45/**
46 * Why the list may be missing people: `unsearchable` hosts cannot be asked, `searchable` ones
47 * can once something is typed, and a search that ran either `failed` or was `truncated` itself.
48 */
49export type ReviewerTruncation =
50 | "unsearchable"
51 | "searchable"
52 | "search-failed"
53 | "search-truncated";
54
55export function reviewerCandidateView(
56 input: ReviewerSearchInput & {
57 readonly search: {
58 readonly data: PullRequestReviewerCandidateList | null;
59 readonly isPending: boolean;
60 readonly error: string | null;
61 };
62 },
63): {
64 readonly candidates: ReadonlyArray<PullRequestReviewerCandidate>;
65 /** Nothing matches yet and the host has not answered, which is not "nobody matches". */
66 readonly searchPending: boolean;
67 readonly searchError: string | null;
68 readonly truncation: ReviewerTruncation | null;
69} {
70 const query = input.query.trim();
71 const hostQuery = hostReviewerQuery(input);
72 // Only once the debounce has caught up, or rows matching the previous text would linger.
73 const searched = hostQuery !== null && hostQuery === query;
74 const candidates = mergeReviewerCandidates(
75 input.loaded?.candidates ?? [],
76 searched ? (input.search.data?.candidates ?? []) : [],
77 query,
78 );
79 const searching = input.searchable && input.loaded?.truncated === true && query.length > 0;
80 const searchError = searched ? input.search.error : null;
81 const truncation: ReviewerTruncation | null =
82 input.loaded?.truncated !== true
83 ? null
84 : !input.searchable
85 ? "unsearchable"
86 : query.length === 0
87 ? "searchable"
88 : !searched
89 ? null
90 : searchError !== null
91 ? "search-failed"
92 : input.search.data?.truncated === true
93 ? "search-truncated"
94 : null;
95 return {
96 candidates,
97 searchPending: candidates.length === 0 && searching && (!searched || input.search.isPending),
98 searchError,
99 truncation,
100 };
101}