- API: issue label bulk add, search messages, room WS push, openapi - Frontend: notify page, issue detail AI triage banner, search page, repository settings, preferences, PR components, file browser - Room: DiscordChannelSidebar, RoomPinPanel, RoomMessageActions, RoomThreadPanel, MessageContent, repository-context - Frontend SDK regenerated from openapi.json
459 lines
18 KiB
TypeScript
459 lines
18 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
reviewCommentList,
|
|
reviewList,
|
|
reviewRequestList,
|
|
reviewRequestCreate,
|
|
reviewRequestDelete,
|
|
type ReviewCommentListResponse,
|
|
type ReviewResponse,
|
|
type ReviewRequestResponse,
|
|
} from "@/client";
|
|
import { PRCommentInput } from "./PRCommentInput";
|
|
import { PRInlineComment } from "./PRInlineComment";
|
|
import { ContentRenderer } from "@/components/shared/ContentRenderer";
|
|
import { useTypingIndicator } from "@/hooks/useTypingIndicator";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Check,
|
|
GitPullRequest,
|
|
MessageSquare,
|
|
UserPlus,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
|
|
interface PRConversationProps {
|
|
namespace: string;
|
|
repoName: string;
|
|
prNumber: number;
|
|
/** Current user UID (for permission checks) */
|
|
currentUserUid?: string;
|
|
/** Whether the current user can modify comments */
|
|
canModify?: boolean;
|
|
}
|
|
|
|
const getRelativeTime = (dateString: string | null | undefined) => {
|
|
if (!dateString) return "";
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
if (diffInSeconds < 60) return "just now";
|
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
|
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
|
return `${Math.floor(diffInSeconds / 2592000)}mo ago`;
|
|
};
|
|
|
|
export function PRConversation({
|
|
namespace,
|
|
repoName,
|
|
prNumber,
|
|
canModify = false,
|
|
}: PRConversationProps) {
|
|
const queryClient = useQueryClient();
|
|
const [reviewState, setReviewState] = useState<string>("");
|
|
const [_reviewBody, setReviewBody] = useState("");
|
|
const [reviewDialogOpen, setReviewDialogOpen] = useState(false);
|
|
const [addReviewerOpen, setAddReviewerOpen] = useState(false);
|
|
const [newReviewerUid, setNewReviewerUid] = useState("");
|
|
|
|
// Typing indicator for comment input
|
|
const { typingUsers, sendTypingStart, sendTypingStop } = useTypingIndicator({});
|
|
|
|
// Fetch review comments (general comments only — no path)
|
|
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
|
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
|
|
queryFn: async () => {
|
|
const resp = await reviewCommentList({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
query: { file_only: false },
|
|
});
|
|
return resp.data?.data as ReviewCommentListResponse;
|
|
},
|
|
enabled: !!namespace && !!repoName && !!prNumber,
|
|
staleTime: 30 * 1000,
|
|
});
|
|
|
|
// Fetch reviews
|
|
const { data: reviewsData } = useQuery({
|
|
queryKey: ["pr-reviews", namespace, repoName, prNumber],
|
|
queryFn: async () => {
|
|
const resp = await reviewList({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
});
|
|
return resp.data?.data;
|
|
},
|
|
enabled: !!namespace && !!repoName && !!prNumber,
|
|
staleTime: 30 * 1000,
|
|
});
|
|
|
|
// Fetch review requests
|
|
const { data: reviewRequestsData } = useQuery({
|
|
queryKey: ["pr-review-requests", namespace, repoName, prNumber],
|
|
queryFn: async () => {
|
|
const resp = await reviewRequestList({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
});
|
|
return resp.data?.data;
|
|
},
|
|
enabled: !!namespace && !!repoName && !!prNumber,
|
|
staleTime: 30 * 1000,
|
|
});
|
|
|
|
// Create general comment
|
|
const createCommentMutation = useMutation({
|
|
mutationFn: async (body: string) => {
|
|
await reviewCommentList /* reuse endpoint */ ({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
query: {},
|
|
});
|
|
// Use reviewCommentCreate for general comments
|
|
const { reviewCommentCreate } = await import("@/client");
|
|
await reviewCommentCreate({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
body: { body, path: null, side: null, line: null, old_line: null },
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] });
|
|
toast.success("Comment posted");
|
|
},
|
|
onError: () => toast.error("Failed to post comment"),
|
|
});
|
|
|
|
// Submit review
|
|
const submitReviewMutation = useMutation({
|
|
mutationFn: async ({ state, body }: { state: string; body: string }) => {
|
|
const { reviewSubmit } = await import("@/client");
|
|
await reviewSubmit({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
body: { state, body: body || undefined },
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["pr-reviews", namespace, repoName, prNumber] });
|
|
queryClient.invalidateQueries({ queryKey: ["pr-comments-general", namespace, repoName, prNumber] });
|
|
setReviewDialogOpen(false);
|
|
setReviewState("");
|
|
setReviewBody("");
|
|
toast.success("Review submitted");
|
|
},
|
|
onError: () => toast.error("Failed to submit review"),
|
|
});
|
|
|
|
// Add reviewer
|
|
const addReviewerMutation = useMutation({
|
|
mutationFn: async (reviewerUid: string) => {
|
|
await reviewRequestCreate({
|
|
path: { namespace, repo: repoName, pr_number: prNumber },
|
|
body: { reviewer: reviewerUid },
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] });
|
|
setAddReviewerOpen(false);
|
|
setNewReviewerUid("");
|
|
toast.success("Review requested");
|
|
},
|
|
onError: () => toast.error("Failed to request review"),
|
|
});
|
|
|
|
// Remove reviewer
|
|
const removeReviewerMutation = useMutation({
|
|
mutationFn: async (reviewerUid: string) => {
|
|
await reviewRequestDelete({
|
|
path: { namespace, repo: repoName, pr_number: prNumber, reviewer: reviewerUid },
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["pr-review-requests", namespace, repoName, prNumber] });
|
|
toast.success("Review request removed");
|
|
},
|
|
onError: () => toast.error("Failed to remove review request"),
|
|
});
|
|
|
|
// Separate general comments (no path) from file comments
|
|
const comments = commentsData?.comments;
|
|
const generalComments = Array.isArray(comments) ? comments.filter(
|
|
(c) => !c.path
|
|
) : [];
|
|
|
|
const reviews = reviewsData?.reviews ?? [];
|
|
const reviewRequests = reviewRequestsData?.requests ?? [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Review requests section */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
<Users className="h-4 w-4" />
|
|
Review Requests
|
|
</h3>
|
|
<Dialog open={addReviewerOpen} onOpenChange={setAddReviewerOpen}>
|
|
<DialogTrigger className="shrink-0 justify-center border border-transparent bg-clip-padding font-medium inline-flex items-center rounded-lg text-sm whitespace-nowrap transition-all outline-none select-none h-7 gap-1 px-2.5 hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground border-input bg-transparent">
|
|
<UserPlus className="h-4 w-4 mr-1" />
|
|
Request review
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Request a review</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-2">
|
|
<div>
|
|
<Label>Reviewer User ID</Label>
|
|
<Input
|
|
placeholder="Enter user ID (UUID)"
|
|
value={newReviewerUid}
|
|
onChange={(e) => setNewReviewerUid(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setAddReviewerOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (newReviewerUid.trim()) {
|
|
addReviewerMutation.mutate(newReviewerUid.trim());
|
|
}
|
|
}}
|
|
disabled={!newReviewerUid.trim() || addReviewerMutation.isPending}
|
|
>
|
|
Request Review
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{reviewRequests.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic">No review requests.</p>
|
|
) : (
|
|
<div className="space-y-1">
|
|
{reviewRequests.map((req: ReviewRequestResponse) => (
|
|
<div
|
|
key={req.reviewer}
|
|
className="flex items-center justify-between p-2 rounded-md border bg-card"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs">
|
|
{req.reviewer_username?.[0]?.toUpperCase() ?? "?"}
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-medium">
|
|
{req.reviewer_username ?? req.reviewer}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
requested by {req.requested_by_username ?? req.requested_by}
|
|
{" · "}
|
|
{getRelativeTime(req.requested_at)}
|
|
</span>
|
|
</div>
|
|
{req.dismissed_at && (
|
|
<span className="text-xs bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
|
dismissed
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-red-600"
|
|
onClick={() => removeReviewerMutation.mutate(req.reviewer)}
|
|
disabled={removeReviewerMutation.isPending}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reviews section */}
|
|
{reviews.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
|
|
<GitPullRequest className="h-4 w-4" />
|
|
Reviews
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{reviews.map((review: ReviewResponse) => (
|
|
<div key={review.reviewer} className="p-3 border rounded-md bg-card">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs">
|
|
{review.reviewer_username?.[0]?.toUpperCase() ?? "?"}
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{review.reviewer_username ?? review.reviewer}
|
|
</span>
|
|
<span
|
|
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
|
|
review.state === "approved"
|
|
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
: review.state === "changes_requested"
|
|
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
: "bg-muted text-muted-foreground"
|
|
}`}
|
|
>
|
|
{review.state}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{getRelativeTime(review.submitted_at ?? review.created_at)}
|
|
</span>
|
|
</div>
|
|
{review.body && (
|
|
<ContentRenderer content={review.body} className="text-sm text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* General comments section */}
|
|
<div>
|
|
<h3 className="text-sm font-semibold flex items-center gap-2 mb-3">
|
|
<MessageSquare className="h-4 w-4" />
|
|
Comments ({generalComments.length})
|
|
</h3>
|
|
|
|
{/* Add general comment */}
|
|
<div className="mb-4">
|
|
<PRCommentInput
|
|
placeholder="Leave a comment..."
|
|
buttonLabel="Comment"
|
|
onSubmit={(body) => createCommentMutation.mutate(body)}
|
|
onTypingStart={sendTypingStart}
|
|
onTypingStop={sendTypingStop}
|
|
/>
|
|
{typingUsers.length > 0 && (
|
|
<div className="mt-1.5 flex items-center gap-1.5 animate-in fade-in slide-in-from-top-1 duration-200">
|
|
<div className="flex -space-x-1.5">
|
|
{typingUsers.slice(0, 3).map((u) => (
|
|
<Avatar key={u.userId} className="h-5 w-5 border border-background">
|
|
{u.avatarUrl ? (
|
|
<img src={u.avatarUrl} alt={u.username} className="h-5 w-5 rounded-full object-cover" />
|
|
) : (
|
|
<AvatarFallback className="text-[10px]">{u.username[0]?.toUpperCase()}</AvatarFallback>
|
|
)}
|
|
</Avatar>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{typingUsers.length === 1
|
|
? `${typingUsers[0].username} is typing…`
|
|
: typingUsers.length === 2
|
|
? `${typingUsers[0].username} and ${typingUsers[1].username} are typing…`
|
|
: `${typingUsers.length} people are typing…`}
|
|
</span>
|
|
<span className="flex gap-0.5 ml-1">
|
|
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
<span className="h-1 w-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Comment list */}
|
|
{commentsLoading ? (
|
|
<p className="text-sm text-muted-foreground">Loading comments...</p>
|
|
) : generalComments.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground italic">
|
|
No comments yet.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{generalComments.map((comment) => (
|
|
<PRInlineComment
|
|
key={comment.id}
|
|
comment={comment}
|
|
namespace={namespace}
|
|
repoName={repoName}
|
|
prNumber={prNumber}
|
|
canModify={canModify}
|
|
onRefresh={() =>
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["pr-comments-general", namespace, repoName, prNumber],
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit review button */}
|
|
<div className="border-t pt-4">
|
|
<Dialog open={reviewDialogOpen} onOpenChange={setReviewDialogOpen}>
|
|
<DialogTrigger className="group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none h-8 gap-1.5 px-2.5 hover:bg-primary/80 bg-primary text-primary-foreground">
|
|
Submit Review
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Submit Review</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-2">
|
|
{/* Review state buttons */}
|
|
<div className="flex gap-2">
|
|
{[
|
|
{ value: "approved", label: "Approve", icon: Check, color: "text-green-600" },
|
|
{ value: "changes_requested", label: "Request changes", icon: X, color: "text-red-600" },
|
|
{ value: "comment", label: "Comment only", icon: MessageSquare, color: "text-muted-foreground" },
|
|
].map(({ value, label, icon: Icon, color }) => (
|
|
<Button
|
|
key={value}
|
|
type="button"
|
|
variant={reviewState === value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setReviewState(value)}
|
|
className={`flex-1 ${reviewState === value ? "" : color}`}
|
|
>
|
|
<Icon className="h-4 w-4 mr-1" />
|
|
{label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
{/* Review body */}
|
|
<PRCommentInput
|
|
placeholder="Leave a comment about this pull request..."
|
|
buttonLabel="Submit Review"
|
|
onSubmit={async (body) => {
|
|
await submitReviewMutation.mutateAsync({
|
|
state: reviewState || "comment",
|
|
body,
|
|
});
|
|
}}
|
|
onCancel={() => setReviewDialogOpen(false)}
|
|
loading={submitReviewMutation.isPending}
|
|
showPreviewToggle={false}
|
|
minRows={4}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|