mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(auditor): waive_finding verb + findings queue panel
Wire the long-unwired mark_waived repo method to a new auditor-only flow verb waive_finding, severity-scoped to minor/nit (blocker/major must be fixed, never waived), requiring a note, with a task.finding_waived audit event and no task status change. Add the verb to the IntentSpec table (auto-derived into the auditor manifest), the flow_auditor route, and the flow_server MCP tool. Surface open review findings (cross-task, blocking-first) on the auditor dashboard via ReviewFindingsRepository.list_open_findings and a new findings field on AuditorDashboard. Restore the panel's 4-card auditor layout with a new read-only FindingsQueuePanel as the 4th card.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""IntentSpec for the auditor ``waive_finding`` verb.
|
||||
|
||||
``mark_waived`` sat unwired on the repository since the findings ledger
|
||||
landed (PR #486) — a deliberate follow-up. The auditor is the role that
|
||||
can close a finding without a dev fix, but only for non-blocking severity.
|
||||
This pins the spec: auditor-only, severity-scoped at the verb body (the
|
||||
IntentSpec carries no task precondition — ``composes=()`` like ``triage``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import intents_for_role
|
||||
from roboco.services.gateway.role_config import _AUDITOR_FLOW
|
||||
|
||||
|
||||
def test_waive_finding_is_an_auditor_flow_verb() -> None:
|
||||
assert "waive_finding" in intents_for_role(Role.AUDITOR)
|
||||
assert "waive_finding" in _AUDITOR_FLOW
|
||||
|
||||
|
||||
def test_waive_finding_is_auditor_only() -> None:
|
||||
for role in (
|
||||
Role.DEVELOPER,
|
||||
Role.QA,
|
||||
Role.DOCUMENTER,
|
||||
Role.CELL_PM,
|
||||
Role.MAIN_PM,
|
||||
Role.PR_REVIEWER,
|
||||
Role.PRODUCT_OWNER,
|
||||
Role.HEAD_MARKETING,
|
||||
):
|
||||
assert "waive_finding" not in intents_for_role(role), (
|
||||
f"{role} must not get waive_finding"
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Choreographer.waive_finding — the auditor's close-without-fix verb.
|
||||
|
||||
``mark_waived`` was the long-unwired repo method; this is its only caller.
|
||||
Severity-scoped: blocker/major must be fixed, never waived. Only open
|
||||
findings are waivable, and a note is required. No task status changes —
|
||||
the ledger row ``open -> waived`` plus a ``task.finding_waived`` audit
|
||||
event is the durable record.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
||||
|
||||
|
||||
def _make_deps() -> ChoreographerDeps:
|
||||
base = {
|
||||
"task": AsyncMock(),
|
||||
"work_session": AsyncMock(),
|
||||
"git": AsyncMock(),
|
||||
"a2a": AsyncMock(),
|
||||
"journal": AsyncMock(),
|
||||
"audit": AsyncMock(),
|
||||
"evidence_repo": AsyncMock(),
|
||||
}
|
||||
repo = base["evidence_repo"]
|
||||
for method in (
|
||||
"list_unread_a2a",
|
||||
"list_unread_mentions",
|
||||
"list_pending_notifications",
|
||||
"task_metadata_gaps",
|
||||
"recent_team_activity",
|
||||
"blockers_in_lane",
|
||||
"journal_highlights_for_task",
|
||||
):
|
||||
getattr(repo, method).return_value = []
|
||||
return ChoreographerDeps(**base)
|
||||
|
||||
|
||||
def _finding_row(
|
||||
*,
|
||||
severity: str = "minor",
|
||||
status: str = "open",
|
||||
origin: str = "qa",
|
||||
) -> MagicMock:
|
||||
row = MagicMock()
|
||||
row.id = uuid4()
|
||||
row.task_id = uuid4()
|
||||
row.severity = severity
|
||||
row.status = status
|
||||
row.origin = origin
|
||||
return row
|
||||
|
||||
|
||||
def _patch_repo(monkeypatch: pytest.MonkeyPatch, row: Any | None) -> MagicMock:
|
||||
"""Patch the board-module ReviewFindingsRepository to return ``row`` from
|
||||
``get`` and a recording ``mark_waived``. ``row=None`` simulates not-found."""
|
||||
repo_mock = MagicMock()
|
||||
repo_mock.get = AsyncMock(return_value=row)
|
||||
repo_mock.mark_waived = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.gateway.choreographer.board.ReviewFindingsRepository",
|
||||
lambda *_a, **_k: repo_mock,
|
||||
)
|
||||
return repo_mock
|
||||
|
||||
|
||||
def _choreographer(monkeypatch: pytest.MonkeyPatch, row: Any | None):
|
||||
deps = _make_deps()
|
||||
deps.task.session = MagicMock()
|
||||
c = Choreographer(deps)
|
||||
return c, _patch_repo(monkeypatch, row)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_minor_open_finding_succeeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row = _finding_row(severity="minor", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
|
||||
env = await c.waive_finding(uuid4(), row.id, "cosmetic, not worth a fix")
|
||||
|
||||
assert env.error is None
|
||||
assert env.status == "waived"
|
||||
repo_mock.mark_waived.assert_awaited_once_with(row.id, "cosmetic, not worth a fix")
|
||||
c.audit.log_task_event.assert_awaited_once()
|
||||
assert c.audit.log_task_event.await_args.kwargs["event_type"] == (
|
||||
"task.finding_waived"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_nit_open_finding_succeeds(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row = _finding_row(severity="nit", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
env = await c.waive_finding(uuid4(), row.id, "preference only")
|
||||
assert env.error is None
|
||||
repo_mock.mark_waived.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_rejects_blocker(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
row = _finding_row(severity="blocker", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
env = await c.waive_finding(uuid4(), row.id, "try to skip the fix")
|
||||
assert env.error == "invalid_state"
|
||||
repo_mock.mark_waived.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_rejects_major(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
row = _finding_row(severity="major", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
env = await c.waive_finding(uuid4(), row.id, "try to skip the fix")
|
||||
assert env.error == "invalid_state"
|
||||
repo_mock.mark_waived.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_rejects_already_addressed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row = _finding_row(severity="minor", status="addressed")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
env = await c.waive_finding(uuid4(), row.id, "already closed")
|
||||
assert env.error == "invalid_state"
|
||||
repo_mock.mark_waived.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_rejects_blank_note(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
row = _finding_row(severity="minor", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
env = await c.waive_finding(uuid4(), row.id, " ")
|
||||
assert env.error == "invalid_state"
|
||||
repo_mock.mark_waived.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_unknown_finding_returns_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
c, repo_mock = _choreographer(monkeypatch, None)
|
||||
env = await c.waive_finding(uuid4(), uuid4(), "note")
|
||||
assert env.error == "not_found"
|
||||
repo_mock.mark_waived.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_waive_succeeds_even_if_audit_log_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The audit event is best-effort: a log failure must not undo the waive."""
|
||||
row = _finding_row(severity="minor", status="open")
|
||||
c, repo_mock = _choreographer(monkeypatch, row)
|
||||
c.audit.log_task_event = AsyncMock(side_effect=RuntimeError("db gone"))
|
||||
|
||||
env = await c.waive_finding(uuid4(), row.id, "still waive me")
|
||||
assert env.error is None
|
||||
repo_mock.mark_waived.assert_awaited_once()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""flow_server exposes the auditor's ``waive_finding`` tool.
|
||||
|
||||
The verb is auto-derived into the auditor manifest via
|
||||
``intents_for_role(Role.AUDITOR)``; this pins that the MCP layer registers
|
||||
it under the public name and POSTs the right payload to the auditor path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from roboco.foundation.identity import Role
|
||||
from roboco.foundation.policy.lifecycle import intents_for_role
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _auditor_manifest() -> dict[str, object]:
|
||||
return {
|
||||
"agent_id": "00000000-0000-0000-0000-000000000004",
|
||||
"role": "auditor",
|
||||
"team": "board",
|
||||
"workspace_path": "/tmp/test",
|
||||
"flow_tools": list(intents_for_role(Role.AUDITOR)),
|
||||
"do_tools": [],
|
||||
"read_tools": [],
|
||||
"write_tools": [],
|
||||
"bash_allowed": True,
|
||||
"subagent_allowed": False,
|
||||
"subagent_model": None,
|
||||
"env": {},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def flow_module_auditor(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> types.ModuleType:
|
||||
manifest_path = tmp_path / "tool-manifest.json"
|
||||
manifest_path.write_text(json.dumps(_auditor_manifest()))
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000004")
|
||||
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "auditor")
|
||||
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
|
||||
monkeypatch.setenv("ROBOCO_SDK_URL", "http://test-sdk:9000")
|
||||
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest_path))
|
||||
import roboco.mcp.flow_server as srv
|
||||
|
||||
importlib.reload(srv)
|
||||
return srv
|
||||
|
||||
|
||||
def test_waive_finding_registers_for_auditor_manifest(
|
||||
flow_module_auditor: types.ModuleType,
|
||||
) -> None:
|
||||
registered = flow_module_auditor._register_tools()
|
||||
assert "waive_finding" in registered, (
|
||||
f"waive_finding not registered for auditor manifest. "
|
||||
f"Registered: {sorted(registered)}"
|
||||
)
|
||||
|
||||
|
||||
def test_waive_finding_posts_to_auditor_path(
|
||||
flow_module_auditor: types.ModuleType,
|
||||
) -> None:
|
||||
captured: list[tuple[str, Any]] = []
|
||||
|
||||
def _client_factory(*_a: object, **_kw: object) -> MagicMock:
|
||||
client = MagicMock()
|
||||
client.__enter__ = MagicMock(return_value=client)
|
||||
client.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def _post(url: str, **kwargs: object) -> MagicMock:
|
||||
captured.append((url, kwargs.get("json", {})))
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"status": "waived", "error": None}
|
||||
return resp
|
||||
|
||||
client.post.side_effect = _post
|
||||
return client
|
||||
|
||||
finding_id = "11111111-1111-1111-1111-111111111111"
|
||||
with patch("httpx.Client", side_effect=_client_factory):
|
||||
result = flow_module_auditor.waive_finding(finding_id, "cosmetic nit")
|
||||
|
||||
assert result["status"] == "waived"
|
||||
orch_calls = [(u, b) for u, b in captured if "test-orchestrator" in u]
|
||||
assert len(orch_calls) == 1
|
||||
url, body = orch_calls[0]
|
||||
assert url.endswith("/api/v1/flow/auditor/waive_finding"), (
|
||||
f"waive_finding must POST to /auditor/waive_finding, got {url}"
|
||||
)
|
||||
assert body == {"finding_id": finding_id, "note": "cosmetic nit"}
|
||||
@@ -289,3 +289,55 @@ async def test_mark_waived_requires_note(db_session: AsyncSession) -> None:
|
||||
async def test_mark_waived_unknown_id_returns_false(db_session: AsyncSession) -> None:
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
assert await repo.mark_waived(uuid4(), "note") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_open_findings_cross_task_blocking_first(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
"""list_open_findings returns OPEN rows across tasks, blocker first."""
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_a = await _seed_task(db_session, agent_id)
|
||||
task_b = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
|
||||
await repo.insert_many(
|
||||
task_id=task_a,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(severity=Severity.MINOR)],
|
||||
)
|
||||
await repo.insert_many(
|
||||
task_id=task_b,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(severity=Severity.BLOCKER)],
|
||||
)
|
||||
# Waive the minor one so only the blocker is OPEN.
|
||||
open_rows = await repo.list_for_task(task_a, status=STATUS_OPEN)
|
||||
await repo.mark_waived(UUID(str(open_rows[0].id)), "waived in test")
|
||||
|
||||
result = await repo.list_open_findings(limit=20)
|
||||
assert len(result) == 1
|
||||
assert result[0].severity == Severity.BLOCKER.value
|
||||
assert str(result[0].task_id) == str(task_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_open_findings_excludes_non_open(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
agent_id = await _seed_agent(db_session)
|
||||
task_id = await _seed_task(db_session, agent_id)
|
||||
repo = ReviewFindingsRepository(db_session)
|
||||
rows = await repo.insert_many(
|
||||
task_id=task_id,
|
||||
origin="qa",
|
||||
round=1,
|
||||
author_slug="be-qa",
|
||||
findings=[_finding(severity=Severity.NIT)],
|
||||
)
|
||||
await repo.mark_waived(UUID(str(rows[0].id)), "nit, skip")
|
||||
assert await repo.list_open_findings(limit=20) == []
|
||||
|
||||
Reference in New Issue
Block a user