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.

154 linesCodeBlameRaw
1import { CircleCheck, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native";
2import { ScrollView, StyleSheet, View } from "react-native";
3
4import { Markdown } from "@/components/chat/markdown";
5import { Badge, Text } from "@/components/ui";
6import type { ApiChanges, ApiComment, ApiPull, CheckStatus } from "@/lib/code";
7import type { RefContext } from "@/lib/links";
8import { ago } from "@/lib/time";
9import { fonts, radius, space, useColors } from "@/theme";
10
11/** A pull request's state, as an icon in its colour. */
12export function PullIcon({ pull }: { pull: Pick<ApiPull, "status"> }) {
13 const colors = useColors();
14 if (pull.status === "merged") return <GitMerge size={20} color={colors.merged} />;
15 if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />;
16 if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />;
17 return <GitPullRequest size={20} color={colors.success} />;
18}
19
20export function Checks({ status }: { status: CheckStatus }) {
21 const colors = useColors();
22 if (status === "passed") return <CircleCheck size={16} color={colors.success} accessibilityLabel="Checks passed" />;
23 if (status === "failed" || status === "errored") return <CircleX size={16} color={colors.danger} accessibilityLabel="Checks failed" />;
24 if (status === "queued" || status === "running") return <Loader size={16} color={colors.warn} accessibilityLabel="Checks running" />;
25 return null;
26}
27
28export function StateBadge({ pull }: { pull: Pick<ApiPull, "status"> }) {
29 const tone = pull.status === "merged" ? "merged" : pull.status === "closed" ? "danger" : pull.status === "open" ? "success" : "neutral";
30 return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />;
31}
32
33/** A description in Markdown; `#123` in it is read in `refs`. */
34export function Body({ text, refs }: { text: string | null; refs: RefContext }) {
35 const colors = useColors();
36 if (!text?.trim()) {
37 return (
38 <Text tone="faint" style={{ fontStyle: "italic" }}>
39 No description.
40 </Text>
41 );
42 }
43 return (
44 <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}>
45 <Markdown text={text} refs={refs} />
46 </View>
47 );
48}
49
50/** The conversation under a work item or pull request: comments, reviews and what happened. */
51export function Timeline({ comments, refs }: { comments: ApiComment[]; refs: RefContext }) {
52 const colors = useColors();
53 if (comments.length === 0) return null;
54 return (
55 <View style={{ gap: space.md }}>
56 {comments.map((comment) =>
57 comment.kind === "event" ? (
58 <Text key={comment.id} size="small" tone="muted">
59 {comment.author.display_username ?? comment.author.username} {comment.body} · {ago(comment.created_at)}
60 </Text>
61 ) : (
62 <View key={comment.id} style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface, gap: space.xs }]}>
63 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}>
64 <Text style={{ fontWeight: "600" }}>{comment.author.display_username ?? comment.author.username}</Text>
65 {comment.author.kind === "agent" ? <Badge label="Agent" tone="accent" /> : null}
66 {comment.verdict ? <Badge label={comment.verdict.replace(/_/g, " ")} tone={comment.verdict === "approve" ? "success" : "warn"} /> : null}
67 <Text size="small" tone="faint">
68 {ago(comment.created_at)}
69 </Text>
70 </View>
71 {comment.path ? (
72 <Text size="mono" tone="muted">
73 {comment.path}
74 {comment.line ? `:${comment.line}` : ""}
75 </Text>
76 ) : null}
77 <Markdown text={comment.body} refs={refs} />
78 </View>
79 ),
80 )}
81 </View>
82 );
83}
84
85/** What a pull request changes, file by file, as unified hunks. */
86export function Diff({ changes }: { changes: ApiChanges }) {
87 const colors = useColors();
88 return (
89 <View style={{ gap: space.lg }}>
90 {changes.files.map((file) => (
91 <View key={file.path} style={[styles.file, { borderColor: colors.line }]}>
92 <View style={[styles.fileHeader, { backgroundColor: colors.raised, borderBottomColor: colors.line }]}>
93 <Text size="mono" style={{ flex: 1 }} numberOfLines={1}>
94 {file.path}
95 </Text>
96 {!file.omitted && (
97 <>
98 <Text size="mono" tone="success">
99 +{file.additions}
100 </Text>
101 <Text size="mono" tone="danger">
102 −{file.deletions}
103 </Text>
104 </>
105 )}
106 </View>
107 {file.omitted ? (
108 <Text tone="muted" size="small" style={{ padding: space.md }}>
109 Not shown here: this change is too large. Open it on the web to read this file.
110 </Text>
111 ) : file.binary ? (
112 <Text tone="muted" size="small" style={{ padding: space.md }}>
113 A binary file.
114 </Text>
115 ) : (
116 <ScrollView horizontal>
117 <View>
118 {(file.hunks ?? []).flatMap((hunk, index) =>
119 hunk.lines.map((line, at) => (
120 <Text
121 key={`${index}-${at}`}
122 style={{
123 fontFamily: fonts.mono,
124 fontSize: 12,
125 lineHeight: 18,
126 paddingHorizontal: space.sm,
127 color: line.kind === "add" ? colors.success : line.kind === "delete" ? colors.danger : colors.fgSoft,
128 backgroundColor: line.kind === "add" ? colors.successLow : "transparent",
129 }}
130 >
131 {line.kind === "add" ? "+ " : line.kind === "delete" ? "− " : " "}
132 {line.text}
133 </Text>
134 )),
135 )}
136 </View>
137 </ScrollView>
138 )}
139 </View>
140 ))}
141 {changes.truncated ? (
142 <Text tone="muted" size="small">
143 Too much changed to show here. Open it on g1t for the rest.
144 </Text>
145 ) : null}
146 </View>
147 );
148}
149
150const styles = StyleSheet.create({
151 box: { borderWidth: 1, borderRadius: radius.md, padding: space.md },
152 file: { borderWidth: 1, borderRadius: radius.md, overflow: "hidden" },
153 fileHeader: { flexDirection: "row", gap: space.sm, padding: space.sm, borderBottomWidth: StyleSheet.hairlineWidth },
154});