mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(findings): path-shaped file refs + per-round collapsible findings (#687)
* fix(findings): enforce path-shaped file refs; group panel findings by round - The findings chokepoint rejects a file that is not a repo-relative path shape (prose like a PR reference validated before, and the panel then rendered a doomed file-content fetch for it) — narrative belongs in evidence, the remediate says so. - The task-detail Findings tab groups findings into per-round collapsible sections (newest expanded) and only attempts a code snippet for a path-shaped file ref, so historical prose refs render as plain metadata instead of a broken loader. * fix(findings): admit client-repo path conventions; teach the file-less option - The shape gate reviews arbitrary client projects, not just this repo: plus and at-sign join the character class so SvelteKit route files, @types dirs, and @2x assets stay citable. Spaces stay excluded — they are the prose signal. - The file-rejection remediate names the file-less option for cross-cutting findings. - The client mirror notes its deliberate non-ASCII divergence from the server gate (unicode server-pass renders snippetless, fail-open). --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -44,7 +44,7 @@ describe("TabFindings", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups findings by round and renders severity/status", () => {
|
||||
it("groups findings by round, expands the newest, collapses older rounds", async () => {
|
||||
const response: TaskFindingsResponse = {
|
||||
findings: [
|
||||
{
|
||||
@@ -99,8 +99,20 @@ describe("TabFindings", () => {
|
||||
|
||||
expect(screen.getByText("Round 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Round 1")).toBeInTheDocument();
|
||||
// The newest round (2) is expanded by default — its finding is visible.
|
||||
const round2Button = screen.getByText("Round 2").closest("button");
|
||||
const round1Button = screen.getByText("Round 1").closest("button");
|
||||
expect(round2Button).toHaveAttribute("aria-expanded", "true");
|
||||
expect(round1Button).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.getByText("blocker")).toBeInTheDocument();
|
||||
expect(screen.getByText("minor")).toBeInTheDocument();
|
||||
// Round 1's finding is collapsed, so its content isn't rendered yet.
|
||||
expect(screen.queryByText("minor")).toBeNull();
|
||||
expect(screen.queryByText("abc1234")).toBeNull();
|
||||
|
||||
// Expanding round 1 reveals its finding.
|
||||
const user = userEvent.setup();
|
||||
await user.click(round1Button!);
|
||||
expect(await screen.findByText("minor")).toBeInTheDocument();
|
||||
expect(screen.getByText("abc1234")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/more not shown/)).toBeNull();
|
||||
});
|
||||
@@ -179,4 +191,75 @@ describe("TabFindings", () => {
|
||||
"Must be fixed before this task can pass review.",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the CodeSnippet fetch for a non-path file but still renders it as text", () => {
|
||||
const response: TaskFindingsResponse = {
|
||||
findings: [
|
||||
{
|
||||
id: "eeeeeeee-0000-0000-0000-000000000000",
|
||||
task_id: "t1",
|
||||
origin: "pm",
|
||||
round: 1,
|
||||
author_slug: "be-pm",
|
||||
file: "PR #676 description",
|
||||
line: null,
|
||||
severity: "major",
|
||||
criterion: null,
|
||||
expected: "x",
|
||||
actual: "y",
|
||||
fix: null,
|
||||
evidence: null,
|
||||
status: "open",
|
||||
addressed_by_commit: null,
|
||||
resolution_note: null,
|
||||
created_at: "2026-07-11T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
summary: [],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
};
|
||||
useTaskFindings.mockReturnValue({ data: response, isLoading: false });
|
||||
render(<TabFindings task={buildTask()} />);
|
||||
|
||||
// The prose file still renders as plain metadata text...
|
||||
expect(screen.getByText("PR #676 description")).toBeInTheDocument();
|
||||
// ...but never drives the doomed CodeSnippet fetch.
|
||||
expect(screen.queryByTestId("code-snippet")).toBeNull();
|
||||
});
|
||||
|
||||
it("still renders CodeSnippet for a real path-shaped file", () => {
|
||||
const response: TaskFindingsResponse = {
|
||||
findings: [
|
||||
{
|
||||
id: "ffffffff-0000-0000-0000-000000000000",
|
||||
task_id: "t1",
|
||||
origin: "qa",
|
||||
round: 1,
|
||||
author_slug: "be-qa",
|
||||
file: "roboco/services/task.py",
|
||||
line: 12,
|
||||
severity: "major",
|
||||
criterion: null,
|
||||
expected: "x",
|
||||
actual: "y",
|
||||
fix: null,
|
||||
evidence: null,
|
||||
status: "open",
|
||||
addressed_by_commit: null,
|
||||
resolution_note: null,
|
||||
created_at: "2026-07-11T00:00:00Z",
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
summary: [],
|
||||
total: 1,
|
||||
truncated: false,
|
||||
};
|
||||
useTaskFindings.mockReturnValue({ data: response, isLoading: false });
|
||||
render(<TabFindings task={buildTask()} />);
|
||||
|
||||
expect(screen.getByTestId("code-snippet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,19 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { HelpTip } from "@/components/ui/help-tip";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { CodeSnippet } from "@/components/git/code-snippet";
|
||||
import { CollapsibleSection } from "./collapsible-section";
|
||||
|
||||
// Mirrors the server-side shape gate (Finding._file_repo_relative in
|
||||
// roboco/foundation/policy/content/models.py) — keep the character classes
|
||||
// in sync, knowing they deliberately diverge on non-ASCII: Python's \w is
|
||||
// unicode, JS's is ASCII, so a unicode path the server accepted renders
|
||||
// snippetless here (fail-open — the plain-text metadata still shows). A
|
||||
// prose `file` (e.g. a PR reference) predates that gate on older ledger
|
||||
// rows and must not drive a doomed CodeSnippet fetch.
|
||||
const FILE_SHAPE_RE = /^[\w.\-/()[\]+@]+$/;
|
||||
function looksLikePath(file: string): boolean {
|
||||
return FILE_SHAPE_RE.test(file);
|
||||
}
|
||||
|
||||
interface TabFindingsProps {
|
||||
task: Task;
|
||||
@@ -43,14 +56,16 @@ const ORIGIN_LABEL: Record<string, string> = {
|
||||
// findings-ledger domain (roboco/foundation/policy/conventions/findings.py).
|
||||
const SEVERITY_DESCRIPTIONS: Record<string, string> = {
|
||||
blocker: "Must be fixed before this task can pass review.",
|
||||
major: "A significant defect; should be fixed but isn't review-blocking alone.",
|
||||
major:
|
||||
"A significant defect; should be fixed but isn't review-blocking alone.",
|
||||
minor: "A smaller defect worth fixing.",
|
||||
nit: "A nitpick — cosmetic or stylistic; fix if convenient.",
|
||||
};
|
||||
|
||||
const STATUS_DESCRIPTIONS: Record<string, string> = {
|
||||
open: "Not yet addressed by the assignee.",
|
||||
addressed: "The assignee says this is fixed — awaiting reviewer verification.",
|
||||
addressed:
|
||||
"The assignee says this is fixed — awaiting reviewer verification.",
|
||||
verified: "A reviewer confirmed the fix.",
|
||||
waived: "Explicitly waived — no fix required.",
|
||||
};
|
||||
@@ -67,7 +82,9 @@ function FindingCard({
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
|
||||
<Badge className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}>
|
||||
<Badge
|
||||
className={SEVERITY_CLASS[finding.severity] ?? SEVERITY_CLASS.nit}
|
||||
>
|
||||
{finding.severity}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
@@ -95,7 +112,7 @@ function FindingCard({
|
||||
</HelpTip>
|
||||
)}
|
||||
</div>
|
||||
{finding.file && (
|
||||
{finding.file && looksLikePath(finding.file) && (
|
||||
<CodeSnippet
|
||||
branch={branch}
|
||||
file={finding.file}
|
||||
@@ -158,8 +175,7 @@ export function TabFindings({ task }: TabFindingsProps) {
|
||||
<ListChecks className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<p>No revision findings recorded yet.</p>
|
||||
<p className="mt-2 text-sm">
|
||||
Findings appear here after the first QA / PR-review / PM / CEO
|
||||
bounce.
|
||||
Findings appear here after the first QA / PR-review / PM / CEO bounce.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -190,27 +206,43 @@ export function TabFindings({ task }: TabFindingsProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rounds.map((group) => (
|
||||
<div key={group.round} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpTip label="Each bounce back to the assignee starts a new round">
|
||||
<h3 className="text-sm font-semibold w-fit">
|
||||
Round {group.round}
|
||||
</h3>
|
||||
</HelpTip>
|
||||
<HelpTip label="Which reviewer stage raised this round's findings">
|
||||
<Badge variant="outline">
|
||||
{ORIGIN_LABEL[group.origin] ?? group.origin}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.items.map((f) => (
|
||||
<FindingCard key={f.id} finding={f} branch={task.branch_name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rounds.map((group, idx) => {
|
||||
const openCount = group.items.filter((f) => f.status === "open").length;
|
||||
return (
|
||||
<CollapsibleSection
|
||||
key={group.round}
|
||||
// Rounds arrive newest-first (see the grouping loop above) — only
|
||||
// the newest is worth seeing without a click; older rounds are
|
||||
// usually already resolved history.
|
||||
defaultOpen={idx === 0}
|
||||
title={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HelpTip label="Each bounce back to the assignee starts a new round">
|
||||
<span>Round {group.round}</span>
|
||||
</HelpTip>
|
||||
<HelpTip label="Which reviewer stage raised this round's findings">
|
||||
<Badge variant="outline">
|
||||
{ORIGIN_LABEL[group.origin] ?? group.origin}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
<HelpTip label="Total findings this round, and how many are still unaddressed">
|
||||
<Badge variant="secondary">
|
||||
{group.items.length}{" "}
|
||||
{group.items.length === 1 ? "finding" : "findings"}
|
||||
{openCount > 0 ? ` · ${openCount} open` : ""}
|
||||
</Badge>
|
||||
</HelpTip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{group.items.map((f) => (
|
||||
<FindingCard key={f.id} finding={f} branch={task.branch_name} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
})}
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
… {data.total - findings.length} more not shown ({data.total} total)
|
||||
|
||||
Reference in New Issue
Block a user