| 1 | import { usePullRequestConnected } from "./usePullRequestConnected"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useNavigation } from "@react-navigation/native"; |
| 4 | import type { PullRequestAction, PullRequestDetail } from "@t3tools/contracts"; |
| 5 | import Animated from "react-native-reanimated"; |
| 6 | import { PR_ROW_LAYOUT, PR_DISCLOSURE_ENTER, PR_ROW_EXIT } from "./pullRequestAnimations"; |
| 7 | import { useState } from "react"; |
| 8 | import { Linking, Pressable, View } from "react-native"; |
| 9 | import { AppText as Text } from "../../components/AppText"; |
| 10 | import { SymbolView } from "../../components/AppSymbol"; |
| 11 | import { ControlPillMenu } from "../../components/ControlPillMenu"; |
| 12 | import { PullRequestAvatar } from "./PullRequestPresentation"; |
| 13 | import { PullRequestCardRow } from "./PullRequestControls"; |
| 14 | import { PullRequestAgentRow } from "./PullRequestAgentRow"; |
| 15 | import { composerPullRequests } from "../../state/pull-requests"; |
| 16 | import { useEnvironmentQuery } from "../../state/query"; |
| 17 | import { allowedUpdateMethods, PR_ACTION_ICONS, PR_ACTION_LABELS } from "./pullRequestActions"; |
| 18 | import { |
| 19 | checkStatus, |
| 20 | pullRequestBranchSummary, |
| 21 | pullRequestChecksSummary, |
| 22 | pullRequestReviewSummary, |
| 23 | pullRequestStatus, |
| 24 | } from "./pullRequestStatus"; |
| 25 | import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; |
| 26 | import { usePullRequestAction } from "./usePullRequestAction"; |
| 27 | import { usePullRequestAgentActions } from "./usePullRequestAgentActions"; |
| 28 | |
| 29 | type RowProps = { readonly pr: PullRequestDetail; readonly params: PullRequestParams }; |
| 30 | const STATUS_ACTIONS: readonly PullRequestAction[] = [ |
| 31 | "ready", |
| 32 | "draft", |
| 33 | "close", |
| 34 | "reopen", |
| 35 | "revert", |
| 36 | ]; |
| 37 | const 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 | |
| 47 | export 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 | |
| 98 | export 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 | |
| 174 | export 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 | |
| 229 | export 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 | } |