1import { parsePullRequestAnchor } from "@t3tools/shared/pullRequestAnchor";
2import { usePullRequestConnected } from "./usePullRequestConnected";
3import { useAtomValue } from "@effect/atom-react";
4import { useNavigation } from "@react-navigation/native";
5import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts";
6import {
7 capitalizeTerm,
8 sourceControlClients,
9} from "@t3tools/client-runtime/source-control-clients";
10import Animated from "react-native-reanimated";
11import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations";
12import { useState } from "react";
13import { Linking, Pressable, View } from "react-native";
14import { AppText as Text } from "../../components/ui/AppText";
15import { SymbolView } from "../../components/ui/AppSymbol";
16import { Menu } from "../../components/ui/Menu";
17import { PullRequestAvatar } from "./PullRequestPresentation";
18import { PullRequestCardRow } from "./PullRequestControls";
19import { PullRequestAgentRow } from "./PullRequestAgentRow";
20import { PullRequestGlyph } from "./pullRequestGlyphs";
21import { composerPullRequests } from "../../state/pull-requests";
22import { useEnvironmentQuery } from "../../state/query";
23import {
24 allowedUpdateMethods,
25 PR_ACTION_ICONS,
26 pullRequestActionLabel,
27} from "./pullRequestActions";
28import {
29 checkStatus,
30 pullRequestBranchSummary,
31 pullRequestChecksSummary,
32 pullRequestReviewSummary,
33 pullRequestStatus,
34} from "./pullRequestStatus";
35import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
36import { usePullRequestAction } from "./usePullRequestAction";
37import { usePullRequestAgentActions } from "./usePullRequestAgentActions";
38
39type RowProps = { readonly pr: PullRequestDetail; readonly params: PullRequestParams };
40const STATUS_ACTIONS: readonly PullRequestAction[] = [
41 "ready",
42 "draft",
43 "close",
44 "reopen",
45 "revert",
46];
47const CHECK_PRIORITY = {
48 failure: 0,
49 "action-required": 1,
50 pending: 2,
51 cancelled: 3,
52 neutral: 4,
53 skipped: 5,
54 success: 6,
55};
56
57export function PullRequestStatusMenu(props: RowProps) {
58 const action = usePullRequestAction(props.params, props.pr);
59 const actions = STATUS_ACTIONS.filter(action.available);
60 const status = pullRequestStatus(props.pr);
61 const terminology = sourceControlClients.get(props.pr.provider).changeRequest;
62 const term = capitalizeTerm(terminology.singular);
63 const trigger = (
64 <Pressable
65 accessibilityRole={actions.length ? "button" : undefined}
66 accessibilityLabel={`${term} status, ${status.label}`}
67 disabled={!actions.length}
68 className="min-h-8 flex-row items-center gap-1.5 active:opacity-70"
69 >
70 <SymbolView name={status.icon} size={14} tintColorClassName={status.tint} />
71 <Text className={`text-xs font-t3-medium ${status.text}`}>{status.label}</Text>
72 {actions.length ? (
73 <SymbolView name="chevron.down" size={9} tintColorClassName="accent-icon-muted" />
74 ) : null}
75 </Pressable>
76 );
77 if (!actions.length) return trigger;
78 return (
79 <Menu
80 title={`${term} status`}
81 actions={actions.map((id) => ({
82 id,
83 title: pullRequestActionLabel(id, terminology),
84 image: PR_ACTION_ICONS[id],
85 attributes: { destructive: id === "close" },
86 }))}
87 onPressAction={({ nativeEvent }) => {
88 const selected = actions.find((id) => id === nativeEvent.event);
89 if (!selected) return;
90 if (selected === "close") action.confirmClose();
91 else if (selected === "revert")
92 action.confirm(
93 selected,
94 pullRequestActionLabel("revert", terminology),
95 `Open a new ${terminology.singular} that reverses these changes?`,
96 );
97 else void action.perform(selected);
98 }}
99 >
100 {trigger}
101 </Menu>
102 );
103}
104
105export function PullRequestChecksRow(props: RowProps) {
106 const linked = parsePullRequestAnchor(props.params.navigationUrl)?.kind === "checks";
107 const [expanded, setExpanded] = useState(linked);
108 const [seenNavigation, setSeenNavigation] = useState(props.params.navigationId);
109 if (props.params.navigationId !== seenNavigation) {
110 setSeenNavigation(props.params.navigationId);
111 if (linked) setExpanded(true);
112 }
113 const checks = pullRequestChecksSummary(props.pr);
114 const action = usePullRequestAction(props.params, props.pr);
115 const definition = sourceControlClients.get(props.pr.provider);
116 return (
117 <Animated.View layout={PR_ROW_LAYOUT}>
118 <PullRequestCardRow
119 title={checks.title}
120 icon={checks.icon}
121 tint={checks.tint}
122 value={checks.count}
123 compact
124 accessibilityLabel={`Checks, ${checks.label}`}
125 expanded={expanded}
126 onPress={() => setExpanded(!expanded)}
127 />
128 {expanded ? (
129 <Animated.View
130 layout={PR_ROW_LAYOUT}
131 entering={PR_DISCLOSURE_ENTER}
132 exiting={PR_ROW_EXIT}
133 className="pl-8"
134 >
135 {props.pr.state === "open" &&
136 props.pr.checks.some((check) => check.status === "failure") ? (
137 <PullRequestAgentRow {...props} task="feedback" title="Fix failing checks" />
138 ) : null}
139 {action.available("approve-workflows") ? (
140 <PullRequestCardRow
141 title="Approve workflows"
142 icon="checkmark"
143 detail={`${props.pr.workflowApprovalsRequired} waiting for approval`}
144 onPress={() =>
145 action.confirm(
146 "approve-workflows",
147 "Approve workflows",
148 `Allow this ${definition.changeRequest.singular}'s workflows to run? Review the workflow changes first.`,
149 )
150 }
151 />
152 ) : null}
153 {[...props.pr.checks]
154 .sort((a, b) => CHECK_PRIORITY[a.status] - CHECK_PRIORITY[b.status])
155 .map((check) => {
156 const status = checkStatus(check);
157 const url = check.url;
158 return (
159 <PullRequestCardRow
160 key={`${check.name}:${url}`}
161 title={check.name}
162 icon={status.icon}
163 tint={status.tint}
164 value={status.label}
165 detail={check.description ?? undefined}
166 onPress={url ? () => void Linking.openURL(url) : undefined}
167 />
168 );
169 })}
170 {!props.pr.checks.length ? (
171 <Text className="px-4 py-3 text-xs text-foreground-muted">No checks reported.</Text>
172 ) : null}
173 <PullRequestCardRow
174 title="Open checks on provider"
175 icon="arrow.up.right"
176 onPress={() =>
177 void Linking.openURL(
178 definition.changeRequestChecksUrl?.(props.pr.url) ?? props.pr.url,
179 )
180 }
181 />
182 </Animated.View>
183 ) : null}
184 </Animated.View>
185 );
186}
187
188export function PullRequestBranchRow(props: RowProps) {
189 const action = usePullRequestAction(props.params, props.pr);
190 const agent = usePullRequestAgentActions(props.params, props.pr);
191 const methods = action.available("update-branch") ? allowedUpdateMethods(props.pr) : [];
192 const canResolve =
193 agent.canAsk && props.pr.state === "open" && props.pr.mergeability === "conflicting";
194 const summary = action.busy ? "Updating…" : pullRequestBranchSummary(props.pr);
195 const clean = props.pr.mergeability === "mergeable";
196 const conflicting = props.pr.mergeability === "conflicting";
197 const row = (
198 <PullRequestCardRow
199 title={
200 props.pr.mergeability === "conflicting"
201 ? `Conflicts with ${props.pr.baseBranch}`
202 : clean
203 ? `No conflicts with ${props.pr.baseBranch}`
204 : "Branch"
205 }
206 icon={
207 conflicting ? PullRequestGlyph.conflicting : clean ? "checkmark.circle" : "arrow.branch"
208 }
209 tint={
210 conflicting
211 ? "accent-danger-foreground"
212 : clean
213 ? "accent-adaptive-emerald-600-400"
214 : undefined
215 }
216 value={
217 conflicting || (clean && props.pr.baseComparison !== "behind" && !action.busy)
218 ? undefined
219 : summary
220 }
221 compact
222 menu={methods.length > 0 || canResolve}
223 accessibilityLabel={`Branch, ${summary}`}
224 />
225 );
226 if (!methods.length && !canResolve) return row;
227 return (
228 <Menu
229 title="Branch"
230 actions={[
231 ...(canResolve ? [agent.menuAction("conflicts")] : []),
232 ...methods.map((id) => ({ id, title: `Update with ${id}`, image: "arrow.up.circle" })),
233 ]}
234 onPressAction={({ nativeEvent }) => {
235 const method = methods.find((id) => id === nativeEvent.event);
236 if (method) void action.perform("update-branch", method);
237 else agent.select(nativeEvent.event);
238 }}
239 >
240 {row}
241 </Menu>
242 );
243}
244
245export function PullRequestReviewersRow(props: RowProps) {
246 const [expanded, setExpanded] = useState(false);
247 const navigation = useNavigation();
248 const activity = useEnvironmentQuery(
249 composerPullRequests.activity(pullRequestTarget(props.params)),
250 );
251 const summary = pullRequestReviewSummary(props.pr, activity.data);
252 const canWrite = useAtomValue(
253 composerPullRequests.requestReviewers.permissionAtom(
254 pullRequestTarget(props.params).environmentId,
255 ),
256 );
257 const connected = usePullRequestConnected(props.params.environmentId);
258 const canRequest =
259 connected &&
260 canWrite &&
261 props.pr.state === "open" &&
262 props.pr.capabilities.reviewers.request &&
263 props.pr.viewerPermissions.requestReviewers;
264 return (
265 <Animated.View layout={PR_ROW_LAYOUT}>
266 <PullRequestCardRow
267 title={summary.title}
268 icon={summary.icon}
269 tint={summary.tint}
270 value={summary.count}
271 compact
272 expanded={expanded}
273 onPress={() => setExpanded(!expanded)}
274 />
275 {expanded ? (
276 <Animated.View
277 layout={PR_ROW_LAYOUT}
278 entering={PR_DISCLOSURE_ENTER}
279 exiting={PR_ROW_EXIT}
280 className="pl-8"
281 >
282 {summary.reviews.map((review) => (
283 <View
284 key={review.id}
285 accessibilityLabel={`${review.author?.login ?? "Unknown reviewer"}, ${review.reviewState === "APPROVED" ? "Approved" : review.reviewState === "CHANGES_REQUESTED" ? "Changes requested" : "Commented"}`}
286 className="min-h-14 flex-row items-center gap-3 px-4 py-2"
287 >
288 <SymbolView
289 name={
290 review.reviewState === "APPROVED"
291 ? "checkmark"
292 : review.reviewState === "CHANGES_REQUESTED"
293 ? "plusminus"
294 : "bubble.left"
295 }
296 size={18}
297 tintColorClassName={
298 review.reviewState === "APPROVED"
299 ? "accent-adaptive-emerald-600-400"
300 : review.reviewState === "CHANGES_REQUESTED"
301 ? "accent-danger-foreground"
302 : "accent-icon-muted"
303 }
304 />
305 <PullRequestAvatar actor={review.author} size={32} />
306 <Text className="min-w-0 flex-1 text-sm font-t3-medium" numberOfLines={1}>
307 {review.author?.login ?? "Unknown reviewer"}
308 </Text>
309 </View>
310 ))}
311 {props.pr.reviewers
312 .filter(
313 (reviewer) =>
314 !summary.reviews.some((review) => review.author?.login === reviewer.login),
315 )
316 .map((reviewer) => (
317 <View
318 key={reviewer.login}
319 accessibilityLabel={`${reviewer.login}, Review requested`}
320 className="min-h-14 flex-row items-center gap-3 px-4 py-2"
321 >
322 <SymbolView name="clock" size={18} tintColorClassName="accent-icon-muted" />
323 <PullRequestAvatar actor={reviewer} size={32} />
324 <Text className="min-w-0 flex-1 text-sm font-t3-medium" numberOfLines={1}>
325 {reviewer.login}
326 </Text>
327 </View>
328 ))}
329 {canRequest ? (
330 <PullRequestCardRow
331 title="Request review"
332 icon="person.badge.plus"
333 onPress={() => navigation.navigate("PullRequestReviewers", props.params)}
334 />
335 ) : (
336 <PullRequestCardRow
337 title="View reviewers on provider"
338 icon="arrow.up.right"
339 onPress={() => void Linking.openURL(props.pr.url)}
340 />
341 )}
342 </Animated.View>
343 ) : null}
344 </Animated.View>
345 );
346}