Files
2026-09-10 23:39:56 +01:00

518 lines
17 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { setPanelTitle } from "@/lib/layout/panel-portal-manager";
import { IconCheck } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { controlSizingClassName } from "@kandev/ui/control-sizing";
import { useAppStore } from "@/components/state-provider";
import {
ChangeRequestDetail,
type ChangeRequestDetailModel,
} from "@/components/integrations/change-request-detail";
import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr";
import { prPanelLabel, prTaskKey } from "@/components/github/pr-utils";
import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback";
import { useGitHubStatus } from "@/hooks/domains/github/use-github-status";
import { getGitHubMutationActor } from "@/lib/github-auth";
import { useCommentsStore, isPRFeedbackComment } from "@/lib/state/slices/comments";
import type { PRFeedbackComment } from "@/lib/state/slices/comments";
import { useToast } from "@/components/toast-provider";
import { submitPRReview } from "@/lib/api/domains/github-pr-api";
import type { TaskPR, PRFeedback, MergeableState } from "@/lib/types/github";
import { PRMergeButton } from "./pr-merge-button";
import { PRMergeabilityNotice, buildConflictResolutionMessage } from "./pr-mergeability-notice";
import { hasActiveMergeQueueEntry, PRMergeQueueStatus } from "./pr-merge-queue-status";
import { PRWorkflowAttentionNotice } from "./pr-workflow-attention-notice";
import { usePRScopedReviewRequest } from "./use-pr-scoped-review-request";
// --- Dockview panel wrapper ---
type PRDetailPanelProps = {
panelId: string;
/** Per-PR params; multi-repo panels carry prKey="<owner>/<repo>/<pr_number>". */
params?: { prKey?: string };
};
export function PRDetailPanelComponent({ panelId, params }: PRDetailPanelProps) {
const { t } = useTranslation();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const { prs } = useTaskPR(activeTaskId);
const activePR = useActiveTaskPR();
const sessionId = useAppStore((s) => s.tasks.activeSessionId);
// Multi-repo: when the panel was opened with a prKey, render the matching
// TaskPR. Falls back to the active (primary) PR for legacy single-repo
// panels that pre-date the prKey param.
const pr = (params?.prKey ? prs.find((p) => prTaskKey(p) === params.prKey) : null) ?? activePR;
useEffect(() => {
const title = pr ? prPanelLabel(pr.pr_number) : t("task:pullRequest2");
setPanelTitle(panelId, title);
}, [pr, panelId, t]);
if (!pr || !sessionId) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
{t("github:noPullRequestLinkedToThis")}
</div>
);
}
return (
<div data-testid="pr-detail-panel" className="h-full">
<PRDetailContent taskPR={pr} sessionId={sessionId} />
</div>
);
}
// --- Add PR feedback as chat context ---
function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) {
const { t } = useTranslation();
const { toast } = useToast();
const addComment = useCommentsStore((s) => s.addComment);
const addAsContext = useCallback(
(feedbackType: PRFeedbackComment["feedbackType"], content: string) => {
const comment: PRFeedbackComment = {
id: `pr-feedback-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
sessionId,
text: content,
createdAt: new Date().toISOString(),
status: "pending",
source: "pr-feedback",
prNumber,
feedbackType,
content,
};
addComment(comment);
toast({ description: t("github:addedToChatContext") });
},
[sessionId, prNumber, addComment, toast, t],
);
return { addAsContext };
}
// The panel used to patch state / merged_at / closed_at / additions / deletions /
// mergeable_state from the feedback response straight into the Zustand taskPRs
// row (useSyncLivePRState). That write never reached github_task_prs, so opening
// this panel turned its own tab purple while the kanban card and boot payload
// kept reading state=open, and a reload snapped it back. The backend now writes
// the same live PR through SyncTaskPR during the feedback fetch and publishes
// github.task_pr.updated, which lib/ws/handlers/github.ts applies to the store —
// one writer, and it survives a reload. Do not reintroduce a client-side patch.
type PRPanelMetrics = {
reviewCount: number;
pendingReviewCount: number;
commentCount: number;
reviewState: TaskPR["review_state"];
};
function computeLiveReviewState(feedback: PRFeedback, fallbackState: TaskPR["review_state"]) {
const requestedReviewers = feedback.pr.requested_reviewers ?? [];
const reviews = feedback.reviews ?? [];
if (reviews.length === 0) {
return requestedReviewers.length > 0 ? "pending" : fallbackState || "";
}
const latestByAuthor = new Map<string, { state: string; createdAt: number }>();
for (const review of reviews) {
const current = latestByAuthor.get(review.author);
const createdAt = new Date(review.created_at).getTime();
if (!current || createdAt > current.createdAt) {
latestByAuthor.set(review.author, { state: review.state, createdAt });
}
}
let hasChangesRequested = false;
let allApproved = true;
for (const review of latestByAuthor.values()) {
if (review.state === "CHANGES_REQUESTED") hasChangesRequested = true;
if (review.state !== "APPROVED") allApproved = false;
}
if (hasChangesRequested) return "changes_requested";
if (allApproved) return "approved";
return "pending";
}
function derivePanelMetrics(taskPR: TaskPR, feedback: PRFeedback | null): PRPanelMetrics {
if (!feedback) {
return {
reviewCount: taskPR.review_count,
pendingReviewCount: taskPR.pending_review_count,
commentCount: taskPR.comment_count,
reviewState: taskPR.review_state,
};
}
const pendingReviewCount = feedback.pr.requested_reviewers?.length ?? taskPR.pending_review_count;
return {
reviewCount: (feedback.reviews ?? []).length,
pendingReviewCount,
commentCount: (feedback.comments ?? []).length,
reviewState: computeLiveReviewState(feedback, taskPR.review_state),
};
}
// GitHub logins are case-insensitive; normalize before comparing.
// Fails closed when the current user is unknown — without that identity we
// can't tell whether the viewer is the PR author, and GitHub rejects
// self-approval, so the button would only ever produce a failed request.
// Exported for unit testing.
export function shouldHideApproveButton(
taskPR: TaskPR,
feedback: PRFeedback | null,
currentUser: string | null,
hasMutationActor = !!currentUser?.trim(),
): boolean {
const liveState = feedback?.pr.state ?? taskPR.state;
if (liveState !== "open") return true;
const normalizedUser = currentUser?.trim().toLowerCase();
if (!normalizedUser) return !hasMutationActor;
const prAuthor = feedback?.pr.author_login ?? taskPR.author_login;
if (prAuthor?.trim().toLowerCase() === normalizedUser) return true;
return (
feedback?.reviews?.some(
(r) => r.state === "APPROVED" && r.author?.trim().toLowerCase() === normalizedUser,
) ?? false
);
}
export function shouldShowReRequestReviewAction(prState: string, reviewState: string): boolean {
return prState === "open" && reviewState === "DISMISSED";
}
function ApproveButton({
workspaceId,
taskPR,
feedback,
onRefresh,
}: {
workspaceId: string | null;
taskPR: TaskPR;
feedback: PRFeedback | null;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [submitting, setSubmitting] = useState(false);
// Ensures status (and thus the authenticated username) is fetched even when
// the PR panel is the first GitHub-aware surface the user opens; without
// this, currentUser is null on first render and shouldHideApproveButton has
// no identity to compare against the PR author.
const { status } = useGitHubStatus();
const currentUser = status?.username ?? null;
const mutationActor = getGitHubMutationActor(status);
if (shouldHideApproveButton(taskPR, feedback, currentUser, !!mutationActor)) return null;
const handleApprove = async () => {
if (!workspaceId) return;
setSubmitting(true);
try {
await submitPRReview(
workspaceId,
{ owner: taskPR.owner, repo: taskPR.repo, number: taskPR.pr_number },
"APPROVE",
);
toast({ description: t("github:prApproved"), variant: "success" });
onRefresh();
} catch (e) {
toast({
title: t("github:failedToApprove"),
description: e instanceof Error ? e.message : t("github:anErrorOccurred"),
variant: "error",
});
} finally {
setSubmitting(false);
}
};
return (
<Button
data-testid="pr-approve-button"
className={controlSizingClassName(
"standard",
"cursor-pointer gap-1.5 border-0 bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-500",
)}
onClick={handleApprove}
disabled={submitting}
>
<IconCheck className="h-3.5 w-3.5" />
{submitting ? t("github:approving") : t("github:approveAs", { mutationActor })}
</Button>
);
}
function githubPerson(login: string, avatarUrl?: string, isBot?: boolean) {
return {
name: login,
url: login ? `https://github.com/${login}` : undefined,
avatarUrl: avatarUrl || undefined,
isBot,
};
}
function mapGitHubIdentity(taskPR: TaskPR, feedback: PRFeedback | null) {
const live = feedback?.pr;
return {
title: live?.title ?? taskPR.pr_title,
url: live?.html_url || taskPR.pr_url,
state: live?.state ?? taskPR.state,
draft: live?.draft,
author: githubPerson(live?.author_login ?? taskPR.author_login),
description: live?.body,
};
}
function mapGitHubDates(taskPR: TaskPR, feedback: PRFeedback | null) {
const live = feedback?.pr;
return {
createdAt: live?.created_at ?? taskPR.created_at,
mergedAt: live?.merged_at ?? taskPR.merged_at ?? undefined,
closedAt: live?.closed_at ?? taskPR.closed_at ?? undefined,
};
}
function mapGitHubBranches(taskPR: TaskPR, feedback: PRFeedback | null) {
const live = feedback?.pr;
return {
sourceBranch: live?.head_branch ?? taskPR.head_branch,
targetBranch: live?.base_branch ?? taskPR.base_branch,
additions: live?.additions ?? taskPR.additions,
deletions: live?.deletions ?? taskPR.deletions,
};
}
function mapGitHubReviews(
taskPR: TaskPR,
feedback: PRFeedback | null,
requestingReviewers: string[],
labels: { reRequestReview: string; requesting: string },
) {
const requesting = new Set(requestingReviewers.map((reviewer) => reviewer.toLowerCase()));
const state = feedback?.pr.state ?? taskPR.state;
return (feedback?.reviews ?? []).map((review) => ({
id: String(review.id),
author: githubPerson(review.author, review.author_avatar),
state: review.state,
body: review.body || undefined,
createdAt: review.created_at,
actions: shouldShowReRequestReviewAction(state, review.state)
? [
{
id: "rerequest-review",
label: labels.reRequestReview,
pendingLabel: labels.requesting,
tone: "secondary" as const,
disabled: requesting.has(review.author.toLowerCase()),
busy: requesting.has(review.author.toLowerCase()),
},
]
: undefined,
}));
}
function mapGitHubChecks(feedback: PRFeedback | null) {
return (feedback?.checks ?? []).map((check) => ({
id: `${check.name}-${check.html_url}-${check.source}-${check.started_at ?? ""}`,
name: check.name,
state: check.status,
conclusion: check.conclusion,
url: check.html_url || undefined,
output: check.output,
startedAt: check.started_at ?? undefined,
completedAt: check.completed_at ?? undefined,
}));
}
export function mapGitHubComments(feedback: PRFeedback | null) {
return (feedback?.comments ?? []).map((comment) => {
const url = comment.html_url?.trim();
return {
id: String(comment.id),
parentId: comment.in_reply_to ? String(comment.in_reply_to) : undefined,
author: githubPerson(comment.author, comment.author_avatar, comment.author_is_bot),
body: comment.body,
createdAt: comment.created_at,
...(url ? { url } : {}),
path: comment.path || undefined,
line: comment.line || undefined,
};
});
}
function mapGitHubDetail(
taskPR: TaskPR,
feedback: PRFeedback | null,
metrics: PRPanelMetrics,
review: {
requestedReviewers: { login: string; type: "user" | "team" }[];
requestingReviewers: string[];
labels: { reRequestReview: string; requesting: string };
},
): ChangeRequestDetailModel {
return {
providerId: "github",
reviewKey: `${taskPR.owner}/${taskPR.repo}#${taskPR.pr_number}`,
number: taskPR.pr_number,
...mapGitHubIdentity(taskPR, feedback),
...mapGitHubDates(taskPR, feedback),
...mapGitHubBranches(taskPR, feedback),
reviewState: metrics.reviewState,
pendingReviewCount: metrics.pendingReviewCount,
reviews: mapGitHubReviews(taskPR, feedback, review.requestingReviewers, review.labels),
requestedReviewers: review.requestedReviewers.map((reviewer) => ({
...githubPerson(reviewer.login),
kind: reviewer.type,
})),
checks: mapGitHubChecks(feedback),
comments: mapGitHubComments(feedback),
lastSyncedAt: taskPR.last_synced_at ?? undefined,
};
}
function useConflictQueued(sessionId: string, prNumber: number): boolean {
return useCommentsStore((s) =>
s.pendingForChat.some((id) => {
const comment = s.byId[id];
return (
!!comment &&
isPRFeedbackComment(comment) &&
comment.feedbackType === "conflict" &&
comment.sessionId === sessionId &&
comment.prNumber === prNumber
);
}),
);
}
function PRDetailNotice({
displayPR,
attention,
mergeableState,
mergeable,
isDraft,
prState,
baseBranch,
onResolveConflicts,
resolveDisabled,
}: {
displayPR: TaskPR;
attention?: PRFeedback["workflow_attention"];
mergeableState: MergeableState | undefined;
mergeable: boolean;
isDraft: boolean;
prState: TaskPR["state"];
baseBranch: string;
onResolveConflicts: () => void;
resolveDisabled: boolean;
}) {
return (
<div className="space-y-2">
<PRWorkflowAttentionNotice pr={displayPR} attention={attention} />
{hasActiveMergeQueueEntry(displayPR) ? (
<PRMergeQueueStatus pr={displayPR} />
) : (
<PRMergeabilityNotice
state={mergeableState}
mergeable={mergeable}
isDraft={isDraft}
prState={prState}
baseBranch={baseBranch}
onResolveConflicts={onResolveConflicts}
resolveDisabled={resolveDisabled}
/>
)}
</div>
);
}
export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
const { t } = useTranslation();
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const { feedback, loading, refresh } = usePRFeedback(
workspaceId,
taskPR.owner,
taskPR.repo,
taskPR.pr_number,
);
const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number);
const { toast } = useToast();
const reviewRequest = usePRScopedReviewRequest(taskPR, {
workspaceId,
requestedReviewers: feedback?.pr.requested_reviewers ?? [],
reviews: feedback?.reviews ?? [],
refresh,
toast,
});
const metrics = derivePanelMetrics(taskPR, feedback);
const conflictQueued = useConflictQueued(sessionId, taskPR.pr_number);
const onResolveConflicts = useCallback(() => {
if (conflictQueued) return;
addAsContext(
"conflict",
buildConflictResolutionMessage({
prNumber: taskPR.pr_number,
headBranch: taskPR.head_branch,
baseBranch: taskPR.base_branch,
}),
);
}, [addAsContext, conflictQueued, taskPR.pr_number, taskPR.head_branch, taskPR.base_branch]);
const detail = mapGitHubDetail(taskPR, feedback, metrics, {
requestedReviewers: reviewRequest.requestedReviewers,
requestingReviewers: reviewRequest.requestingReviewers,
labels: {
reRequestReview: t("github:reRequestReview"),
requesting: t("github:requesting"),
},
});
const liveState = feedback?.pr.state ?? taskPR.state;
const isDraft = feedback?.pr.draft ?? false;
const isMergeable = feedback?.pr.mergeable ?? true;
const mergeableState = feedback?.pr.mergeable_state ?? taskPR.mergeable_state;
const displayPR = { ...taskPR, state: liveState };
return (
<ChangeRequestDetail
detail={detail}
loading={loading}
contentLoading={loading && !feedback}
onRefresh={refresh}
onAddContext={addAsContext}
onAction={({ actionId, targetId }) => {
if (actionId === "rerequest-review" && targetId) void reviewRequest.reRequest(targetId);
}}
headerActions={
<>
<ApproveButton
workspaceId={workspaceId}
taskPR={taskPR}
feedback={feedback}
onRefresh={refresh}
/>
<PRMergeButton taskPR={taskPR} onMerged={refresh} />
</>
}
notice={
<PRDetailNotice
displayPR={displayPR}
attention={feedback?.workflow_attention}
mergeableState={mergeableState}
mergeable={isMergeable}
isDraft={isDraft}
prState={liveState}
baseBranch={taskPR.base_branch}
onResolveConflicts={onResolveConflicts}
resolveDisabled={conflictQueued}
/>
}
/>
);
}