2026-06-12 14:14:50 -07:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from typing import Any
|
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from agent.dashboard import review_api, routes
|
refactor: consolidate reviewer modules into agent/review/
Part of the domain-reorg adoption (build plan step C2): fork content,
upstream layout. Nine 1:1 module moves (reviewer_diff/eval_store/
findings/groups/publish/reconcile/trace_context + review_style_
collector/guidance) into agent/review/, with internal relative
imports re-wired to the new package depth. agent/review/__init__.py
mirrors upstream's thin re-export shim (one of the 21 verified "A"
structural adds).
Rewrote the 38 grep hits across importer files (agent/{analyzer,
ci_autofix,reviewer,webapp}.py, agent/dashboard/*, agent/middleware/
settle_review_check.py, agent/tools/*, agent/utils/github_feedback.py,
agent/webhooks/github.py, evals/reviewer/*, and the reviewer test
suite) to point at agent.review.*; 4 of the 38 hits were name
collisions (list_reviewer_findings, reviewer_outcomes,
_reviewer_thread_id, reviewer_thread_id — not the moved modules) and
were left untouched. tests/test_github_checks.py's module-alias
import (`from agent import reviewer_publish`) follows upstream's own
`from agent.review import publish as reviewer_publish` pattern so
downstream `reviewer_publish.*` call sites needed no changes.
agent/reviewer.py and agent/webapp.py stay in place per the hard
rule (fork content, import-only rewire) and are not part of this
package.
Gates: ruff check + ruff format --check, pytest --co -q (1637
collected), full unit suite (1637 passed), and the reviewer/findings
suite in isolation (pytest -k "review or finding", 421 passed).
2026-07-17 13:52:03 -04:00
|
|
|
from agent.review.findings import REVIEWER_THREAD_KIND
|
2026-06-12 14:14:50 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _thread(owner: str, name: str, number: int, author: str) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"thread_id": f"{owner}/{name}/{number}",
|
|
|
|
|
"status": "idle",
|
|
|
|
|
"updated_at": "2026-06-12T00:00:00Z",
|
|
|
|
|
"metadata": {
|
|
|
|
|
"kind": REVIEWER_THREAD_KIND,
|
|
|
|
|
"pr": {"owner": owner, "name": name, "number": number, "author": author},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fake_client(pages: list[list[dict[str, Any]]]) -> tuple[Any, dict[str, Any]]:
|
|
|
|
|
captured: dict[str, Any] = {"calls": []}
|
|
|
|
|
queue = list(pages)
|
|
|
|
|
|
|
|
|
|
async def search(**kwargs: Any) -> list[dict[str, Any]]:
|
|
|
|
|
captured["calls"].append(kwargs)
|
|
|
|
|
return queue.pop(0) if queue else []
|
|
|
|
|
|
|
|
|
|
return SimpleNamespace(threads=SimpleNamespace(search=search)), captured
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_reviews_pushes_author_into_metadata_filter(monkeypatch) -> None:
|
|
|
|
|
client, captured = _fake_client([[]])
|
|
|
|
|
monkeypatch.setattr(review_api, "langgraph_client", lambda: client)
|
|
|
|
|
|
|
|
|
|
reviews, has_more = await review_api.list_reviews(20, author="octocat")
|
|
|
|
|
|
|
|
|
|
assert captured["calls"][0]["metadata"] == {
|
|
|
|
|
"kind": REVIEWER_THREAD_KIND,
|
|
|
|
|
"pr": {"author": "octocat"},
|
|
|
|
|
}
|
|
|
|
|
assert reviews == []
|
|
|
|
|
assert has_more is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_reviews_no_author_filters_kind_only(monkeypatch) -> None:
|
|
|
|
|
client, captured = _fake_client([[]])
|
|
|
|
|
monkeypatch.setattr(review_api, "langgraph_client", lambda: client)
|
|
|
|
|
|
|
|
|
|
await review_api.list_reviews(20, author=None)
|
|
|
|
|
|
|
|
|
|
assert captured["calls"][0]["metadata"] == {"kind": REVIEWER_THREAD_KIND}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_list_reviews_applies_accessibility_and_has_more(monkeypatch) -> None:
|
|
|
|
|
page = [
|
|
|
|
|
_thread("acme", "a", 1, "octocat"),
|
|
|
|
|
_thread("acme", "b", 2, "octocat"),
|
|
|
|
|
_thread("acme", "a", 3, "octocat"),
|
|
|
|
|
]
|
|
|
|
|
client, _ = _fake_client([page])
|
|
|
|
|
monkeypatch.setattr(review_api, "langgraph_client", lambda: client)
|
|
|
|
|
|
|
|
|
|
async def is_accessible(summary: dict[str, Any]) -> bool:
|
|
|
|
|
return summary["full_name"] == "acme/a"
|
|
|
|
|
|
|
|
|
|
reviews, has_more = await review_api.list_reviews(1, offset=0, is_accessible=is_accessible)
|
|
|
|
|
|
|
|
|
|
assert [r["number"] for r in reviews] == [1]
|
|
|
|
|
# two accessible records exist (1 and 3); page of 1 leaves more.
|
|
|
|
|
assert has_more is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_accessible_repo_full_names_lowercases(monkeypatch) -> None:
|
|
|
|
|
fetch = AsyncMock(return_value=([], [{"full_name": "Acme/Repo"}, {"full_name": "Acme/Other"}]))
|
|
|
|
|
monkeypatch.setattr(routes, "_fetch_user_installations_and_repos", fetch)
|
|
|
|
|
|
|
|
|
|
names = await routes.accessible_repo_full_names("octocat")
|
|
|
|
|
|
|
|
|
|
assert names == frozenset({"acme/repo", "acme/other"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_accessible_repo_full_names_resolves_fresh_each_call(monkeypatch) -> None:
|
|
|
|
|
# Access is an authorization boundary, so it must not be cached across calls:
|
|
|
|
|
# a second call re-fetches and reflects revoked access.
|
|
|
|
|
fetch = AsyncMock(
|
|
|
|
|
side_effect=[
|
|
|
|
|
([], [{"full_name": "acme/repo"}]),
|
|
|
|
|
([], []),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(routes, "_fetch_user_installations_and_repos", fetch)
|
|
|
|
|
|
|
|
|
|
first = await routes.accessible_repo_full_names("octocat")
|
|
|
|
|
second = await routes.accessible_repo_full_names("octocat")
|
|
|
|
|
|
|
|
|
|
assert first == frozenset({"acme/repo"})
|
|
|
|
|
assert second == frozenset()
|
|
|
|
|
assert fetch.await_count == 2
|