Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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