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