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.

328 linesCodeBlameRaw
1import * as Clipboard from "expo-clipboard";
2import { Stack, router, useLocalSearchParams } from "expo-router";
3import { useHeaderHeight } from "expo-router/react-navigation";
4import * as WebBrowser from "expo-web-browser";
5import { CircleCheck, CircleDot, CircleX, Copy, ExternalLink, FolderGit2, Link2, Plus, Search, Unlink } from "lucide-react-native";
6import { useState } from "react";
7import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native";
8
9import { Sheet, SheetAction } from "@/components/chat/sheet";
10import { Body, Checks, PullIcon, Timeline } from "@/components/code";
11import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui";
12import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work";
13import { useRepos } from "@/lib/code";
14import { siteUrl } from "@/lib/server";
15import { useSignedIn } from "@/lib/session";
16import { ago } from "@/lib/time";
17import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work";
18import { radius, space, useColors } from "@/theme";
19
20/**
21 * A work item: what it asks for, the repositories it is linked to (where
22 * it came from, where it is meant to land, and what has landed where),
23 * each one's pull requests for it, and its conversation. The bar at the
24 * foot comments, and its ⋯ closes, reopens and links a repository.
25 */
26export default function WorkItem() {
27 const params = useLocalSearchParams<{ number: string; workspace?: string }>();
28 const { server, workspace: current } = useSignedIn();
29 const workspace = params.workspace ?? current?.slug ?? "";
30 const number = Number(params.number);
31 const detail = useWorkItem(workspace, number);
32 const context = useWorkContext(workspace, number);
33 const actions = useItemActions(workspace, number);
34 const headerHeight = useHeaderHeight();
35 const colors = useColors();
36 const [more, setMore] = useState(false);
37 const [linking, setLinking] = useState(false);
38 const [picked, setPicked] = useState<ApiItemRepo | null>(null);
39
40 const title = <Stack.Screen options={{ title: `#${number}` }} />;
41 if (detail.isPending || detail.error) {
42 return (
43 <Screen>
44 {title}
45 {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />}
46 </Screen>
47 );
48 }
49
50 const { item, comments } = detail.data;
51 const by = item.author.display_username ?? item.author.username;
52 const refs = { workspace };
53 const sitePath = `/${workspace}/-/work/${number}`;
54 // The context has each repository's pull requests; the item alone still has its repositories.
55 const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] }));
56 const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path);
57 const linked = new Set(repos.map((repo) => repo.path.toLowerCase()));
58 const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase()));
59
60 const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong.");
61 const close = (reason: "completed" | "not_planned") => {
62 setMore(false);
63 actions.close.mutate(reason, { onError: failed("Could not close it") });
64 };
65 const reopen = () => {
66 setMore(false);
67 actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") });
68 };
69 const link = (repo: string) => {
70 setLinking(false);
71 actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") });
72 };
73 const openRepo = (path: string) => {
74 const [owner, name] = path.split("/", 2);
75 router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } });
76 };
77 const openPull = (pull: ApiWorkPull) => {
78 const [owner, name] = pull.repo.split("/", 2);
79 router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } });
80 };
81 const busy = actions.close.isPending || actions.reopen.isPending;
82
83 return (
84 <Screen>
85 {title}
86 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
87 <ScrollView
88 contentContainerStyle={styles.content}
89 keyboardShouldPersistTaps="handled"
90 keyboardDismissMode="interactive"
91 refreshControl={
92 <RefreshControl refreshing={detail.isRefetching} onRefresh={() => Promise.all([detail.refetch(), context.refetch()])} tintColor={colors.accent} />
93 }
94 >
95 <View style={{ gap: space.sm }}>
96 <Text size="heading" selectable>
97 {item.title}
98 </Text>
99 <View style={styles.wrap}>
100 <ItemIcon item={item} size={18} />
101 <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}>
102 {stateLabel(item)}
103 </Text>
104 <Text size="small" tone="muted">
105 #{item.number} · {by} · {ago(item.created_at)}
106 </Text>
107 </View>
108 {item.labels.length || item.milestone || item.assignees.length ? (
109 <View style={styles.wrap}>
110 {item.labels.map((label) => (
111 <Chip key={label} label={label} />
112 ))}
113 {item.milestone ? <Chip label={item.milestone.title} /> : null}
114 {item.assignees.map((name) => (
115 <Chip key={name} label={`@${name}`} />
116 ))}
117 </View>
118 ) : null}
119 </View>
120
121 <Body text={item.body} refs={refs} />
122
123 <View style={{ marginHorizontal: -space.lg }}>
124 <SectionHeader title="Repositories" />
125 {repos.length === 0 ? (
126 <Text tone="muted" style={{ paddingHorizontal: space.lg }}>
127 Not linked to a repository yet. Link the ones where the work is meant to land.
128 </Text>
129 ) : (
130 repos.map((repo) => (
131 <View key={repo.repo_id}>
132 <Row
133 leading={<FolderGit2 size={20} color={colors.muted} />}
134 title={repoName(repo.path, workspace)}
135 subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`}
136 trailing={repo.landed ? <Badge label="Landed" tone="merged" /> : repo.role === "target" ? <Badge label="Not landed" tone="warn" /> : null}
137 // A deleted repository has nothing to open or unlink.
138 onPress={repo.path ? () => setPicked(repo) : undefined}
139 />
140 {repo.pulls.map((pull) => (
141 <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}>
142 <Row
143 leading={<PullIcon pull={pull} />}
144 title={pull.title}
145 subtitle={`#${pull.number} · ${pull.status}`}
146 trailing={<Checks status={pull.check_status} />}
147 onPress={() => openPull(pull)}
148 />
149 </View>
150 ))}
151 </View>
152 ))
153 )}
154 {missing.length && item.state === "open" ? (
155 <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}>
156 Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}.
157 </Text>
158 ) : null}
159 <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}>
160 <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} />
161 </View>
162 </View>
163
164 {suggested.length ? (
165 <View style={{ marginHorizontal: -space.lg }}>
166 <SectionHeader title="Suggested" />
167 {suggested.map((one) => (
168 <Row
169 key={one.repo_id}
170 leading={<FolderGit2 size={20} color={colors.faint} />}
171 title={repoName(one.path, workspace)}
172 subtitle={one.why}
173 chevron={false}
174 trailing={
175 <Button
176 title="Add as target"
177 variant="secondary"
178 icon={Plus}
179 busy={actions.link.isPending && actions.link.variables?.repo === one.path}
180 onPress={() => link(one.path)}
181 />
182 }
183 />
184 ))}
185 </View>
186 ) : null}
187
188 <Timeline comments={comments} refs={refs} />
189
190 {item.state === "open" ? (
191 <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} />
192 ) : (
193 <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} />
194 )}
195 </ScrollView>
196 <CommentBar
197 busy={actions.comment.isPending}
198 onMore={() => setMore(true)}
199 onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))}
200 />
201 </KeyboardAvoidingView>
202
203 <Sheet visible={more} onClose={() => setMore(false)}>
204 {item.state === "open" ? (
205 <>
206 <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} />
207 <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} />
208 </>
209 ) : (
210 <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} />
211 )}
212 <SheetAction
213 icon={Link2}
214 label="Link a repository"
215 onPress={() => {
216 setMore(false);
217 setLinking(true);
218 }}
219 />
220 <SheetAction
221 icon={Copy}
222 label="Copy link"
223 onPress={() => {
224 setMore(false);
225 void Clipboard.setStringAsync(siteUrl(server, sitePath));
226 }}
227 />
228 <SheetAction
229 icon={ExternalLink}
230 label="Open on g1t"
231 onPress={() => {
232 setMore(false);
233 void WebBrowser.openBrowserAsync(siteUrl(server, sitePath));
234 }}
235 />
236 </Sheet>
237
238 <Sheet visible={picked !== null} onClose={() => setPicked(null)}>
239 {picked ? (
240 <>
241 <Text size="heading" style={{ paddingHorizontal: space.lg }}>
242 {picked.path}
243 </Text>
244 <SheetAction
245 icon={FolderGit2}
246 label="Open the repository"
247 onPress={() => {
248 setPicked(null);
249 openRepo(picked.path);
250 }}
251 />
252 {picked.role !== "origin" ? (
253 <SheetAction
254 icon={Unlink}
255 label="Unlink it"
256 danger
257 onPress={() => {
258 setPicked(null);
259 actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") });
260 }}
261 />
262 ) : null}
263 </>
264 ) : null}
265 </Sheet>
266
267 <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} />
268 </Screen>
269 );
270}
271
272/** Picks a repository of the workspace to link as a target. */
273function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) {
274 const colors = useColors();
275 const { height } = useWindowDimensions();
276 const [search, setSearch] = useState("");
277 const repos = useRepos(visible ? search.trim() : "");
278 const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase()));
279 return (
280 <Sheet visible={visible} onClose={onClose}>
281 <View style={{ paddingHorizontal: space.lg, gap: space.sm }}>
282 <Text size="heading">Link a repository</Text>
283 <Text size="small" tone="muted">
284 As a target: where the work is meant to land. It closes once it has landed in every target.
285 </Text>
286 <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}>
287 <Search size={16} color={colors.faint} />
288 <TextInput
289 value={search}
290 onChangeText={setSearch}
291 placeholder="Find a repository"
292 placeholderTextColor={colors.faint}
293 autoCapitalize="none"
294 autoCorrect={false}
295 style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }}
296 />
297 </View>
298 </View>
299 <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled">
300 {repos.isPending ? (
301 <Loading />
302 ) : choices.length === 0 ? (
303 <Text tone="muted" style={{ padding: space.lg }}>
304 {search ? "Nothing matches." : "Every repository you can see is linked already."}
305 </Text>
306 ) : (
307 choices.map((repo) => (
308 <Row
309 key={repo.id}
310 leading={<FolderGit2 size={20} color={colors.muted} />}
311 title={repo.name}
312 subtitle={`${repo.namespace}/${repo.name}`}
313 chevron={false}
314 trailing={<Plus size={18} color={colors.accent} />}
315 onPress={() => onPick(`${repo.namespace}/${repo.name}`)}
316 />
317 ))
318 )}
319 </ScrollView>
320 </Sheet>
321 );
322}
323
324const styles = StyleSheet.create({
325 content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl },
326 wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 },
327 search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md },
328});