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();
|
).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 = {
|
const response: TaskFindingsResponse = {
|
||||||
findings: [
|
findings: [
|
||||||
{
|
{
|
||||||
@@ -99,8 +99,20 @@ describe("TabFindings", () => {
|
|||||||
|
|
||||||
expect(screen.getByText("Round 2")).toBeInTheDocument();
|
expect(screen.getByText("Round 2")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Round 1")).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("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.getByText("abc1234")).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/more not shown/)).toBeNull();
|
expect(screen.queryByText(/more not shown/)).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -179,4 +191,75 @@ describe("TabFindings", () => {
|
|||||||
"Must be fixed before this task can pass review.",
|
"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 { HelpTip } from "@/components/ui/help-tip";
|
||||||
import { ListChecks } from "lucide-react";
|
import { ListChecks } from "lucide-react";
|
||||||
import { CodeSnippet } from "@/components/git/code-snippet";
|
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 {
|
interface TabFindingsProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -43,14 +56,16 @@ const ORIGIN_LABEL: Record<string, string> = {
|
|||||||
// findings-ledger domain (roboco/foundation/policy/conventions/findings.py).
|
// findings-ledger domain (roboco/foundation/policy/conventions/findings.py).
|
||||||
const SEVERITY_DESCRIPTIONS: Record<string, string> = {
|
const SEVERITY_DESCRIPTIONS: Record<string, string> = {
|
||||||
blocker: "Must be fixed before this task can pass review.",
|
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.",
|
minor: "A smaller defect worth fixing.",
|
||||||
nit: "A nitpick — cosmetic or stylistic; fix if convenient.",
|
nit: "A nitpick — cosmetic or stylistic; fix if convenient.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_DESCRIPTIONS: Record<string, string> = {
|
const STATUS_DESCRIPTIONS: Record<string, string> = {
|
||||||
open: "Not yet addressed by the assignee.",
|
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.",
|
verified: "A reviewer confirmed the fix.",
|
||||||
waived: "Explicitly waived — no fix required.",
|
waived: "Explicitly waived — no fix required.",
|
||||||
};
|
};
|
||||||
@@ -67,7 +82,9 @@ function FindingCard({
|
|||||||
<CardContent className="pt-4 space-y-2">
|
<CardContent className="pt-4 space-y-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<HelpTip label={SEVERITY_DESCRIPTIONS[finding.severity]}>
|
<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}
|
{finding.severity}
|
||||||
</Badge>
|
</Badge>
|
||||||
</HelpTip>
|
</HelpTip>
|
||||||
@@ -95,7 +112,7 @@ function FindingCard({
|
|||||||
</HelpTip>
|
</HelpTip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{finding.file && (
|
{finding.file && looksLikePath(finding.file) && (
|
||||||
<CodeSnippet
|
<CodeSnippet
|
||||||
branch={branch}
|
branch={branch}
|
||||||
file={finding.file}
|
file={finding.file}
|
||||||
@@ -158,8 +175,7 @@ export function TabFindings({ task }: TabFindingsProps) {
|
|||||||
<ListChecks className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
<ListChecks className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||||
<p>No revision findings recorded yet.</p>
|
<p>No revision findings recorded yet.</p>
|
||||||
<p className="mt-2 text-sm">
|
<p className="mt-2 text-sm">
|
||||||
Findings appear here after the first QA / PR-review / PM / CEO
|
Findings appear here after the first QA / PR-review / PM / CEO bounce.
|
||||||
bounce.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -190,27 +206,43 @@ export function TabFindings({ task }: TabFindingsProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rounds.map((group) => (
|
{rounds.map((group, idx) => {
|
||||||
<div key={group.round} className="space-y-3">
|
const openCount = group.items.filter((f) => f.status === "open").length;
|
||||||
<div className="flex items-center gap-2">
|
return (
|
||||||
<HelpTip label="Each bounce back to the assignee starts a new round">
|
<CollapsibleSection
|
||||||
<h3 className="text-sm font-semibold w-fit">
|
key={group.round}
|
||||||
Round {group.round}
|
// Rounds arrive newest-first (see the grouping loop above) — only
|
||||||
</h3>
|
// the newest is worth seeing without a click; older rounds are
|
||||||
</HelpTip>
|
// usually already resolved history.
|
||||||
<HelpTip label="Which reviewer stage raised this round's findings">
|
defaultOpen={idx === 0}
|
||||||
<Badge variant="outline">
|
title={
|
||||||
{ORIGIN_LABEL[group.origin] ?? group.origin}
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</Badge>
|
<HelpTip label="Each bounce back to the assignee starts a new round">
|
||||||
</HelpTip>
|
<span>Round {group.round}</span>
|
||||||
</div>
|
</HelpTip>
|
||||||
<div className="space-y-3">
|
<HelpTip label="Which reviewer stage raised this round's findings">
|
||||||
{group.items.map((f) => (
|
<Badge variant="outline">
|
||||||
<FindingCard key={f.id} finding={f} branch={task.branch_name} />
|
{ORIGIN_LABEL[group.origin] ?? group.origin}
|
||||||
))}
|
</Badge>
|
||||||
</div>
|
</HelpTip>
|
||||||
</div>
|
<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 && (
|
{data?.truncated && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
… {data.total - findings.length} more not shown ({data.total} total)
|
… {data.total - findings.length} more not shown ({data.total} total)
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ _FINDING_CRITERION_CAP = 500
|
|||||||
# agent's own filesystem layout.
|
# agent's own filesystem layout.
|
||||||
_WINDOWS_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
_WINDOWS_ABS_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||||
|
|
||||||
|
# A finding's `file` must look like a path, not prose (a live bug: a finding
|
||||||
|
# with `file = "PR #676 description"` validated, and the panel then tried to
|
||||||
|
# fetch a git blob literally named that). Word chars/dot/hyphen/slash cover
|
||||||
|
# every ordinary path segment; parens/brackets/plus/at are additionally
|
||||||
|
# allowed because findings reference paths in ARBITRARY reviewed projects,
|
||||||
|
# not just this repo — Next.js route groups (`app/(dashboard)/page.tsx`) and
|
||||||
|
# dynamic segments (`app/[taskId]/page.tsx`) here, SvelteKit route files
|
||||||
|
# (`src/routes/+page.svelte`), `@types/` dirs, and `@2x` retina assets in
|
||||||
|
# client repos. Deliberately excludes spaces — they ARE the prose signal:
|
||||||
|
# verified via `git ls-files` that only 2 of 2270 tracked paths (both static
|
||||||
|
# `vault_assets/meta/` template notes, never a code-review target) contain
|
||||||
|
# one, while every prose example a reviewer might mistakenly pass as `file`
|
||||||
|
# ("PR #676 description", "the description in the PR") always does.
|
||||||
|
_PATH_SHAPE_RE = re.compile(r"^[\w.\-/()\[\]+@]+$")
|
||||||
|
|
||||||
|
|
||||||
class _Base(BaseModel):
|
class _Base(BaseModel):
|
||||||
"""Shared config: drop unknown keys (graceful), validate assignment."""
|
"""Shared config: drop unknown keys (graceful), validate assignment."""
|
||||||
@@ -138,6 +153,11 @@ class Finding(_Base):
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
"file must not contain '..' path segments — repo-relative only"
|
"file must not contain '..' path segments — repo-relative only"
|
||||||
)
|
)
|
||||||
|
if not _PATH_SHAPE_RE.match(v):
|
||||||
|
raise ValueError(
|
||||||
|
"file does not look like a path — put narrative/context in "
|
||||||
|
"`evidence` and reference a real `file:line` in `file`/`line`"
|
||||||
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from roboco.foundation.policy.content import Finding, Severity
|
from roboco.foundation.policy.content import (
|
||||||
|
ContentValidationError,
|
||||||
|
Finding,
|
||||||
|
Severity,
|
||||||
|
validate_findings,
|
||||||
|
)
|
||||||
from roboco.services.gateway.envelope import Envelope
|
from roboco.services.gateway.envelope import Envelope
|
||||||
from roboco.services.gateway.evidence_builder import BRIEFING_LIST_CAP
|
from roboco.services.gateway.evidence_builder import BRIEFING_LIST_CAP
|
||||||
from roboco.services.repositories.review_findings import (
|
from roboco.services.repositories.review_findings import (
|
||||||
@@ -124,6 +129,37 @@ def findings_count_hint(findings: Sequence[Any]) -> str | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_or_reject(
|
||||||
|
raw: list[dict[str, Any]],
|
||||||
|
) -> tuple[list[Finding], Envelope | None]:
|
||||||
|
"""``validate_findings``, converting a ``ContentValidationError`` into a
|
||||||
|
field-aware rejection instead of the generic one every producer
|
||||||
|
(fail_review / pr_fail) used to hardcode regardless of which field failed
|
||||||
|
— unhelpful for a rejected ``file`` in particular, since the generic
|
||||||
|
remediate says "file ... optional", which reads as though the value
|
||||||
|
simply shouldn't have been sent rather than naming where it belongs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return validate_findings(raw), None
|
||||||
|
except ContentValidationError as exc:
|
||||||
|
if exc.field == "file":
|
||||||
|
remediate = (
|
||||||
|
"`file` must be a real repo-relative path — put narrative or "
|
||||||
|
"context in `evidence` instead, and reference a real "
|
||||||
|
"`file`/`line` here (or omit `file` entirely for a "
|
||||||
|
"cross-cutting finding not tied to one file)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
remediate = (
|
||||||
|
"each finding needs expected + actual (file/line/severity/"
|
||||||
|
"criterion/fix/evidence optional)"
|
||||||
|
)
|
||||||
|
return [], Envelope.invalid_state(
|
||||||
|
message=f"malformed finding: {exc.field} — {exc.reason}",
|
||||||
|
remediate=remediate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def unmatched_criteria(task: Any, criteria: list[str]) -> list[str]:
|
def unmatched_criteria(task: Any, criteria: list[str]) -> list[str]:
|
||||||
"""``criteria`` entries matching neither an AC id nor AC text on ``task``.
|
"""``criteria`` entries matching neither an AC id nor AC text on ``task``.
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from roboco.foundation.policy.batch import is_batch_root_subtask
|
|||||||
from roboco.foundation.policy.content import (
|
from roboco.foundation.policy.content import (
|
||||||
ContentValidationError,
|
ContentValidationError,
|
||||||
markers,
|
markers,
|
||||||
validate_findings,
|
|
||||||
)
|
)
|
||||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||||
from roboco.services.gateway.choreographer.collision import build_collision_context
|
from roboco.services.gateway.choreographer.collision import build_collision_context
|
||||||
@@ -174,16 +173,9 @@ class PRGateMixin(_Base):
|
|||||||
)
|
)
|
||||||
if cap := findings_lib.findings_count_guard(raw):
|
if cap := findings_lib.findings_count_guard(raw):
|
||||||
return [], cap
|
return [], cap
|
||||||
try:
|
validated, bad = findings_lib.validate_or_reject(raw)
|
||||||
validated = validate_findings(raw)
|
if bad is not None:
|
||||||
except ContentValidationError as exc:
|
return [], bad
|
||||||
return [], Envelope.invalid_state(
|
|
||||||
message=f"malformed finding: {exc.field} — {exc.reason}",
|
|
||||||
remediate=(
|
|
||||||
"each finding needs expected + actual (file/line/severity/"
|
|
||||||
"criterion/fix/evidence optional)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if t is not None and (
|
if t is not None and (
|
||||||
unknown := findings_lib.unknown_finding_criteria(t, validated)
|
unknown := findings_lib.unknown_finding_criteria(t, validated)
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ from roboco.foundation.policy import tracing as _tr
|
|||||||
from roboco.foundation.policy.content import (
|
from roboco.foundation.policy.content import (
|
||||||
ContentValidationError,
|
ContentValidationError,
|
||||||
markers,
|
markers,
|
||||||
validate_findings,
|
|
||||||
)
|
)
|
||||||
from roboco.services.content_notes import apply_structured_note
|
from roboco.services.content_notes import apply_structured_note
|
||||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||||
@@ -893,16 +892,9 @@ class QAMixin(_Base):
|
|||||||
)
|
)
|
||||||
if cap := findings_lib.findings_count_guard(raw):
|
if cap := findings_lib.findings_count_guard(raw):
|
||||||
return [], cap
|
return [], cap
|
||||||
try:
|
validated, bad = findings_lib.validate_or_reject(raw)
|
||||||
validated = validate_findings(raw)
|
if bad is not None:
|
||||||
except ContentValidationError as exc:
|
return [], bad
|
||||||
return [], Envelope.invalid_state(
|
|
||||||
message=f"malformed finding: {exc.field} — {exc.reason}",
|
|
||||||
remediate=(
|
|
||||||
"each finding needs expected + actual (file/line/severity/"
|
|
||||||
"criterion/fix/evidence optional)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if unknown := findings_lib.unknown_finding_criteria(t, validated):
|
if unknown := findings_lib.unknown_finding_criteria(t, validated):
|
||||||
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
|
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
|
||||||
return validated, None
|
return validated, None
|
||||||
|
|||||||
@@ -346,6 +346,51 @@ def test_finding_accepts_dot_segment_and_double_dot_substring() -> None:
|
|||||||
assert ok.file == "./roboco/services/foo..bar.py"
|
assert ok.file == "./roboco/services/foo..bar.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_rejects_prose_file_with_spaces() -> None:
|
||||||
|
# Live bug: a finding's `file` carried a PR reference ("PR #676
|
||||||
|
# description") instead of a path — it validated, and the panel then
|
||||||
|
# tried (and failed) to fetch a git blob literally named that.
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Finding.model_validate(_finding(file="PR #676 description"))
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
Finding.model_validate(_finding(file="the description in the PR"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_accepts_real_nested_path() -> None:
|
||||||
|
ok = Finding.model_validate(
|
||||||
|
_finding(file="roboco/services/gateway/choreographer/findings.py")
|
||||||
|
)
|
||||||
|
assert ok.file == "roboco/services/gateway/choreographer/findings.py"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_accepts_nextjs_route_group_and_dynamic_segment_paths() -> None:
|
||||||
|
# This repo's own tracked tree uses parens (route groups) and brackets
|
||||||
|
# (dynamic segments) in real, common paths — the shape gate must not
|
||||||
|
# reject them.
|
||||||
|
ok = Finding.model_validate(
|
||||||
|
_finding(file="panel/src/app/(dashboard)/tasks/[taskId]/page.tsx")
|
||||||
|
)
|
||||||
|
assert ok.file == "panel/src/app/(dashboard)/tasks/[taskId]/page.tsx"
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_accepts_client_repo_path_conventions() -> None:
|
||||||
|
# Findings reference paths in arbitrary reviewed projects, not just this
|
||||||
|
# repo: SvelteKit route files (+), @types dirs and @2x assets (@) are
|
||||||
|
# real, common tracked paths a reviewer must be able to cite.
|
||||||
|
for path in (
|
||||||
|
"src/routes/+page.svelte",
|
||||||
|
"src/@types/foo.d.ts",
|
||||||
|
"assets/logo@2x.png",
|
||||||
|
):
|
||||||
|
ok = Finding.model_validate(_finding(file=path))
|
||||||
|
assert ok.file == path
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_file_none_bypasses_the_shape_gate() -> None:
|
||||||
|
f = Finding.model_validate(_finding(file=None))
|
||||||
|
assert f.file is None
|
||||||
|
|
||||||
|
|
||||||
def test_finding_rejects_non_positive_line() -> None:
|
def test_finding_rejects_non_positive_line() -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
Finding.model_validate(_finding(line=0))
|
Finding.model_validate(_finding(line=0))
|
||||||
|
|||||||
@@ -488,6 +488,34 @@ async def test_fail_review_requires_at_least_one_issue() -> None:
|
|||||||
assert "finding" in body["message"].lower()
|
assert "finding" in body["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fail_review_rejects_prose_file_names_evidence_in_remediate() -> None:
|
||||||
|
qa_id = uuid4()
|
||||||
|
task_id = uuid4()
|
||||||
|
t = _qa_owned_task(task_id, qa_id)
|
||||||
|
task_svc = AsyncMock()
|
||||||
|
task_svc.get.return_value = t
|
||||||
|
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
|
||||||
|
journal_svc = AsyncMock()
|
||||||
|
journal_svc.has_learning_for_task.return_value = True
|
||||||
|
deps = _make_deps(task=task_svc, journal=journal_svc)
|
||||||
|
c = Choreographer(deps)
|
||||||
|
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"file": "PR #676 description",
|
||||||
|
"severity": "major",
|
||||||
|
"expected": "matches the acceptance criteria",
|
||||||
|
"actual": "diverges from the acceptance criteria",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
env = await c.fail_review(qa_id, task_id, findings=findings)
|
||||||
|
body = env.as_dict()
|
||||||
|
assert body["error"] == "invalid_state"
|
||||||
|
assert "evidence" in body["remediate"]
|
||||||
|
assert "file" in body["remediate"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
|
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
|
||||||
qa_id = uuid4()
|
qa_id = uuid4()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from roboco.foundation.policy.content import Finding, Severity
|
from roboco.foundation.policy.content import Finding, Severity
|
||||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||||
|
from roboco.services.gateway.choreographer.pr_gate import PRGateMixin
|
||||||
|
|
||||||
|
|
||||||
def _make_choreographer() -> Choreographer:
|
def _make_choreographer() -> Choreographer:
|
||||||
@@ -194,3 +195,24 @@ def test_pr_fail_embeds_findings_and_summary_does_not_duplicate() -> None:
|
|||||||
assert "returns 500 on the timestamp branch" not in slot["summary"]
|
assert "returns 500 on the timestamp branch" not in slot["summary"]
|
||||||
# The derived TEXT mirror renders the findings table (render_markdown).
|
# The derived TEXT mirror renders the findings table (render_markdown).
|
||||||
assert "returns 500 on the timestamp branch" in t.pr_reviewer_notes
|
assert "returns 500 on the timestamp branch" in t.pr_reviewer_notes
|
||||||
|
|
||||||
|
|
||||||
|
def test_pr_fail_findings_validation_rejects_prose_file_names_evidence() -> None:
|
||||||
|
"""The static validator behind ``pr_fail`` — mirrors QA's
|
||||||
|
``fail_review`` rejection: a non-path ``file`` is refused, and the
|
||||||
|
remediate points the reviewer at ``evidence`` instead."""
|
||||||
|
findings = [
|
||||||
|
{
|
||||||
|
"file": "PR #676 description",
|
||||||
|
"severity": "major",
|
||||||
|
"expected": "matches the acceptance criteria",
|
||||||
|
"actual": "diverges from the acceptance criteria",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
validated, rejection = PRGateMixin._validate_pr_fail_findings(None, None, findings)
|
||||||
|
assert validated == []
|
||||||
|
assert rejection is not None
|
||||||
|
body = rejection.as_dict()
|
||||||
|
assert body["error"] == "invalid_state"
|
||||||
|
assert "evidence" in body["remediate"]
|
||||||
|
assert "file" in body["remediate"]
|
||||||
|
|||||||
Reference in New Issue
Block a user