1import { useAtomCommand } from "~/state/use-atom-command";
2/**
3 * Asking someone to review, from the row that says who is already reviewing.
4 *
5 * The people who may be asked are read only once this menu opens: on a large repository that is
6 * a list of everyone with access, which is worth a request when somebody wants it and worth
7 * nothing on every pull request they merely open.
8 */
9import type {
10 EnvironmentId,
11 PullRequestRef,
12 PullRequestReviewerCandidate,
13} from "@t3tools/contracts";
14import { CheckIcon, UserPlusIcon } from "lucide-react";
15import { useMemo, useState } from "react";
16
17import { pullRequestEnvironment } from "~/state/pullRequests";
18import { useEnvironmentQuery } from "~/state/query";
19import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
20
21import { toastManager } from "../ui/toast";
22import { PullRequestCandidatePicker } from "./PullRequestCandidatePicker";
23import { PullRequestActorLabel } from "./pullRequestPresentation";
24import { readableFailure } from "./pullRequestDetail.logic";
25
26/** Long lists are common — an organisation repository lists everyone — so what arrived can be
27 * narrowed here. It narrows only what arrived: the host is asked once, when the menu opens. */
28function matches(candidate: PullRequestReviewerCandidate, query: string): boolean {
29 if (query.length === 0) return true;
30 const needle = query.toLowerCase();
31 return (
32 candidate.login.toLowerCase().includes(needle) ||
33 (candidate.name ?? "").toLowerCase().includes(needle)
34 );
35}
36
37export function PullRequestReviewerPicker({
38 environmentId,
39 reference,
40 allowed,
41}: {
42 environmentId: EnvironmentId;
43 reference: PullRequestRef;
44 /** False where the host would refuse this account's request, which is worth saying rather than
45 * hiding: the control disabled with a reason answers the question its absence would raise. */
46 allowed: boolean;
47}) {
48 const [open, setOpen] = useState(false);
49 const [query, setQuery] = useState("");
50 const [pending, setPending] = useState<string | null>(null);
51
52 // Mounted with the menu closed, so nothing is asked of the host until it opens.
53 const candidatesQuery = useEnvironmentQuery(
54 open ? pullRequestEnvironment.reviewerCandidates({ environmentId, input: reference }) : null,
55 );
56 const requestReviewers = useAtomCommand(pullRequestEnvironment.requestReviewers, {
57 reportFailure: false,
58 });
59
60 const candidates = useMemo(
61 () => (candidatesQuery.data?.candidates ?? []).filter((entry) => matches(entry, query)),
62 [candidatesQuery.data, query],
63 );
64
65 const toggle = async (candidate: PullRequestReviewerCandidate) => {
66 if (pending !== null) return;
67 setPending(candidate.id);
68 const result = await requestReviewers({
69 environmentId,
70 input: {
71 ...reference,
72 reviewers: [{ id: candidate.id, kind: candidate.kind }],
73 requested: !candidate.isRequested,
74 },
75 });
76 setPending(null);
77 if (result._tag === "Failure") {
78 toastManager.add({
79 type: "error",
80 title: candidate.isRequested
81 ? `Could not take back the review request to ${candidate.login}`
82 : `Could not ask ${candidate.login} for a review`,
83 description: readableFailure(
84 squashAtomCommandFailure(result),
85 "The host refused it. Check that you have write access on this repository, and that they still have access to it.",
86 ),
87 });
88 return;
89 }
90 toastManager.add({
91 type: "success",
92 title: candidate.isRequested
93 ? `Review request to ${candidate.login} taken back`
94 : `Review requested from ${candidate.login}`,
95 });
96 };
97
98 return (
99 <PullRequestCandidatePicker
100 icon={<UserPlusIcon className="size-3.5" />}
101 label="Request a review"
102 allowed={allowed}
103 disabledReason="Asking someone to review needs write access on this repository"
104 open={open}
105 onOpenChange={setOpen}
106 query={query}
107 onQueryChange={setQuery}
108 searchLabel="Search people with access"
109 isPending={candidatesQuery.isPending && candidatesQuery.data === null}
110 error={candidatesQuery.data === null ? candidatesQuery.error : null}
111 candidates={candidates}
112 emptyLabel="Nobody else has access to this repository."
113 noMatchLabel="Nobody with access matches that."
114 errorLabel="The people with access could not be read."
115 truncated={candidatesQuery.data?.truncated === true}
116 truncatedLabel="This repository has more people with access than are listed here. Ask for the rest on the host."
117 candidateKey={(candidate) => `${candidate.kind}:${candidate.id}`}
118 disabled={pending !== null}
119 onSelect={(candidate) => void toggle(candidate)}
120 >
121 {(candidate) => (
122 <>
123 <PullRequestActorLabel actor={candidate} className="flex-1" />
124 {candidate.kind === "team" ? (
125 <span className="shrink-0 text-muted-foreground">team</span>
126 ) : null}
127 {candidate.isRequested ? (
128 <CheckIcon aria-label="Already asked" className="size-3.5 shrink-0" />
129 ) : null}
130 </>
131 )}
132 </PullRequestCandidatePicker>
133 );
134}