mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
Adds GitHub-style review capabilities to the PR detail view, grounded in what the Buzz protocol supports: draft/ready via NIP-34 status kinds (1630/1633, same shape as `buzz pr status`), and reviewer requests + approvals as labeled kind:1 comments (t=review-request / t=approval) since NIP-34 has no dedicated review kinds. A review card in the conversation flow (above the composer) shows reviewers, review state, and the Approve / Ready-for-review / Convert-to-draft actions, gated to the PR author or repo owner per the existing trusted-actor rules. Also includes earlier UI polish from this branch: grayscale activity heatmap, icon-only view toggles, breadcrumb + tab restructure (Code first, branch picker + local/remote toggle in panel headers), viewer git-identity commit avatar matching, and StatPill label cleanup. Splits hooks.ts and ProjectRepositoryPanel.tsx to stay under the file-size guard. Mock bridge now accepts repo-scoped publishes so PR comments, approvals, and status changes work in e2e; adds project-pr-review spec.
849 lines
27 KiB
TypeScript
849 lines
27 KiB
TypeScript
import {
|
|
Check,
|
|
GitMerge,
|
|
GitPullRequest,
|
|
GitPullRequestDraft,
|
|
MessageSquare,
|
|
UserPlus,
|
|
X,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { ForumComposer } from "@/features/forum/ui/ForumComposer";
|
|
import {
|
|
type Project,
|
|
type ProjectPullRequest,
|
|
useCreateProjectPullRequestCommentMutation,
|
|
} from "@/features/projects/hooks";
|
|
import {
|
|
useApproveProjectPullRequestMutation,
|
|
useRequestProjectPullRequestReviewMutation,
|
|
useUpdateProjectPullRequestStatusMutation,
|
|
} 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 } from "@/shared/lib/pubkey";
|
|
import { Button } from "@/shared/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuTrigger,
|
|
} from "@/shared/ui/dropdown-menu";
|
|
import { Markdown } from "@/shared/ui/markdown";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|
import {
|
|
ProjectFeedRow,
|
|
ProjectFeedRowCluster,
|
|
ProjectFeedRowMonoCell,
|
|
} from "./ProjectFeedRow";
|
|
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
|
|
|
function compactDate(createdAt: number) {
|
|
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
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() ||
|
|
`${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`
|
|
);
|
|
}
|
|
|
|
function relativeOpenedAt(createdAt: number) {
|
|
const elapsedSeconds = Math.max(
|
|
1,
|
|
Math.floor(Date.now() / 1_000 - createdAt),
|
|
);
|
|
const units = [
|
|
{ label: "year", seconds: 365 * 24 * 60 * 60 },
|
|
{ label: "month", seconds: 30 * 24 * 60 * 60 },
|
|
{ label: "day", seconds: 24 * 60 * 60 },
|
|
{ label: "hour", seconds: 60 * 60 },
|
|
{ label: "minute", seconds: 60 },
|
|
];
|
|
const unit =
|
|
units.find((item) => elapsedSeconds >= item.seconds) ??
|
|
units[units.length - 1];
|
|
const value = Math.max(1, Math.floor(elapsedSeconds / unit.seconds));
|
|
return `${value} ${unit.label}${value === 1 ? "" : "s"} ago`;
|
|
}
|
|
|
|
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({
|
|
profiles,
|
|
pubkey,
|
|
role,
|
|
}: {
|
|
profiles?: UserProfileLookup;
|
|
pubkey: string;
|
|
role?: React.ReactNode;
|
|
}) {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
return (
|
|
<ProfileIdentityButton
|
|
align="center"
|
|
avatarSize="xs"
|
|
avatarUrl={profile?.avatarUrl ?? null}
|
|
isAgent={profile?.isAgent === true}
|
|
label={labelForPubkey(pubkey, profiles)}
|
|
pubkey={pubkey}
|
|
role={role}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ProjectFeedRow
|
|
meta={
|
|
<>
|
|
<ProfileIdentityButton
|
|
avatarClassName="shrink-0"
|
|
avatarSize="xs"
|
|
avatarUrl={authorProfile?.avatarUrl ?? null}
|
|
isAgent={authorProfile?.isAgent === true}
|
|
label={authorLabel}
|
|
pubkey={pullRequest.author}
|
|
showLabel={false}
|
|
/>
|
|
<span className="truncate font-medium text-foreground/80">
|
|
{authorLabel}
|
|
</span>
|
|
<span>opened {relativeOpenedAt(pullRequest.createdAt)}</span>
|
|
<span className="rounded-full border border-border/50 px-1.5 py-0.5 text-2xs">
|
|
Member
|
|
</span>
|
|
<span>·</span>
|
|
<span>{pullRequest.status}</span>
|
|
</>
|
|
}
|
|
onOpen={onOpen}
|
|
statusIcon={
|
|
<StatusIcon className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`} />
|
|
}
|
|
testId="project-pull-request-row"
|
|
title={pullRequest.title}
|
|
trailing={
|
|
<>
|
|
{pullRequest.comments.length > 0 ? (
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
{pullRequest.comments.length}
|
|
</span>
|
|
) : null}
|
|
<ProjectFeedRowCluster>
|
|
<ProjectFeedRowMonoCell
|
|
label={`#${pullRequest.id.slice(0, 8)}`}
|
|
onClick={onOpen}
|
|
title="View pull request"
|
|
/>
|
|
</ProjectFeedRowCluster>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export type PullRequestPanelMode = "conversation" | "commits" | "checks";
|
|
|
|
/** Candidate reviewers: project owner, contributors, and PR recipients —
|
|
* minus the PR author and anyone already requested. */
|
|
function reviewerCandidates(project: Project, pullRequest: ProjectPullRequest) {
|
|
const requested = new Set(pullRequest.reviewers);
|
|
const author = normalizePubkey(pullRequest.author);
|
|
return [
|
|
...new Set(
|
|
[project.owner, ...project.contributors, ...pullRequest.recipients].map(
|
|
normalizePubkey,
|
|
),
|
|
),
|
|
].filter((pubkey) => pubkey !== author && !requested.has(pubkey));
|
|
}
|
|
|
|
function PullRequestReviewersRow({
|
|
canRequest,
|
|
profiles,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
canRequest: boolean;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const requestReviewMutation =
|
|
useRequestProjectPullRequestReviewMutation(project);
|
|
const candidates = reviewerCandidates(project, pullRequest);
|
|
const approvedBy = new Set(
|
|
pullRequest.approvals.map((approval) => normalizePubkey(approval.author)),
|
|
);
|
|
|
|
const handleRequest = React.useCallback(
|
|
async (pubkey: string) => {
|
|
try {
|
|
await requestReviewMutation.mutateAsync({
|
|
pullRequest,
|
|
reviewers: [pubkey],
|
|
reviewerLabel: labelForPubkey(pubkey, profiles),
|
|
});
|
|
toast.success("Review requested.");
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to request review.",
|
|
);
|
|
}
|
|
},
|
|
[profiles, pullRequest, requestReviewMutation],
|
|
);
|
|
|
|
if (pullRequest.reviewers.length === 0 && !canRequest) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5 border-border/50 border-b px-3 py-2.5 text-xs text-muted-foreground">
|
|
<span className="font-medium">Reviewers</span>
|
|
{pullRequest.reviewers.map((pubkey) => {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
const label = labelForPubkey(pubkey, profiles);
|
|
const hasApproved = approvedBy.has(normalizePubkey(pubkey));
|
|
return (
|
|
<Tooltip key={pubkey}>
|
|
<TooltipTrigger asChild>
|
|
<span className="relative inline-flex">
|
|
<UserAvatar
|
|
accent={profile?.isAgent === true}
|
|
avatarUrl={profile?.avatarUrl ?? null}
|
|
displayName={label}
|
|
size="xs"
|
|
/>
|
|
{hasApproved ? (
|
|
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-background">
|
|
<Check className="h-2.5 w-2.5" />
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{label}
|
|
{hasApproved ? " — approved" : " — review requested"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
{canRequest && candidates.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="h-6 gap-1 rounded-full px-2 text-2xs text-muted-foreground hover:text-foreground"
|
|
disabled={requestReviewMutation.isPending}
|
|
size="xs"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<UserPlus className="h-3 w-3" />
|
|
Request
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="min-w-52">
|
|
<DropdownMenuLabel>Request a review</DropdownMenuLabel>
|
|
{candidates.map((pubkey) => {
|
|
const profile = profileForPubkey(pubkey, profiles);
|
|
const label = labelForPubkey(pubkey, profiles);
|
|
return (
|
|
<DropdownMenuItem
|
|
key={pubkey}
|
|
onSelect={() => {
|
|
void handleRequest(pubkey);
|
|
}}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
<UserAvatar
|
|
accent={profile?.isAgent === true}
|
|
avatarUrl={profile?.avatarUrl ?? null}
|
|
displayName={label}
|
|
size="xs"
|
|
/>
|
|
<span className="truncate">{label}</span>
|
|
</span>
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** GitHub-style review box rendered in the conversation flow, above the
|
|
* comment composer: reviewers on top, review state + actions below. */
|
|
function PullRequestReviewCard({
|
|
profiles,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const identityQuery = useIdentityQuery();
|
|
const statusMutation = useUpdateProjectPullRequestStatusMutation(project);
|
|
const approveMutation = useApproveProjectPullRequestMutation(project);
|
|
|
|
const viewerPubkey = identityQuery.data?.pubkey ?? null;
|
|
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
|
|
const isAuthor = viewer === normalizePubkey(pullRequest.author);
|
|
const isOwner = viewer === normalizePubkey(project.owner);
|
|
const canChangeStatus = Boolean(viewer) && (isAuthor || isOwner);
|
|
const canRequestReview = canChangeStatus;
|
|
const hasApproved = Boolean(
|
|
viewer &&
|
|
pullRequest.approvals.some(
|
|
(approval) => normalizePubkey(approval.author) === viewer,
|
|
),
|
|
);
|
|
const canApprove =
|
|
Boolean(viewer) &&
|
|
!isAuthor &&
|
|
!hasApproved &&
|
|
(pullRequest.status === "Open" || pullRequest.status === "Draft");
|
|
|
|
const handleStatusChange = React.useCallback(
|
|
async (status: "open" | "draft") => {
|
|
try {
|
|
await statusMutation.mutateAsync({ pullRequest, status });
|
|
toast.success(
|
|
status === "draft"
|
|
? "Converted to draft."
|
|
: "Marked as ready for review.",
|
|
);
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to update status.",
|
|
);
|
|
}
|
|
},
|
|
[pullRequest, statusMutation],
|
|
);
|
|
|
|
const handleApprove = React.useCallback(async () => {
|
|
try {
|
|
await approveMutation.mutateAsync({ pullRequest });
|
|
toast.success("Pull request approved.");
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to approve.",
|
|
);
|
|
}
|
|
}, [approveMutation, pullRequest]);
|
|
|
|
const approvalCount = pullRequest.approvals.length;
|
|
const isDraft = pullRequest.status === "Draft";
|
|
const reviewState = isDraft
|
|
? "This pull request is still a work in progress."
|
|
: approvalCount > 0
|
|
? `Approved by ${pluralize(approvalCount, "reviewer")}.`
|
|
: pullRequest.reviewers.length > 0
|
|
? "Review requested — no approvals yet."
|
|
: "No reviews yet.";
|
|
const reviewStateDetail = isDraft
|
|
? "Draft pull requests cannot be merged."
|
|
: approvalCount === 0
|
|
? "Approvals from reviewers will show up here."
|
|
: null;
|
|
|
|
return (
|
|
<div className="overflow-hidden rounded-lg border border-border/50 bg-background/45">
|
|
<PullRequestReviewersRow
|
|
canRequest={canRequestReview}
|
|
profiles={profiles}
|
|
project={project}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-3 p-3">
|
|
{isDraft ? (
|
|
<GitPullRequestDraft className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : approvalCount > 0 ? (
|
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-600 text-white">
|
|
<Check className="h-3 w-3" />
|
|
</span>
|
|
) : (
|
|
<GitPullRequest className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-foreground">{reviewState}</p>
|
|
{reviewStateDetail ? (
|
|
<p className="text-xs text-muted-foreground">{reviewStateDetail}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
{hasApproved ? (
|
|
<span className="inline-flex items-center gap-1.5 rounded-full border border-green-600/40 px-2.5 py-1 text-xs font-medium text-green-600 dark:text-green-500">
|
|
<Check className="h-3.5 w-3.5" />
|
|
Approved
|
|
</span>
|
|
) : null}
|
|
{canApprove ? (
|
|
<Button
|
|
className="h-7 gap-1.5 rounded-full bg-green-600 px-3 text-white hover:bg-green-700"
|
|
disabled={approveMutation.isPending}
|
|
onClick={() => {
|
|
void handleApprove();
|
|
}}
|
|
size="xs"
|
|
type="button"
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
Approve
|
|
</Button>
|
|
) : null}
|
|
{canChangeStatus && isDraft ? (
|
|
<Button
|
|
className="h-7 gap-1.5 rounded-full px-3"
|
|
disabled={statusMutation.isPending}
|
|
onClick={() => {
|
|
void handleStatusChange("open");
|
|
}}
|
|
size="xs"
|
|
type="button"
|
|
variant="secondary"
|
|
>
|
|
<GitPullRequest className="h-3.5 w-3.5" />
|
|
Ready for review
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{canChangeStatus && pullRequest.status === "Open" ? (
|
|
<div className="border-border/50 border-t px-3 py-2 text-right text-xs text-muted-foreground">
|
|
Still in progress?{" "}
|
|
<button
|
|
className="font-medium underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
|
disabled={statusMutation.isPending}
|
|
onClick={() => {
|
|
void handleStatusChange("draft");
|
|
}}
|
|
type="button"
|
|
>
|
|
Convert to draft
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** GitHub-style PR title + status line, rendered above the PR tab row. */
|
|
export function PullRequestDetailHeader({
|
|
profiles,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const authorLabel = labelForPubkey(pullRequest.author, profiles);
|
|
const targetBranch = project.defaultBranch || "default branch";
|
|
const sourceBranch = pullRequest.branchName || "unknown branch";
|
|
const commitCount = Math.max(1, pullRequest.updateCount + 1);
|
|
|
|
return (
|
|
<div className="min-w-0 space-y-2.5">
|
|
<h3 className="min-w-0 text-xl font-semibold leading-snug text-foreground">
|
|
{pullRequest.title}{" "}
|
|
<span className="font-normal text-muted-foreground">
|
|
#{pullRequest.id.slice(0, 8)}
|
|
</span>
|
|
</h3>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5 text-xs leading-4 text-muted-foreground">
|
|
<span
|
|
className={`mr-1 inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium text-white ${pullRequestStatusBadgeClassName(pullRequest.status)}`}
|
|
>
|
|
{pullRequest.status === "Merged" ? (
|
|
<GitMerge className="h-3.5 w-3.5" />
|
|
) : (
|
|
<GitPullRequest className="h-3.5 w-3.5" />
|
|
)}
|
|
{pullRequest.status}
|
|
</span>
|
|
<span className="font-medium text-foreground">{authorLabel}</span>
|
|
<span>wants to merge {pluralize(commitCount, "commit")} into</span>
|
|
<code className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-foreground">
|
|
{targetBranch}
|
|
</code>
|
|
<span>from</span>
|
|
<code className="rounded-md bg-muted px-1.5 py-0.5 text-2xs text-foreground">
|
|
{sourceBranch}
|
|
</code>
|
|
<span>·</span>
|
|
<span>opened {compactDate(pullRequest.createdAt)}</span>
|
|
<span>·</span>
|
|
<span>updated {compactDate(pullRequest.updatedAt)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PullRequestDetail({
|
|
mode,
|
|
profiles,
|
|
project,
|
|
pullRequest,
|
|
}: {
|
|
mode: PullRequestPanelMode;
|
|
profiles?: UserProfileLookup;
|
|
project: Project;
|
|
pullRequest: ProjectPullRequest;
|
|
}) {
|
|
const commentMutation = useCreateProjectPullRequestCommentMutation(project);
|
|
const members = React.useMemo(
|
|
() => pullRequestMembers(project, pullRequest, profiles),
|
|
[profiles, project, pullRequest],
|
|
);
|
|
const handleCommentSubmit = React.useCallback(
|
|
async (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
) => {
|
|
try {
|
|
await commentMutation.mutateAsync({
|
|
content,
|
|
mediaTags,
|
|
mentionPubkeys,
|
|
pullRequest,
|
|
});
|
|
toast.success("Comment posted.");
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to post comment.",
|
|
);
|
|
throw error;
|
|
}
|
|
},
|
|
[commentMutation, pullRequest],
|
|
);
|
|
|
|
if (mode === "commits") {
|
|
return (
|
|
<div className="divide-y divide-border/50">
|
|
<section className="space-y-3 p-4">
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Commits
|
|
</h4>
|
|
<article className="space-y-1">
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={pullRequest.author}
|
|
role={compactDate(pullRequest.createdAt)}
|
|
/>
|
|
{pullRequest.commit ? (
|
|
<code className="shrink-0 rounded-md bg-background/55 px-2 py-1 text-xs text-muted-foreground">
|
|
{pullRequest.commit.slice(0, 7)}
|
|
</code>
|
|
) : null}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">{pullRequest.title}</p>
|
|
</article>
|
|
{pullRequest.updates.map((update) => (
|
|
<article className="space-y-1" key={update.id}>
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={update.author}
|
|
role={compactDate(update.createdAt)}
|
|
/>
|
|
{update.commit ? (
|
|
<code className="shrink-0 rounded-md bg-background/55 px-2 py-1 text-xs text-muted-foreground">
|
|
{update.commit.slice(0, 7)}
|
|
</code>
|
|
) : null}
|
|
</div>
|
|
{update.content ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{update.content}
|
|
</p>
|
|
) : null}
|
|
</article>
|
|
))}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mode === "checks") {
|
|
return (
|
|
<div className="p-4">
|
|
<div className="rounded-lg border border-border/50 bg-background/45 p-4 text-sm text-muted-foreground">
|
|
No checks have been reported for this pull request yet.
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y divide-border/50">
|
|
{pullRequest.content ? (
|
|
<header className="p-4">
|
|
<Markdown
|
|
className="text-sm"
|
|
content={pullRequest.content}
|
|
interactive={false}
|
|
/>
|
|
</header>
|
|
) : null}
|
|
|
|
{pullRequest.updates.length > 0 ? (
|
|
<section className="space-y-3 p-4">
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
Updates
|
|
</h4>
|
|
{pullRequest.updates.map((update) => (
|
|
<article className="space-y-1" key={update.id}>
|
|
<div className="flex min-w-0 items-center justify-between gap-3">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={update.author}
|
|
role={compactDate(update.createdAt)}
|
|
/>
|
|
{update.commit ? (
|
|
<code className="shrink-0 rounded-md bg-background/55 px-2 py-1 text-xs text-muted-foreground">
|
|
{update.commit.slice(0, 7)}
|
|
</code>
|
|
) : null}
|
|
</div>
|
|
{update.content ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{update.content}
|
|
</p>
|
|
) : null}
|
|
</article>
|
|
))}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="space-y-3 p-4">
|
|
<h4 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
<MessageSquare className="h-3.5 w-3.5" />
|
|
Discussion
|
|
</h4>
|
|
{pullRequest.comments.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{pullRequest.comments.map((item) => {
|
|
// Approvals and review requests render as compact timeline
|
|
// rows (GitHub-style) rather than full comment cards.
|
|
if (item.isApproval || item.isReviewRequest) {
|
|
return (
|
|
<div
|
|
className="flex min-w-0 flex-wrap items-center gap-1.5 px-1 text-xs text-muted-foreground"
|
|
key={item.id}
|
|
>
|
|
{item.isApproval ? (
|
|
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-500" />
|
|
) : (
|
|
<UserPlus className="h-3.5 w-3.5 shrink-0" />
|
|
)}
|
|
<span className="font-medium text-foreground">
|
|
{labelForPubkey(item.author, profiles)}
|
|
</span>
|
|
<span className="min-w-0 truncate">
|
|
{item.isApproval
|
|
? "approved these changes"
|
|
: item.content.trim() || "requested a review"}
|
|
</span>
|
|
<span>· {compactDate(item.createdAt)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<article
|
|
className="rounded-lg border border-border/50 bg-background/45 p-3"
|
|
key={item.id}
|
|
>
|
|
<div className="mb-2">
|
|
<AuthorIdentity
|
|
profiles={profiles}
|
|
pubkey={item.author}
|
|
role={compactDate(item.createdAt)}
|
|
/>
|
|
</div>
|
|
<Markdown
|
|
className="text-sm"
|
|
content={item.content}
|
|
interactive={false}
|
|
/>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No comments yet.</p>
|
|
)}
|
|
<PullRequestReviewCard
|
|
profiles={profiles}
|
|
project={project}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
<ForumComposer
|
|
className="rounded-lg border border-border/50 bg-background/45"
|
|
disabled={commentMutation.isPending}
|
|
isSending={commentMutation.isPending}
|
|
members={members}
|
|
onSubmit={handleCommentSubmit}
|
|
placeholder="Add a comment…"
|
|
profiles={profiles}
|
|
/>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PullRequestsPanel({
|
|
error,
|
|
isLoading,
|
|
mode = "conversation",
|
|
onSelectedPullRequestIdChange,
|
|
profiles,
|
|
project,
|
|
pullRequests,
|
|
selectedPullRequestId,
|
|
}: {
|
|
error: unknown;
|
|
isLoading: boolean;
|
|
mode?: PullRequestPanelMode;
|
|
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 (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
Loading pull requests…
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (pullRequests.length === 0) {
|
|
return (
|
|
<p className="p-4 text-sm text-muted-foreground">
|
|
{error
|
|
? "Could not load pull requests for this repository."
|
|
: "No pull requests yet."}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
if (selectedPullRequest) {
|
|
return (
|
|
<PullRequestDetail
|
|
mode={mode}
|
|
profiles={profiles}
|
|
project={project}
|
|
pullRequest={selectedPullRequest}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="divide-y divide-border/50">
|
|
{pullRequests.map((pullRequest) => (
|
|
<PullRequestRow
|
|
key={pullRequest.id}
|
|
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
|
|
profiles={profiles}
|
|
pullRequest={pullRequest}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|