import { Check, ChevronDown, ChevronUp, FileCode2, GitBranch, GitCommitHorizontal, GitMerge, GitPullRequest, History, MessageSquare, TriangleAlert, UserPlus, X, } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ForumComposer } from "@/features/forum/ui/ForumComposer"; import { type Project, type ProjectPullRequest, type ProjectPullRequestCommentAnchor, useCreateProjectPullRequestCommentMutation, } from "@/features/projects/hooks"; import { projectPullRequestCommentTimelineKind } from "@/features/projects/projectPullRequests.mjs"; import { formatExactTimestamp, relativeTime, } from "@/features/projects/lib/projectsViewHelpers"; import { canReviewProjectPullRequest } from "@/features/projects/pullRequestReviews"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { ProjectFeedRow, ProjectFeedRowCluster, ProjectFeedRowMonoCell, } from "./ProjectFeedRow"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; import type { OpenMergeRecoveryTerminal } from "./MergePullRequestButton"; import { OverviewRailSection } from "./ProjectOverviewPanel"; import { ProfileAuthorName, ProfileIdentityButton, } from "./ProjectProfileIdentity"; import { ProjectRichContent } from "./ProjectRichContent"; import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; function profileForPubkey(pubkey: string, profiles?: UserProfileLookup) { return profiles?.[normalizePubkey(pubkey)] ?? null; } function labelForPubkey(pubkey: string, profiles?: UserProfileLookup) { const profile = profileForPubkey(pubkey, profiles); return ( profile?.displayName?.trim() || profile?.nip05Handle?.trim() || truncatePubkey(pubkey) ); } function pluralize(count: number, singular: string, plural = `${singular}s`) { return `${count} ${count === 1 ? singular : plural}`; } function pullRequestStatusClassName(status: ProjectPullRequest["status"]) { if (status === "Closed") return "text-destructive"; if (status === "Draft") return "text-muted-foreground"; if (status === "Merged") return "text-purple-400"; return "text-green-500"; } function pullRequestStatusBadgeClassName(status: ProjectPullRequest["status"]) { if (status === "Closed") return "bg-destructive"; if (status === "Draft") return "bg-muted-foreground/80"; if (status === "Merged") return "bg-purple-600"; return "bg-green-600"; } function pullRequestMembers( project: Project, pullRequest: ProjectPullRequest, profiles?: UserProfileLookup, ): ChannelMember[] { return [ ...new Set([ project.owner, pullRequest.author, ...project.contributors, ...pullRequest.recipients, ]), ].map((pubkey) => { const profile = profileForPubkey(pubkey, profiles); return { pubkey, role: "member" as const, isAgent: profile?.isAgent === true, joinedAt: new Date(0).toISOString(), displayName: profile?.displayName?.trim() || profile?.nip05Handle?.trim() || null, }; }); } function AuthorIdentity({ avatarSize = "md", profiles, pubkey, role, showLabel = true, }: { avatarSize?: "xs" | "sm" | "md"; profiles?: UserProfileLookup; pubkey: string; role?: React.ReactNode; showLabel?: boolean; }) { const profile = profileForPubkey(pubkey, profiles); return ( ); } /** Commit hash chip that jumps to the commit detail when a handler is given. */ function CommitHashChip({ hash, onOpenCommit, }: { hash: string; onOpenCommit?: (commitHash: string) => void; }) { const short = hash.slice(0, 7); if (!onOpenCommit) { return ( {short} ); } return ( ); } function PullRequestCommitRow({ author, branch, createdAt, hash, message, onOpenCommit, profiles, }: { author: string; branch: string | null; createdAt: number; hash: string | null; message: string; onOpenCommit?: (commitHash: string) => void; profiles?: UserProfileLookup; }) { const authorProfile = profileForPubkey(author, profiles); const authorLabel = labelForPubkey(author, profiles); const openCommit = hash && onOpenCommit ? () => onOpenCommit(hash) : undefined; return ( {authorLabel}{" "} authored{" "} {relativeTime(createdAt)} {branch ? ( {branch} ) : null} } onOpen={openCommit} testId="project-pull-request-commit-row" title={message} trailing={ hash ? ( ) : undefined } /> ); } function PullRequestRow({ onOpen, profiles, pullRequest, }: { onOpen: () => void; profiles?: UserProfileLookup; pullRequest: ProjectPullRequest; }) { const authorProfile = profileForPubkey(pullRequest.author, profiles); const authorLabel = labelForPubkey(pullRequest.author, profiles); const StatusIcon = pullRequest.status === "Closed" || pullRequest.status === "Draft" ? X : Check; const statusClassName = pullRequestStatusClassName(pullRequest.status); return ( {authorLabel} {" "} created this pull request{" "} {relativeTime(pullRequest.createdAt)} {pullRequest.branchName ? ( {pullRequest.branchName} ) : null} {pullRequest.status} } onOpen={onOpen} statusIcon={ } testId="project-pull-request-row" title={pullRequest.title} trailing={ <> {pullRequest.comments.length > 0 ? ( ) : null} } /> ); } export type PullRequestPanelMode = "conversation" | "commits" | "checks"; /** GitHub-style PR title line, rendered as the top section of the PR detail * card. Status, branches, and dates live in the right-hand meta rail. */ export function PullRequestDetailHeader({ profiles, pullRequest, }: { profiles?: UserProfileLookup; pullRequest: ProjectPullRequest; }) { const authorLabel = labelForPubkey(pullRequest.author, profiles); const sourceChannelId = pullRequest.channelId; const { goChannel } = useAppNavigation(); const channelsQuery = useChannelsQuery({ enabled: Boolean(sourceChannelId), }); const sourceChannel = channelsQuery.data?.find( (channel) => channel.id === sourceChannelId, ); return (

{pullRequest.title}{" "} #{pullRequest.id.slice(0, 8)}

{authorLabel} created {relativeTime(pullRequest.createdAt)} {sourceChannelId ? ( linked from {sourceChannel ? ( ) : ( an unavailable channel )} (author-claimed) ) : null}

); } /** Right-hand meta column for the PR detail view. */ export function PullRequestMetaRail({ profiles, project, pullRequest, }: { profiles?: UserProfileLookup; project: Project; pullRequest: ProjectPullRequest; }) { const identityQuery = useIdentityQuery(); const authorProfile = profileForPubkey(pullRequest.author, profiles); const authorLabel = labelForPubkey(pullRequest.author, profiles); const targetBranch = pullRequest.targetBranch || project.defaultBranch || "default branch"; const sourceBranch = pullRequest.branchName || "unknown branch"; const commitCount = Math.max(1, pullRequest.updateCount + 1); const viewerPubkey = identityQuery.data?.pubkey; const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null; const isAuthor = viewer === normalizePubkey(pullRequest.author); const isOwner = viewer === normalizePubkey(project.owner); const isManagedAgentOwner = useIsManagedAgent(project.owner) === true; const canRequestReview = Boolean(viewer) && (isAuthor || isOwner || isManagedAgentOwner); return ( ); } function PullRequestDetail({ mode, onOpenInlineComment, onOpenCommit, onOpenTerminal, profiles, project, pullRequest, }: { mode: PullRequestPanelMode; onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void; onOpenCommit?: (commitHash: string) => void; onOpenTerminal?: OpenMergeRecoveryTerminal; profiles?: UserProfileLookup; project: Project; pullRequest: ProjectPullRequest; }) { const identityQuery = useIdentityQuery(); const commentMutation = useCreateProjectPullRequestCommentMutation(project); const [ expandedReviewHistoryPullRequestIds, setExpandedReviewHistoryPullRequestIds, ] = React.useState>(() => new Set()); const [ collapsedReviewHistoryPullRequestIds, setCollapsedReviewHistoryPullRequestIds, ] = React.useState>(() => new Set()); const members = React.useMemo( () => pullRequestMembers(project, pullRequest, profiles), [profiles, project, pullRequest], ); const submitComment = React.useCallback( async ( content: string, mentionPubkeys: string[], mediaTags?: string[][], decision?: "request-changes", ) => { try { await commentMutation.mutateAsync({ content, decision, mediaTags, mentionPubkeys, pullRequest, }); toast.success( decision === "request-changes" ? "Changes requested." : "Comment posted.", ); } catch (error) { toast.error( error instanceof Error ? error.message : "Failed to post comment.", ); throw error; } }, [commentMutation, pullRequest], ); const handleCommentSubmit = React.useCallback( (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => submitComment(content, mentionPubkeys, mediaTags), [submitComment], ); const handleChangeRequestSubmit = React.useCallback( (content: string, mentionPubkeys: string[], mediaTags?: string[][]) => submitComment(content, mentionPubkeys, mediaTags, "request-changes"), [submitComment], ); if (mode === "commits") { const commitCount = Math.max(1, pullRequest.updates.length + 1); return (

Commits

{commitCount}
{pullRequest.updates.map((update) => ( ))}
); } if (mode === "checks") { return (

No checks have been reported for this pull request yet.

); } const reviewHistory = pullRequest.comments .map((item) => ({ item, timelineKind: projectPullRequestCommentTimelineKind(item), })) .sort( (left, right) => left.item.createdAt - right.item.createdAt || left.item.id.localeCompare(right.item.id), ); const reviewHistoryCollapsed = collapsedReviewHistoryPullRequestIds.has( pullRequest.id, ); const reviewHistoryExpanded = expandedReviewHistoryPullRequestIds.has( pullRequest.id, ); const earlierReviewHistoryCount = Math.max(0, reviewHistory.length - 3); const visibleReviewHistory = reviewHistoryExpanded || earlierReviewHistoryCount === 0 ? reviewHistory : reviewHistory.slice(-3); const displayedReviewHistory = reviewHistoryCollapsed ? [] : visibleReviewHistory; const canRequestChanges = canReviewProjectPullRequest( project, pullRequest, identityQuery.data?.pubkey, ); return (
{pullRequest.content ? (
) : null} {pullRequest.updates.length > 0 ? (

Updates

{pullRequest.updates.map((update) => (
{relativeTime(update.createdAt)} } /> {update.commit ? ( ) : null}
{update.content ? ( ) : null}
))}
) : null}
{reviewHistory.length > 0 ? ( ) : null} {!reviewHistoryCollapsed && earlierReviewHistoryCount > 0 && !reviewHistoryExpanded ? ( ) : null} {displayedReviewHistory.map(({ item, timelineKind }, index) => { const isHistoricalDecision = item.reviewDecisionStatus === "historical"; const trimmedContent = item.content.trim(); const activityContent = timelineKind === null ? trimmedContent : timelineKind === "changes-requested" && !/^requested changes\.?$/i.test(trimmedContent) ? trimmedContent : timelineKind === "approved" && !/^approved (these )?changes\.?$/i.test(trimmedContent) ? trimmedContent : null; return (
{index < displayedReviewHistory.length - 1 ? ( ) : ( )} {timelineKind === "approved" ? ( ) : timelineKind === "changes-requested" ? ( ) : timelineKind === "review-request" ? ( ) : ( )}
{labelForPubkey(item.author, profiles)} {timelineKind ? ( <> {" "} {timelineKind === "approved" ? isHistoricalDecision ? "approved an earlier commit" : "approved these changes" : timelineKind === "changes-requested" ? isHistoricalDecision ? "requested changes on an earlier commit" : "requested changes" : trimmedContent || "requested a review"} ) : null} {relativeTime(item.createdAt)}
{activityContent ? ( ) : null} {item.anchor ? ( ) : null}
); })}

Add Your Comment

); } export function PullRequestsPanel({ error, isLoading, mode = "conversation", onOpenInlineComment, onOpenCommit, onOpenTerminal, onSelectedPullRequestIdChange, profiles, project, pullRequests, selectedPullRequestId, }: { error: unknown; isLoading: boolean; mode?: PullRequestPanelMode; onOpenInlineComment?: (anchor: ProjectPullRequestCommentAnchor) => void; onOpenCommit?: (commitHash: string) => void; onOpenTerminal?: OpenMergeRecoveryTerminal; onSelectedPullRequestIdChange: (id: string | null) => void; profiles?: UserProfileLookup; project: Project; pullRequests: ProjectPullRequest[]; selectedPullRequestId: string | null; }) { const selectedPullRequest = pullRequests.find((item) => item.id === selectedPullRequestId) ?? null; React.useEffect(() => { if ( selectedPullRequestId && !pullRequests.some((item) => item.id === selectedPullRequestId) ) { onSelectedPullRequestIdChange(null); } }, [onSelectedPullRequestIdChange, pullRequests, selectedPullRequestId]); if (isLoading) { return (

Loading pull requests…

); } if (pullRequests.length === 0) { return (

{error ? "Could not load pull requests for this repository." : "No pull requests yet."}

); } if (selectedPullRequest) { return ( ); } return (
{pullRequests.map((pullRequest) => ( onSelectedPullRequestIdChange(pullRequest.id)} profiles={profiles} pullRequest={pullRequest} /> ))}
); }