Add finding review states, ECO export, and a report release signature.

Accepted findings become ECO rows with a required reason; false-positive and wontfix stay off the change list. Pipeline re-runs keep dispositions by finding_id like comments.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 23:21:36 +02:00
co-authored by Cursor
parent 6fc2ac583d
commit de15402309
14 changed files with 611 additions and 13 deletions
@@ -6,7 +6,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
import { Badge } from "@/components/ui/badge";
import { FindingCard } from "./finding-card";
import { StatusBadge } from "./status-badge";
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { cn, sortFindings, subtypeLabel } from "@/lib/utils";
interface ComponentGroupProps {
@@ -26,9 +26,11 @@ interface ComponentGroupProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: ComponentGroupProps) {
export function ComponentGroup({ designator, findings, component, onViewReference, findingKeys, isReviewed, onToggleReviewed, comments, projectId, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: ComponentGroupProps) {
const [open, setOpen] = useState(true);
const sorted = sortFindings(findings);
@@ -82,6 +84,8 @@ export function ComponentGroup({ designator, findings, component, onViewReferenc
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
isReported={!!(f.finding_id && reportedFindingIds?.has(f.finding_id))}
review={f.finding_id ? reviews?.[f.finding_id] : undefined}
onReviewSaved={onReviewSaved}
/>
);
})}
@@ -7,7 +7,8 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "./status-badge";
import { FindingComments } from "./finding-comments";
import type { Finding, FindingComment, Collaborator } from "@/lib/types";
import { FindingReviewControls } from "./finding-review-controls";
import type { Finding, FindingComment, FindingReview, Collaborator } from "@/lib/types";
import { cn } from "@/lib/utils";
const BORDER_COLOR: Record<string, string> = {
@@ -31,6 +32,8 @@ interface FindingCardProps {
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
defaultOpen?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingCard({
@@ -48,11 +51,16 @@ export function FindingCard({
onReportFinding,
isReported,
defaultOpen,
review,
onReviewSaved,
}: FindingCardProps) {
const [open, setOpen] = useState(defaultOpen ?? false);
const commentCount = comments?.length ?? 0;
const hasCommentSupport = !!(projectId && collaborators && onCommentAdded && onCommentDeleted);
const expandable = !!finding.recommendation || (hasCommentSupport && !!finding.finding_id);
const expandable =
!!finding.recommendation ||
(hasCommentSupport && !!finding.finding_id) ||
!!(projectId && finding.finding_id && onReviewSaved);
return (
<div
@@ -189,6 +197,15 @@ export function FindingCard({
onCommentDeleted={onCommentDeleted}
/>
)}
{projectId && finding.finding_id && onReviewSaved && (
<FindingReviewControls
projectId={projectId}
findingId={finding.finding_id}
review={review}
userName={currentUserName ?? "User"}
onSaved={onReviewSaved}
/>
)}
</div>
)}
</div>
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PdfViewerPanel } from "@/components/pdf/pdf-viewer-panel";
import { FindingCard } from "./finding-card";
import type { Finding, FindingComment, Collaborator, Component } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, Collaborator, Component } from "@/lib/types";
import { subtypeLabel } from "@/lib/utils";
interface FindingFocusViewProps {
@@ -29,6 +29,8 @@ interface FindingFocusViewProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
isReported?: boolean;
review?: FindingReview;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingFocusView({
@@ -51,6 +53,8 @@ export function FindingFocusView({
onCommentDeleted,
onReportFinding,
isReported,
review,
onReviewSaved,
}: FindingFocusViewProps) {
const backRef = useRef<HTMLButtonElement>(null);
@@ -110,6 +114,8 @@ export function FindingFocusView({
onReportFinding={onReportFinding}
isReported={isReported}
defaultOpen
review={review}
onReviewSaved={onReviewSaved}
/>
</div>
<div className="w-full lg:w-auto lg:shrink-0 lg:sticky lg:top-6 h-[70vh] lg:h-[calc(100vh-3rem)] rounded-lg border border-border bg-card overflow-hidden">
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { setFindingReview } from "@/lib/api";
import type { FindingReview, FindingReviewState } from "@/lib/types";
const STATES: { value: FindingReviewState; label: string }[] = [
{ value: "open", label: "Open" },
{ value: "accepted", label: "Accepted (ECO)" },
{ value: "false_positive", label: "False positive" },
{ value: "wontfix", label: "Won't fix" },
];
export function FindingReviewControls({
projectId,
findingId,
review,
userName,
onSaved,
}: {
projectId: string;
findingId: string;
review?: FindingReview;
userName: string;
onSaved: (findingId: string, review: FindingReview) => void;
}) {
const [state, setState] = useState<FindingReviewState>(review?.state ?? "open");
const [reason, setReason] = useState(review?.reason ?? "");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function save() {
setBusy(true);
setError(null);
try {
const saved = await setFindingReview(projectId, findingId, state, reason, userName);
onSaved(findingId, saved.state === "open" ? { ...saved, state: "open", reason: "" } : saved);
} catch (e) {
setError(e instanceof Error ? e.message : "Save failed");
} finally {
setBusy(false);
}
}
return (
<div className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 rounded-lg border border-input bg-transparent px-2 text-xs"
value={state}
onChange={(e) => setState(e.target.value as FindingReviewState)}
>
{STATES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
<Input
className="h-8 min-w-[160px] flex-1 text-xs"
placeholder={state === "open" ? "Reason optional when open" : "Reason (required)"}
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Button size="sm" variant="outline" onClick={save} disabled={busy}>
Save
</Button>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
);
}
@@ -5,7 +5,7 @@ import { useCallback, useMemo } from "react";
import { ComponentGroup } from "./component-group";
import { ReportFilters } from "./report-filters";
import { ReviewedSection } from "./reviewed-section";
import type { Finding, FindingComment, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import type { Finding, FindingComment, FindingReview, FindingStatus, DesignGraph, Collaborator } from "@/lib/types";
import { groupBy, getFindingKey } from "@/lib/utils";
interface FindingsListProps {
@@ -23,9 +23,11 @@ interface FindingsListProps {
onCommentDeleted?: (commentId: string, findingId: string) => void;
onReportFinding?: (finding: Finding) => void;
reportedFindingIds?: Set<string>;
reviews?: Record<string, FindingReview>;
onReviewSaved?: (findingId: string, review: FindingReview) => void;
}
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds }: FindingsListProps) {
export function FindingsList({ findings, graph, onViewReference, projectId, isReviewed, toggleReviewed, comments, collaborators, currentUserId, currentUserName, onCommentAdded, onCommentDeleted, onReportFinding, reportedFindingIds, reviews, onReviewSaved }: FindingsListProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
@@ -38,6 +40,7 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const statusParam = searchParams.get("status");
const componentParam = searchParams.get("component");
const reviewParam = searchParams.get("review");
const searchParam = searchParams.get("q") ?? "";
const statusFilters = useMemo(() => {
@@ -77,9 +80,13 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
const q = searchParam.toLowerCase();
if (q && !f.finding.toLowerCase().includes(q) && !(f.why ?? "").toLowerCase().includes(q))
return false;
if (reviewParam === "open") {
const st = f.finding_id ? reviews?.[f.finding_id]?.state : undefined;
if (st && st !== "open") return false;
}
return true;
},
[statusFilters, componentParam, searchParam]
[statusFilters, componentParam, searchParam, reviewParam, reviews]
);
const filtered = useMemo(() => {
@@ -120,6 +127,10 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onComponentChange={(v) => updateParams({ component: v === "all" ? null : v })}
search={searchParam}
onSearchChange={(v) => updateParams({ q: v || null })}
needsReview={reviewParam === "open"}
onToggleNeedsReview={() =>
updateParams({ review: reviewParam === "open" ? null : "open" })
}
designators={designators}
/>
<div className="space-y-1">
@@ -144,6 +155,8 @@ export function FindingsList({ findings, graph, onViewReference, projectId, isRe
onCommentDeleted={onCommentDeleted}
onReportFinding={onReportFinding}
reportedFindingIds={reportedFindingIds}
reviews={reviews}
onReviewSaved={onReviewSaved}
/>
))}
{filtered.length === 0 && reviewedFindings.length === 0 && findings.length > 0 && (
@@ -21,6 +21,8 @@ interface ReportFiltersProps {
search: string;
onSearchChange: (value: string) => void;
designators: string[];
needsReview?: boolean;
onToggleNeedsReview?: () => void;
}
const STATUSES: { key: FindingStatus; label: string; activeClass: string }[] = [
@@ -37,6 +39,8 @@ export function ReportFilters({
search,
onSearchChange,
designators,
needsReview,
onToggleNeedsReview,
}: ReportFiltersProps) {
return (
<div className="flex flex-wrap items-center gap-3">
@@ -56,6 +60,19 @@ export function ReportFilters({
</Button>
))}
</div>
{onToggleNeedsReview && (
<Button
variant="outline"
size="sm"
className={cn(
"h-8 text-xs",
needsReview && "bg-amber-500/20 text-amber-700 dark:text-amber-400 border-amber-500/40",
)}
onClick={onToggleNeedsReview}
>
Needs review
</Button>
)}
<Select value={componentFilter} onValueChange={(v) => onComponentChange(v ?? "all")}>
<SelectTrigger className="w-[140px] h-8 text-xs">