FN-7184: render pull request review context
Render pull-request review details in the task Review tab so PR-backed tasks show actionable context.\n\n- Preserve PR review metadata when mapping legacy review API data.\n- Show PR reviewers, checks, blocking reasons, comment state, author, path, and GitHub links.\n- Add responsive Review-tab styles and coverage for empty, partial, and populated PR review states.\n\nFiles changed:\n packages/dashboard/app/api/legacy.ts | 51 ++++++--\n .../dashboard/app/components/TaskReviewTab.css | 101 +++++++++++++++\n .../dashboard/app/components/TaskReviewTab.tsx | 94 +++++++++++++-\n .../components/__tests__/TaskReviewTab.test.tsx | 143 ++++++++++++++++++++-\n 4 files changed, 368 insertions(+), 21 deletions(-) Fusion-Task-Id: FN-7184 Fusion-Task-Lineage: 59680355-1afa-44e1-b5e1-33e26335a2c8 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -6435,24 +6435,47 @@ export function acceptTaskReview(taskId: string, projectId?: string): Promise<Ta
|
||||
}
|
||||
|
||||
function mapTaskReviewDataToLegacy(data: TaskReviewData): TaskReviewResponse {
|
||||
const fetchedAt = data.fetchedAt ?? undefined;
|
||||
const canonicalItems = data.items.map((item) => ({
|
||||
id: item.itemId,
|
||||
body: item.body,
|
||||
author: { login: item.author },
|
||||
createdAt: item.createdAt ?? new Date(0).toISOString(),
|
||||
updatedAt: item.updatedAt ?? undefined,
|
||||
path: item.filePath,
|
||||
threadId: item.threadId,
|
||||
htmlUrl: item.url,
|
||||
state: item.reviewState ?? undefined,
|
||||
summary: item.title ?? undefined,
|
||||
isResolved: item.isResolved,
|
||||
...(typeof item.line === "number" ? { line: item.line } : {}),
|
||||
}));
|
||||
|
||||
return {
|
||||
reviewState: {
|
||||
source: data.mode,
|
||||
summary: data.summary ?? undefined,
|
||||
items: data.items.map((item) => ({
|
||||
id: item.itemId,
|
||||
body: item.body,
|
||||
author: { login: item.author },
|
||||
createdAt: item.createdAt ?? new Date(0).toISOString(),
|
||||
updatedAt: item.updatedAt ?? undefined,
|
||||
path: item.filePath,
|
||||
threadId: item.threadId,
|
||||
htmlUrl: item.url,
|
||||
state: item.reviewState ?? undefined,
|
||||
isResolved: item.isResolved,
|
||||
})),
|
||||
addressing: [],
|
||||
lastRefreshedAt: data.fetchedAt ?? undefined,
|
||||
items: canonicalItems,
|
||||
addressing: data.items
|
||||
.filter((item) => item.progressStatus != null)
|
||||
.map((item) => ({
|
||||
itemId: item.itemId,
|
||||
status: item.progressStatus ?? "queued",
|
||||
selectedAt: item.createdAt ?? fetchedAt ?? new Date(0).toISOString(),
|
||||
snapshot: {
|
||||
itemId: item.itemId,
|
||||
sourceMode: item.sourceMode,
|
||||
source: item.sourceMode === "pull-request" ? "pr-review" : "reviewer-agent",
|
||||
summary: item.title || item.body.slice(0, 120),
|
||||
body: item.body,
|
||||
authorLogin: item.author,
|
||||
filePath: item.filePath,
|
||||
lineNumber: item.line,
|
||||
threadId: item.threadId,
|
||||
url: item.url,
|
||||
},
|
||||
})),
|
||||
lastRefreshedAt: fetchedAt,
|
||||
refreshStatus: "ready",
|
||||
refreshSource: "initial-load",
|
||||
},
|
||||
|
||||
@@ -345,6 +345,92 @@ The Review tab mirrors the Import-from-GitHub comment provenance model: avatar,
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:TaskReviewTab 2026-06-27-23:38:
|
||||
PR-linked tasks must expose decision-adjacent reviewers/checks/blockers plus item author/state/GitHub links without overflowing the task-detail modal. These scoped flex rows wrap using design tokens so desktop and mobile Review tabs preserve the existing hierarchy.
|
||||
*/
|
||||
.task-review-tab__pr-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-width: 0;
|
||||
padding: var(--space-md);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--surface) 72%, var(--card));
|
||||
}
|
||||
|
||||
.task-review-tab__pr-summary-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__pr-summary-label {
|
||||
color: var(--text);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.task-review-tab__pill-list,
|
||||
.task-review-tab__pr-item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__pill-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.task-review-tab__pill-list-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-review-tab__reviewer-login,
|
||||
.task-review-tab__check-name {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--text);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-review-tab__github-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-review-tab__github-link svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-review-tab__empty {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -418,6 +504,21 @@ The Review tab mirrors the Import-from-GitHub comment provenance model: avatar,
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.task-review-tab__pr-summary,
|
||||
.task-review-tab__pr-item-meta {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-review-tab__pill-list,
|
||||
.task-review-tab__pill-list-item,
|
||||
.task-review-tab__pr-item-meta {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-review-tab__github-link {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.task-review-tab__status,
|
||||
.task-review-tab__decision {
|
||||
max-width: 100%;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./TaskReviewTab.css";
|
||||
import { getErrorMessage, type Task, type TaskDetail } from "@fusion/core";
|
||||
import { getErrorMessage, type PrCheckStatus, type Task, type TaskDetail, type TaskReviewSummary } from "@fusion/core";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { Bot, GitPullRequest, User } from "lucide-react";
|
||||
import { Bot, ExternalLink, GitPullRequest, User } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fetchTaskReview, refreshTaskReview, reviseTaskReviewItems, updateTask } from "../api";
|
||||
@@ -84,6 +84,26 @@ function formatRefreshSource(source?: "manual" | "auto" | "initial-load", t?: (k
|
||||
return t?.("taskReview.refreshSourceInitialLoad", "Initial load") ?? "Initial load";
|
||||
}
|
||||
|
||||
function summarizeChecks(checks: PrCheckStatus[] | undefined, t: (key: string, defaultValue: string, options?: Record<string, unknown>) => string): string {
|
||||
const safeChecks = checks ?? [];
|
||||
if (safeChecks.length === 0) return t("taskReview.noChecksReported", "No checks reported");
|
||||
const requiredChecks = safeChecks.filter((check) => check.required).length;
|
||||
const successfulChecks = safeChecks.filter((check) => check.state === "success").length;
|
||||
const blockingChecks = safeChecks.filter((check) => check.required && check.state !== "success" && check.state !== "skipped" && check.state !== "neutral").length;
|
||||
return t("taskReview.checksSummary", "{{successful}}/{{total}} checks passing · {{required}} required · {{blocking}} blocking", {
|
||||
successful: successfulChecks,
|
||||
total: safeChecks.length,
|
||||
required: requiredChecks,
|
||||
blocking: blockingChecks,
|
||||
});
|
||||
}
|
||||
|
||||
function getCheckTone(check: PrCheckStatus): "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" {
|
||||
if (check.state === "success" || check.state === "skipped" || check.state === "neutral") return "APPROVED";
|
||||
if (check.state === "pending" || check.state === "stale") return "REVIEW_REQUIRED";
|
||||
return "CHANGES_REQUESTED";
|
||||
}
|
||||
|
||||
function getDisplayReviewItems(review: ReviewState): DisplayReviewItem[] {
|
||||
const addressingById = new Map(review.addressing.map((record) => [record.itemId, record] as const));
|
||||
const items = review.items.map((item) => {
|
||||
@@ -144,6 +164,7 @@ export function TaskReviewTab({
|
||||
const [isSavingAutoMergePreference, setIsSavingAutoMergePreference] = useState(false);
|
||||
|
||||
const isPrMode = review?.source === "pull-request";
|
||||
const prSummary = isPrMode ? review?.summary as TaskReviewSummary | undefined : undefined;
|
||||
const displayItems = useMemo(() => (review ? getDisplayReviewItems(review) : []), [review]);
|
||||
const filteredDisplayItems = useMemo(() => {
|
||||
if (authorTypeFilter === "all") return displayItems;
|
||||
@@ -298,7 +319,7 @@ export function TaskReviewTab({
|
||||
author: item.item.author?.login,
|
||||
summary: item.item.summary ?? item.item.body.slice(0, 120),
|
||||
body: item.item.body,
|
||||
url: typeof itemRecord.url === "string" ? itemRecord.url : undefined,
|
||||
url: item.item.htmlUrl ?? (typeof itemRecord.url === "string" ? itemRecord.url : undefined),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -397,6 +418,51 @@ export function TaskReviewTab({
|
||||
source: formatRefreshSource(review?.refreshSource, t),
|
||||
})}</span>
|
||||
</div>
|
||||
{/*
|
||||
FNXC:TaskReviewTab 2026-06-27-23:38:
|
||||
PR-linked tasks need Review-tab context that is already present in the GitHub review payload: decision, reviewers, checks, blockers, and per-item author/state/GitHub links. Keep this branch gated to pull-request mode so reviewer-agent reviews retain their established direct-mode layout.
|
||||
*/}
|
||||
{isPrMode ? (
|
||||
<section className="task-review-tab__pr-summary" aria-label={t("taskReview.prSummaryAria", "Pull request review summary")}>
|
||||
<div className="task-review-tab__pr-summary-section">
|
||||
<div className="task-review-tab__pr-summary-label">{t("taskReview.reviewers", "Reviewers")}</div>
|
||||
{prSummary?.reviewers?.length ? (
|
||||
<ul className="task-review-tab__pill-list" aria-label={t("taskReview.reviewers", "Reviewers")}>
|
||||
{prSummary.reviewers.map((reviewer) => (
|
||||
<li key={`${reviewer.login}-${reviewer.state}`} className="task-review-tab__pill-list-item">
|
||||
<span className="task-review-tab__reviewer-login">{reviewer.login}</span>
|
||||
<span className={`task-review-tab__decision task-review-tab__decision--${reviewer.state}`}>{reviewer.state}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="task-review-tab__meta">{t("taskReview.noReviewers", "No reviewers reported")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="task-review-tab__pr-summary-section">
|
||||
<div className="task-review-tab__pr-summary-label">{t("taskReview.checks", "Checks")}</div>
|
||||
<div className="task-review-tab__meta">{summarizeChecks(prSummary?.checks, t)}</div>
|
||||
{prSummary?.checks?.length ? (
|
||||
<ul className="task-review-tab__pill-list" aria-label={t("taskReview.checks", "Checks")}>
|
||||
{prSummary.checks.map((check) => (
|
||||
<li key={`${check.name}-${check.state}`} className="task-review-tab__pill-list-item">
|
||||
<span className="task-review-tab__check-name">{check.name}</span>
|
||||
<span className={`task-review-tab__decision task-review-tab__decision--${getCheckTone(check)}`}>{check.state}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
{prSummary?.blockingReasons?.length ? (
|
||||
<div className="task-review-tab__pr-summary-section">
|
||||
<div className="task-review-tab__pr-summary-label">{t("taskReview.blockingReasons", "Blocking reasons")}</div>
|
||||
<ul className="task-review-tab__blockers">
|
||||
{prSummary.blockingReasons.map((reason) => <li key={reason}>{reason}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{loading ? <div className="task-review-tab__meta"><LoadingSpinner label={t("taskReview.loadingData", "Loading review data…")} /></div> : null}
|
||||
{!loading && error ? <div className="task-review-tab__error">{error}</div> : null}
|
||||
{!loading && !error && !isPrMode && displayItems.length === 0 ? <div className="task-review-tab__empty">{emptyMessage ?? t("taskReview.noFeedbackDirect", "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")}</div> : null}
|
||||
@@ -409,6 +475,10 @@ export function TaskReviewTab({
|
||||
const authorType = authorInfo.authorIsBot ? "bot" : "human";
|
||||
const avatarKey = `${item.id}:${authorInfo.author}`;
|
||||
const showAvatarImg = Boolean(authorInfo.authorAvatarUrl && !brokenAvatars.has(avatarKey));
|
||||
const prAuthor = isPrMode ? item.item?.author?.login ?? item.addressing?.snapshot?.authorLogin : undefined;
|
||||
const prState = isPrMode ? item.item?.state : undefined;
|
||||
const prUrl = isPrMode ? item.item?.htmlUrl ?? item.addressing?.snapshot?.url : undefined;
|
||||
const summaryPrefix = item.path && !isPrMode ? `${item.path}: ` : "";
|
||||
|
||||
return (
|
||||
<li key={item.id} className="task-review-tab__item card" data-review-comment-author-type={authorType}>
|
||||
@@ -417,7 +487,7 @@ export function TaskReviewTab({
|
||||
<div className="task-review-tab__item-header">
|
||||
<div className="task-review-tab__item-selection">
|
||||
<input id={checkboxId} type="checkbox" checked={selected.includes(item.id)} onChange={() => toggleSelected(item.id)} />
|
||||
<span className="task-review-tab__item-summary">{item.path ? `${item.path}: ` : ""}{item.summary}</span>
|
||||
<span className="task-review-tab__item-summary">{summaryPrefix}{item.summary}</span>
|
||||
</div>
|
||||
<span className={`task-review-tab__status task-review-tab__status--${item.status}`}>{item.status}</span>
|
||||
</div>
|
||||
@@ -449,6 +519,22 @@ export function TaskReviewTab({
|
||||
<time className="task-review-tab__comment-time" dateTime={item.createdAt} title={item.createdAt}>{formatTimestamp(item.createdAt, t)}</time>
|
||||
</div>
|
||||
<div className="task-review-tab__item-meta-list">
|
||||
{isPrMode ? (
|
||||
<div className="task-review-tab__pr-item-meta" aria-label={t("taskReview.prItemMeta", "Pull request review item metadata")}>
|
||||
{prAuthor ? <span className="task-review-tab__meta">{t("taskReview.itemAuthor", "Author: {{author}}", { author: prAuthor })}</span> : null}
|
||||
{prState ? <span className={`task-review-tab__decision task-review-tab__decision--${prState}`}>{prState}</span> : null}
|
||||
<span className="task-review-tab__meta">{formatTimestamp(item.createdAt, t)}</span>
|
||||
{item.path ? <span className="task-review-tab__meta">{item.path}</span> : null}
|
||||
{prUrl ? (
|
||||
<a className="task-review-tab__github-link" href={prUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("taskReview.viewOnGitHub", "View on GitHub")}
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="task-review-tab__meta">{formatTimestamp(item.createdAt, t)}</div>
|
||||
)}
|
||||
{item.addressing ? (
|
||||
<div className="task-review-tab__meta">
|
||||
{t("taskReview.selectedAt", "Selected: {{timestamp}}", { timestamp: formatTimestamp(item.addressing.selectedAt, t) })}
|
||||
|
||||
@@ -3,7 +3,7 @@ FNXC:DashboardTests 2026-06-14-08:31:
|
||||
FN-6441 rescued this orphaned component test after standalone dashboard-app execution passed without assertion, timeout, or source-code changes. Keep it registered through the app backfill lane so task-review UI regressions cannot silently fall out of quality coverage again.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render as rtlRender, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { act, render as rtlRender, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import { TaskReviewTab } from "../TaskReviewTab";
|
||||
import { makeTask } from "./TaskDetailModal.test-helpers";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
@@ -47,6 +47,7 @@ describe("TaskReviewTab", () => {
|
||||
|
||||
await renderWithAct(<TaskReviewTab task={makeTask({ reviewState: undefined })} addToast={vi.fn()} />);
|
||||
expect(await screen.findByText("No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Pull request review summary")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -67,7 +68,7 @@ describe("TaskReviewTab", () => {
|
||||
await renderWithAct(<TaskReviewTab task={task} addToast={addToast} />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refresh" }));
|
||||
expect(apiMocks.refreshTaskReview).toHaveBeenCalledWith(task.id, undefined);
|
||||
expect(await screen.findByText("APPROVED")).toBeInTheDocument();
|
||||
expect((await screen.findAllByText("APPROVED")).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Looks good").length).toBeGreaterThan(0);
|
||||
expect(addToast).toHaveBeenCalledWith("Review refreshed", "success");
|
||||
});
|
||||
@@ -127,6 +128,139 @@ describe("TaskReviewTab", () => {
|
||||
await renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("No review items yet.")).toBeInTheDocument();
|
||||
expect(screen.getByText("No reviewers reported")).toBeInTheDocument();
|
||||
expect(screen.getByText("No checks reported")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders PR decision, reviewers, checks, blockers, and per-item GitHub metadata", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: {
|
||||
reviewDecision: "CHANGES_REQUESTED",
|
||||
reviewers: [{ login: "octocat", state: "CHANGES_REQUESTED", submittedAt: "2026-06-27T12:00:00.000Z" }],
|
||||
blockingReasons: ["1 required review requesting changes"],
|
||||
checks: [
|
||||
{ name: "lint", required: true, state: "success" },
|
||||
{ name: "test", required: true, state: "failure", detailsUrl: "https://github.example/checks/test" },
|
||||
],
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: "review-1",
|
||||
body: "Please fix the failing branch.",
|
||||
author: { login: "octocat" },
|
||||
createdAt: "2026-06-27T12:00:00.000Z",
|
||||
path: "src/parser.ts",
|
||||
summary: "Review CHANGES_REQUESTED",
|
||||
state: "CHANGES_REQUESTED",
|
||||
htmlUrl: "https://github.example/reviews/1",
|
||||
},
|
||||
{
|
||||
id: "comment-1",
|
||||
body: "Nit: rename this variable.",
|
||||
author: { login: "hubot" },
|
||||
createdAt: "2026-06-27T12:05:00.000Z",
|
||||
summary: "PR comment",
|
||||
state: "COMMENTED",
|
||||
htmlUrl: "https://github.example/comments/1",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
const { container } = await renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("CHANGES_REQUESTED · 2 review item(s)")).toBeInTheDocument();
|
||||
const summary = screen.getByLabelText("Pull request review summary");
|
||||
expect(within(summary).getByText("octocat")).toBeInTheDocument();
|
||||
expect(within(summary).getByText("1/2 checks passing · 2 required · 1 blocking")).toBeInTheDocument();
|
||||
expect(within(summary).getByText("lint")).toBeInTheDocument();
|
||||
expect(within(summary).getByText("test")).toBeInTheDocument();
|
||||
expect(within(summary).getByText("1 required review requesting changes")).toBeInTheDocument();
|
||||
expect(screen.getByText("Author: octocat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Author: hubot")).toBeInTheDocument();
|
||||
expect(screen.getByText("src/parser.ts")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("COMMENTED").length).toBeGreaterThan(0);
|
||||
const githubLinks = screen.getAllByRole("link", { name: /View on GitHub/ });
|
||||
expect(githubLinks.map((link) => link.getAttribute("href"))).toEqual(["https://github.example/reviews/1", "https://github.example/comments/1"]);
|
||||
expect(githubLinks[0]).toHaveAttribute("target", "_blank");
|
||||
expect(githubLinks[0]).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(container.querySelectorAll(".task-review-tab__summary-group .task-review-tab__decision")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["APPROVED", "APPROVED · 0 review item(s)", true],
|
||||
["CHANGES_REQUESTED", "CHANGES_REQUESTED · 0 review item(s)", true],
|
||||
["REVIEW_REQUIRED", "REVIEW_REQUIRED · 0 review item(s)", true],
|
||||
[null, "REVIEW_REQUIRED · 0 review item(s)", false],
|
||||
] as const)("renders PR decision state %s without undefined badges", async (decision, summaryText, hasDecisionBadge) => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: decision, reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
const { container } = await renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText(summaryText)).toBeInTheDocument();
|
||||
expect(screen.queryByText("undefined")).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll(".task-review-tab__summary-group .task-review-tab__decision")).toHaveLength(hasDecisionBadge ? 1 : 0);
|
||||
});
|
||||
|
||||
it("renders PR items when optional path, state, URL, or body are missing", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "REVIEW_REQUIRED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [
|
||||
{
|
||||
id: "comment-with-empty-body",
|
||||
body: "",
|
||||
author: { login: "code-review-bot" },
|
||||
createdAt: "2026-06-27T12:10:00.000Z",
|
||||
summary: "PR comment by code-review-bot",
|
||||
},
|
||||
],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
await renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText("PR comment by code-review-bot")).toBeInTheDocument();
|
||||
expect(screen.getByText("Author: code-review-bot")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link", { name: /View on GitHub/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("undefined")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles markdown/plain rendering for PR items without affecting selection", async () => {
|
||||
const task = makeTask({
|
||||
reviewState: {
|
||||
source: "pull-request",
|
||||
summary: { reviewDecision: "APPROVED", reviewers: [], blockingReasons: [], checks: [] },
|
||||
items: [{ id: "markdown-pr-item", body: "**bold PR body**", author: { login: "octocat" }, createdAt: new Date().toISOString(), summary: "PR markdown item", state: "APPROVED" }],
|
||||
addressing: [],
|
||||
},
|
||||
});
|
||||
|
||||
apiMocks.fetchTaskReview.mockResolvedValue({ reviewState: task.reviewState, automationStatus: null, emptyMessage: null });
|
||||
const { container } = await renderWithAct(<TaskReviewTab task={task} addToast={vi.fn()} />);
|
||||
|
||||
await screen.findByText("PR markdown item");
|
||||
expect(container.querySelector("strong")?.textContent).toBe("bold PR body");
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
fireEvent.click(screen.getByTestId("task-review-markdown-toggle"));
|
||||
await waitFor(() => expect(container.querySelector("pre.task-review-tab__body")?.textContent).toContain("**bold PR body**"));
|
||||
fireEvent.click(container.querySelector("pre.task-review-tab__body") as HTMLElement);
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("shows load error when initial review fetch fails", async () => {
|
||||
@@ -475,7 +609,7 @@ describe("TaskReviewTab", () => {
|
||||
summary: "Parser guard is missing",
|
||||
threadId: "thread-1",
|
||||
line: 42,
|
||||
url: "https://example.test/thread/1",
|
||||
htmlUrl: "https://example.test/thread/1",
|
||||
},
|
||||
],
|
||||
addressing: [{ itemId: "ri-1", status: "queued", selectedAt: new Date().toISOString() }],
|
||||
@@ -689,6 +823,9 @@ describe("TaskReviewTab", () => {
|
||||
expect(baseBodyRule).toMatch(/overflow-wrap\s*:\s*anywhere\s*;/);
|
||||
expect(baseBodyRule).toMatch(/overflow-x\s*:\s*auto\s*;/);
|
||||
expect(taskReviewCss).toMatch(/\.task-review-tab__item\s*\{[^}]*padding\s*:\s*var\(--card-padding\)\s*;[^}]*\}/);
|
||||
expect(taskReviewCss).toMatch(/\.task-review-tab__pr-summary\s*\{[^}]*gap\s*:\s*var\(--space-md\)\s*;[^}]*\}/);
|
||||
expect(taskReviewCss).toMatch(/\.task-review-tab__pr-item-meta\s*\{[^}]*flex-wrap\s*:\s*wrap\s*;[^}]*\}/);
|
||||
expect(mobileCss).toMatch(/\.task-review-tab__pill-list,\s*\.task-review-tab__pill-list-item,\s*\.task-review-tab__pr-item-meta\s*\{[^}]*width\s*:\s*100%\s*;[^}]*\}/);
|
||||
});
|
||||
|
||||
it("preserves review header structure across sources and empty or populated states", async () => {
|
||||
|
||||
Reference in New Issue
Block a user