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)
|
||||
|
||||
@@ -55,6 +55,21 @@ _FINDING_CRITERION_CAP = 500
|
||||
# agent's own filesystem layout.
|
||||
_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):
|
||||
"""Shared config: drop unknown keys (graceful), validate assignment."""
|
||||
@@ -138,6 +153,11 @@ class Finding(_Base):
|
||||
raise ValueError(
|
||||
"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
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,12 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
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.evidence_builder import BRIEFING_LIST_CAP
|
||||
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]:
|
||||
"""``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 (
|
||||
ContentValidationError,
|
||||
markers,
|
||||
validate_findings,
|
||||
)
|
||||
from roboco.services.gateway.choreographer import findings as findings_lib
|
||||
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):
|
||||
return [], cap
|
||||
try:
|
||||
validated = validate_findings(raw)
|
||||
except ContentValidationError as exc:
|
||||
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)"
|
||||
),
|
||||
)
|
||||
validated, bad = findings_lib.validate_or_reject(raw)
|
||||
if bad is not None:
|
||||
return [], bad
|
||||
if t is not None and (
|
||||
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 (
|
||||
ContentValidationError,
|
||||
markers,
|
||||
validate_findings,
|
||||
)
|
||||
from roboco.services.content_notes import apply_structured_note
|
||||
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):
|
||||
return [], cap
|
||||
try:
|
||||
validated = validate_findings(raw)
|
||||
except ContentValidationError as exc:
|
||||
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)"
|
||||
),
|
||||
)
|
||||
validated, bad = findings_lib.validate_or_reject(raw)
|
||||
if bad is not None:
|
||||
return [], bad
|
||||
if unknown := findings_lib.unknown_finding_criteria(t, validated):
|
||||
return [], findings_lib.criterion_mismatch_rejection(t, unknown)
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
with pytest.raises(ValidationError):
|
||||
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()
|
||||
|
||||
|
||||
@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
|
||||
async def test_fail_review_not_assigned_returns_not_authorized() -> None:
|
||||
qa_id = uuid4()
|
||||
|
||||
@@ -17,6 +17,7 @@ from uuid import uuid4
|
||||
|
||||
from roboco.foundation.policy.content import Finding, Severity
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
from roboco.services.gateway.choreographer.pr_gate import PRGateMixin
|
||||
|
||||
|
||||
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"]
|
||||
# The derived TEXT mirror renders the findings table (render_markdown).
|
||||
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