Skip to content
340 linesCodeBlameRaw
1import { useState } from "react";
2import { Form, data, redirect } from "react-router";
3
4import {
5 MAX_ASSIGNED,
6 REVIEW_ALGORITHM_LABELS,
7 REVIEW_ALGORITHM_SUMMARIES,
8 TEAM_VISIBILITY_SUMMARIES,
9 type ReviewAlgorithm,
10 type TeamVisibility,
11} from "@g1t/contracts";
12
13import type { Route } from "./+types/settings";
14import { ConfirmDialog } from "../../../components/repo-lifecycle";
15import { useTeam, useTeamAgents } from "../../../components/teams";
16import { Coming } from "../../../components/people";
17import { SelectField } from "../../../components/ui/select";
18import { budgetFromText } from "../../../lib/people";
19import { ErrorText, Field, Input, SubmitButton } from "../../../components/ui";
20import { Button } from "../../../components/ui/button";
21import { Card } from "../../../components/ui/card";
22import { Input as NumberInput } from "../../../components/ui/input";
23import { Textarea } from "../../../components/ui/textarea";
24import { CheckboxOption } from "../../../components/ui/checkbox";
25import { RadioGroup, RadioOption } from "../../../components/ui/radio-group";
26import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
27import { SwitchCard } from "../../../components/ui/switch";
28import { parentChoices, reviewAssignmentFromForm, teamChangesFromForm, teamPath } from "../../../lib/teams";
29import { chat, identity, workspaceAgents } from "../../../lib/services.server";
30import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
31
32/** The parent select's value for "no parent". */
33const NO_PARENT = "-";
34/** The lead select's value for no lead. */
35const NO_LEAD = "none";
36
37/**
38 * Its lead, channel and budget. An agent made lead is added to the team
39 * first if it isn't on it, as identity needs; a channel is found by its
40 * name in Chat, as the viewer sees it.
41 */
42async function saveLeadChannelBudget(form: FormData, workspace: string, slug: string, user: Parameters<typeof identity.updateTeam>[0]) {
43 const intent = "people";
44 const lead = String(form.get("lead") ?? NO_LEAD);
45 const channel = String(form.get("channel") ?? "").trim().replace(/^#/, "").toLowerCase();
46 const budget = budgetFromText(String(form.get("budget") ?? ""));
47 if (budget === null) return { intent, error: "Give the budget in dollars, such as 150.", saved: false };
48 if (lead.startsWith("agent:")) {
49 const listed = await workspaceAgents.list(workspace, user).catch(() => null);
50 const agent = listed?.ok ? listed.value.find((a) => `agent:${a.id}` === lead) : null;
51 if (!agent) return { intent, error: "Choose one of the team's agents.", saved: false };
52 const added = await identity.setTeamAgent(user, workspace, slug, agent.id);
53 if (!added.ok) return { intent, error: added.error.message, saved: false };
54 }
55 let channelId = "";
56 let channelName = "";
57 if (channel) {
58 const found = await chat.channelByName(workspace, channel, user).catch(() => null);
59 if (!found?.ok || found.value.channel.kind !== "channel") return { intent, error: `There is no channel #${channel} you can see.`, saved: false };
60 channelId = found.value.channel.id;
61 channelName = found.value.channel.name ?? channel;
62 }
63 const saved = await identity.updateTeam(user, workspace, slug, {
64 lead: lead === NO_LEAD ? "" : lead,
65 channel_id: channelId,
66 channel_name: channelName,
67 budget_micros: budget,
68 });
69 return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false };
70}
71
72export async function loader({ params, context }: Route.LoaderArgs) {
73 const viewer = getViewer(context);
74 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
75 const team = unwrap(await identity.getTeam(viewer, params.owner, params.team));
76 if (!team.can_manage) throw data(null, { status: 404 });
77 const [all, members] = await Promise.all([
78 identity.listTeams(viewer, params.owner).then((found) => (found.ok ? found.value : [])),
79 // Who can lead it: its own people (and its agents, from the layout).
80 identity.teamMembers(viewer, params.owner, params.team, false).then((found) => (found.ok ? found.value : [])),
81 ]);
82 const parents = parentChoices(all, team.slug).filter((other) => other.can_manage || other.slug === team.parent?.slug);
83 return { parents, people: members.map((member) => ({ username: member.username, name: member.name })) };
84}
85
86export async function action({ request, params, context }: Route.ActionArgs) {
87 assertSameOrigin(request);
88 const user = requireUser(context, request);
89 const form = await request.formData();
90 const intent = String(form.get("intent") ?? "");
91 if (intent === "delete") {
92 const deleted = await identity.deleteTeam(user, params.owner, params.team);
93 if (!deleted.ok) return { intent, error: deleted.error.message, saved: false };
94 throw redirect(`/${params.owner}/-/teams`);
95 }
96 if (intent === "review") {
97 const current = unwrap(await identity.getTeam(user, params.owner, params.team));
98 const review = reviewAssignmentFromForm(form, current.review_assignment);
99 const saved = await identity.updateTeam(user, params.owner, params.team, { review_assignment: review });
100 return saved.ok ? { intent, error: null, saved: true } : { intent, error: saved.error.message, saved: false };
101 }
102 if (intent === "people") return saveLeadChannelBudget(form, params.owner, params.team, user);
103 const changes = teamChangesFromForm(form);
104 if (changes.parent === NO_PARENT) changes.parent = "";
105 const saved = await identity.updateTeam(user, params.owner, params.team, changes);
106 if (!saved.ok) return { intent, error: saved.error.message, saved: false };
107 // A new slug is a new address.
108 if (saved.value.slug !== params.team) throw redirect(teamPath(saved.value.workspace, saved.value.slug, "settings"));
109 return { intent, error: null, saved: true };
110}
111
112export default function TeamSettings({ loaderData, actionData }: Route.ComponentProps) {
113 const team = useTeam();
114 const { agents } = useTeamAgents();
115 const { parents, people } = loaderData;
116 const leadValue = team.lead ? (team.lead.kind === "user" ? `@${team.lead.username}` : `agent:${team.lead.agent_id}`) : NO_LEAD;
117 const said = (intent: string) => (actionData?.intent === intent ? actionData : null);
118 const [visibility, setVisibility] = useState<TeamVisibility>(team.visibility);
119 const review = team.review_assignment;
120 const [assigning, setAssigning] = useState(review.enabled);
121 const [algorithm, setAlgorithm] = useState<ReviewAlgorithm>(review.algorithm);
122 const [skipBusy, setSkipBusy] = useState(review.skip_busy);
123
124 return (
125 <div className="max-w-2xl space-y-10">
126 <section aria-labelledby="profile">
127 <h2 id="profile" className="font-medium">
128 Profile
129 </h2>
130 <Form method="post" className="mt-4 space-y-5" key={team.updated_at}>
131 <input type="hidden" name="intent" value="profile" />
132 <Field label="Name">
133 <Input name="name" required maxLength={80} defaultValue={team.name} />
134 </Field>
135 <Field label="Slug" hint={`Mentioned as @${team.workspace}/<slug>. Changing it changes the team's address and how it is mentioned.`}>
136 <Input name="slug" required maxLength={60} defaultValue={team.slug} pattern="[a-z0-9]+(-[a-z0-9]+)*" />
137 </Field>
138 <Field label="Description">
139 <Textarea name="description" rows={2} maxLength={280} defaultValue={team.description ?? ""} />
140 </Field>
141 <fieldset>
142 <legend className="mb-2 text-sm font-medium text-muted">Visibility</legend>
143 <RadioGroup name="visibility" value={visibility} onValueChange={(value) => setVisibility(value as TeamVisibility)} className="space-y-3">
144 <RadioOption value="visible" label="Visible" description={TEAM_VISIBILITY_SUMMARIES.visible} />
145 <RadioOption value="secret" label="Secret" description={TEAM_VISIBILITY_SUMMARIES.secret} />
146 </RadioGroup>
147 </fieldset>
148 {visibility === "visible" && (
149 <div>
150 <span className="mb-1.5 block text-sm font-medium text-muted">Parent team</span>
151 <Select name="parent" defaultValue={team.parent?.slug ?? NO_PARENT}>
152 <SelectTrigger aria-label="Parent team" className="sm:max-w-72">
153 <SelectValue />
154 </SelectTrigger>
155 <SelectContent>
156 <SelectItem value={NO_PARENT}>No parent</SelectItem>
157 {parents.map((parent) => (
158 <SelectItem key={parent.slug} value={parent.slug}>
159 {parent.name}
160 </SelectItem>
161 ))}
162 </SelectContent>
163 </Select>
164 <span className="mt-1.5 block text-xs text-faint">
165 It has its parent's roles on repositories, and hears what is asked of its parent. Moving it needs you to
166 maintain both teams, or own the workspace.
167 </span>
168 </div>
169 )}
170 <input type="hidden" name="notify-shown" value="1" />
171 <SwitchCard name="notify" defaultChecked={team.notify} title="Notify the team when it is mentioned">
172 Everyone in it, and in its child teams, hears of @{team.workspace}/{team.slug} in their notifications.
173 </SwitchCard>
174 <ErrorText>{said("profile")?.error ?? null}</ErrorText>
175 <div className="flex items-center gap-3">
176 <SubmitButton match={{ intent: "profile" }} pending="Saving…">
177 Save
178 </SubmitButton>
179 {said("profile")?.saved && <span className="text-sm text-muted">Saved.</span>}
180 </div>
181 </Form>
182 </section>
183
184 <section aria-labelledby="lead" className="border-t border-line pt-8">
185 <h2 id="lead" className="font-medium">
186 Lead, channel and budget
187 </h2>
188 <p className="mt-1 text-sm text-muted">
189 Everyone on {team.name}, agents included, is told who leads it and where it talks. Its budget caps what its agents spend
190 together in a month; each agent keeps its own budget too.
191 </p>
192 <Form method="post" className="mt-4 space-y-5" key={`people-${team.updated_at}`}>
193 <input type="hidden" name="intent" value="people" />
194 <label className="block">
195 <span className="mb-1.5 block text-sm font-medium text-muted">Lead</span>
196 <SelectField
197 name="lead"
198 defaultValue={leadValue}
199 className="w-full sm:max-w-72"
200 options={[
201 { value: NO_LEAD, label: "No lead" },
202 ...people.map((person) => ({ value: `@${person.username}`, label: person.name ? `${person.name} (@${person.username})` : `@${person.username}` })),
203 ...agents.map((agent) => ({ value: `agent:${agent.id}`, label: `${agent.display_name}, an agent` })),
204 ]}
205 />
206 <span className="mt-1.5 block text-xs text-faint">Someone on the team. Agents page the lead first when a person is needed.</span>
207 </label>
208 <Field label="Channel" hint="The team's channel in Chat, by name. Leave it empty for none.">
209 <span className="block sm:max-w-72">
210 <Input name="channel" defaultValue={team.channel?.name ?? ""} placeholder="sales" />
211 </span>
212 </Field>
213 <Field label="Budget for its agents, a month" hint="In dollars. Empty or 0 for no team budget.">
214 <span className="block sm:max-w-40">
215 <Input name="budget" inputMode="decimal" defaultValue={team.budget_micros ? String(team.budget_micros / 1_000_000) : ""} placeholder="150" />
216 </span>
217 </Field>
218 <div className="flex items-center gap-2 text-sm text-muted">
219 Storage level <Coming />
220 </div>
221 <ErrorText>{said("people")?.error ?? null}</ErrorText>
222 <div className="flex items-center gap-3">
223 <SubmitButton match={{ intent: "people" }} pending="Saving…">
224 Save
225 </SubmitButton>
226 {said("people")?.saved && <span className="text-sm text-muted">Saved.</span>}
227 </div>
228 </Form>
229 </section>
230
231 <section aria-labelledby="review" className="border-t border-line pt-8">
232 <h2 id="review" className="font-medium">
233 Code review assignment
234 </h2>
235 <p className="mt-1 text-sm text-muted">
236 When {team.name} is asked to review a pull request, by a person or by a CODEOWNERS file. Off, everyone in the
237 team is asked. On, g1t picks people from it; the team stays shown as asked.
238 </p>
239 <Form method="post" className="mt-4 space-y-5" key={`review-${team.updated_at}`}>
240 <input type="hidden" name="intent" value="review" />
241 <SwitchCard name="enabled" checked={assigning} onCheckedChange={setAssigning} title="Assign reviewers from the team">
242 Never the pull request's author, and people from the team already asked count.
243 </SwitchCard>
244 {assigning && (
245 <Card tone="plain" className="space-y-5 p-4">
246 <Field label="How many people" hint={`1 to ${MAX_ASSIGNED}.`}>
247 <NumberInput name="count" type="number" min={1} max={MAX_ASSIGNED} defaultValue={review.count} className="max-w-24" />
248 </Field>
249 <fieldset>
250 <legend className="mb-2 text-sm font-medium text-muted">Who goes first</legend>
251 <RadioGroup name="algorithm" value={algorithm} onValueChange={(value) => setAlgorithm(value as ReviewAlgorithm)} className="space-y-3">
252 {(["round_robin", "load_balance"] as const).map((value) => (
253 <RadioOption key={value} value={value} label={REVIEW_ALGORITHM_LABELS[value]} description={REVIEW_ALGORITHM_SUMMARIES[value]} />
254 ))}
255 </RadioGroup>
256 </fieldset>
257 <div className="space-y-2">
258 <CheckboxOption
259 name="skip_busy"
260 checked={skipBusy}
261 onCheckedChange={(on) => setSkipBusy(on === true)}
262 label="Skip people who are busy"
263 description="Busy: this many or more open pull requests are waiting on their review."
264 />
265 {skipBusy && (
266 <div className="pl-6">
267 <NumberInput name="busy_at" type="number" min={1} max={100} defaultValue={review.busy_at} aria-label="Busy at" className="max-w-24" />
268 </div>
269 )}
270 </div>
271 <CheckboxOption
272 name="include_child_teams"
273 defaultChecked={review.include_child_teams}
274 label="Pick from child teams too"
275 description="Otherwise only the team's own people are picked."
276 />
277 <CheckboxOption
278 name="notify_team"
279 defaultChecked={review.notify_team}
280 label="Also tell the rest of the team"
281 description="Everyone in the team hears of the request, not only the people picked."
282 />
283 <Field label="Never pick" hint="Usernames, separated by spaces or commas.">
284 <Input name="excluded" defaultValue={review.excluded.join(" ")} placeholder="ana bo" />
285 </Field>
286 </Card>
287 )}
288 {!assigning && (
289 <>
290 {/* Kept as they are while assignment is off. */}
291 <input type="hidden" name="algorithm" value={review.algorithm} />
292 <input type="hidden" name="count" value={review.count} />
293 <input type="hidden" name="busy_at" value={review.busy_at} />
294 <input type="hidden" name="excluded" value={review.excluded.join(" ")} />
295 {review.skip_busy && <input type="hidden" name="skip_busy" value="on" />}
296 {review.include_child_teams && <input type="hidden" name="include_child_teams" value="on" />}
297 {review.notify_team && <input type="hidden" name="notify_team" value="on" />}
298 </>
299 )}
300 <ErrorText>{said("review")?.error ?? null}</ErrorText>
301 <div className="flex items-center gap-3">
302 <SubmitButton match={{ intent: "review" }} pending="Saving…">
303 Save
304 </SubmitButton>
305 {said("review")?.saved && <span className="text-sm text-muted">Saved.</span>}
306 </div>
307 </Form>
308 </section>
309
310 <section aria-labelledby="danger" className="border-t border-line pt-8">
311 <h2 id="danger" className="font-medium">
312 Delete this team
313 </h2>
314 <p className="mt-1 text-sm text-muted">
315 Its people stay in {team.workspace}, and lose the roles the team gave them. Its child teams move up to{" "}
316 {team.parent ? team.parent.name : "the top"}.
317 </p>
318 <div className="mt-4">
319 <ConfirmDialog
320 intent="delete"
321 title={`Delete ${team.name}?`}
322 description="This cannot be undone."
323 confirm={`${team.workspace}/${team.slug}`}
324 submit="Delete team"
325 busy="Deleting…"
326 error={said("delete")?.error ?? null}
327 trigger={(open) => (
328 <Button type="button" variant="destructive" onClick={open}>
329 Delete team
330 </Button>
331 )}
332 >
333 <li>Mentions of @{team.workspace}/{team.slug} stop telling anyone.</li>
334 <li>Pull requests that asked it to review keep the people it picked.</li>
335 </ConfirmDialog>
336 </div>
337 </section>
338 </div>
339 );
340}