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.

164 linesCodeBlameRaw
1import { Box } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, data, useFetcher } from "react-router";
4
5import { REPO_ROLES, REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES, type RepoRole, type TeamRepo } from "@g1t/contracts";
6
7import type { Route } from "./+types/repositories";
8import { useTeam } from "../../../components/teams";
9import { ErrorText, SubmitButton } from "../../../components/ui";
10import { Badge } from "../../../components/ui/badge";
11import { Card } from "../../../components/ui/card";
12import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
13import { teamPath } from "../../../lib/teams";
14import { identity, repos } from "../../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
16
17const isRole = (value: string): value is RepoRole => (REPO_ROLES as readonly string[]).includes(value);
18
19export async function loader({ params, context }: Route.LoaderArgs) {
20 const viewer = getViewer(context);
21 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
22 const [granted, list] = await Promise.all([
23 identity.teamRepos(viewer, params.owner, params.team).then(unwrap),
24 repos.list(viewer, { namespace: params.owner, memberOnly: true }).catch(() => []),
25 ]);
26 return { granted, names: list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) };
27}
28
29export async function action({ request, params, context }: Route.ActionArgs) {
30 assertSameOrigin(request);
31 const user = requireUser(context, request);
32 const form = await request.formData();
33 const intent = String(form.get("intent") ?? "");
34 const name = String(form.get("repo") ?? "").trim().replace(/^.*\//, "");
35 if (!name) return { intent, repo: name, error: "Choose a repository." };
36 if (intent === "remove") {
37 const removed = await identity.removeTeamRepo(user, params.owner, params.team, params.owner, name);
38 return removed.ok ? { intent, repo: name, error: null } : { intent, repo: name, error: removed.error.message };
39 }
40 const role = String(form.get("role") ?? "");
41 if (!isRole(role)) return { intent, repo: name, error: "Choose a role." };
42 const set = await identity.setTeamRepo(user, params.owner, params.team, params.owner, name, role);
43 return set.ok ? { intent, repo: name, error: null } : { intent, repo: name, error: set.error.message };
44}
45
46function RoleSelect({ value, onValueChange, name }: { value: RepoRole; onValueChange?: (role: RepoRole) => void; name?: string }) {
47 return (
48 <Select name={name} value={value} onValueChange={(role) => onValueChange?.(role as RepoRole)}>
49 <SelectTrigger aria-label="Role" className="w-32">
50 <SelectValue />
51 </SelectTrigger>
52 <SelectContent>
53 {REPO_ROLES.map((role) => (
54 <SelectItem key={role} value={role} description={REPO_ROLE_SUMMARIES[role]}>
55 {REPO_ROLE_LABELS[role]}
56 </SelectItem>
57 ))}
58 </SelectContent>
59 </Select>
60 );
61}
62
63export default function TeamRepositories({ loaderData, actionData }: Route.ComponentProps) {
64 const team = useTeam();
65 const { granted, names } = loaderData;
66 const own = new Set(granted.filter((repo) => repo.inherited_from == null).map((repo) => repo.repo.split("/")[1]));
67 const addable = names.filter((name) => !own.has(name));
68 const [role, setRole] = useState<RepoRole>("write");
69
70 return (
71 <div className="max-w-3xl space-y-6">
72 <p className="text-sm text-muted">
73 Everyone in {team.name}, and in its child teams, has these roles. Where someone has a role another way too, the
74 highest one counts. Giving a team a role on a repository needs the Admin role there.
75 </p>
76 {granted.length === 0 ? (
77 <Card tone="plain" className="border-dashed px-6 py-10 text-center">
78 <Box size={18} className="mx-auto text-faint" />
79 <p className="mt-2 text-sm font-medium">{team.name} has no roles on repositories yet</p>
80 </Card>
81 ) : (
82 <Card asChild tone="plain" divided className="overflow-hidden">
83 <ul>
84 {granted.map((repo) => (
85 <RepoRow key={repo.repo_id} repo={repo} manage={team.can_manage} error={actionData?.repo === repo.repo.split("/")[1] && actionData.intent !== "add" ? actionData.error : null} />
86 ))}
87 </ul>
88 </Card>
89 )}
90
91 {addable.length > 0 && (
92 <Card asChild key={granted.length} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
93 <Form method="post">
94 <input type="hidden" name="intent" value="add" />
95 <Select name="repo">
96 <SelectTrigger aria-label="Repository" className="sm:max-w-64">
97 <SelectValue placeholder="Choose a repository" />
98 </SelectTrigger>
99 <SelectContent>
100 {addable.map((name) => (
101 <SelectItem key={name} value={name}>
102 {name}
103 </SelectItem>
104 ))}
105 </SelectContent>
106 </Select>
107 <RoleSelect name="role" value={role} onValueChange={setRole} />
108 <SubmitButton variant="outline" match={{ intent: "add" }} pending="Adding…">
109 Add repository
110 </SubmitButton>
111 </Form>
112 </Card>
113 )}
114 {actionData?.intent === "add" && <ErrorText>{actionData.error}</ErrorText>}
115 </div>
116 );
117}
118
119function RepoRow({ repo, manage, error }: { repo: TeamRepo; manage: boolean; error: string | null }) {
120 const fetcher = useFetcher<typeof action>();
121 const team = useTeam();
122 const name = repo.repo.split("/")[1] ?? repo.repo;
123 const inherited = repo.inherited_from != null;
124 return (
125 <li className="px-4 py-3">
126 <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
127 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
128 <Box size={13} />
129 </span>
130 <div className="min-w-0 grow basis-40">
131 <Link to={`/${repo.repo}`} className="font-mono text-sm font-medium hover:underline">
132 {name}
133 </Link>
134 {inherited && (
135 <p className="text-xs text-faint">
136 From{" "}
137 <Link to={teamPath(team.workspace, repo.inherited_from!)} className="hover:text-fg hover:underline">
138 {repo.inherited_from}
139 </Link>
140 </p>
141 )}
142 </div>
143 {inherited || !manage ? (
144 <Badge>{REPO_ROLE_LABELS[repo.role]}</Badge>
145 ) : (
146 <span className="flex items-center gap-1">
147 <RoleSelect
148 value={repo.role}
149 onValueChange={(role) => fetcher.submit({ intent: "role", repo: name, role }, { method: "post" })}
150 />
151 <Form method="post">
152 <input type="hidden" name="intent" value="remove" />
153 <input type="hidden" name="repo" value={name} />
154 <SubmitButton variant="outline" match={{ intent: "remove", repo: name }} pending="Removing…">
155 Remove
156 </SubmitButton>
157 </Form>
158 </span>
159 )}
160 </div>
161 <ErrorText>{fetcher.data?.error ?? error}</ErrorText>
162 </li>
163 );
164}