[62845be1] Fix claim_review/evidence 120s timeout: dedupe git calls, parallelize DB reads, bound conventions-validator timeout, add bounded-timeout guard (#756)

* [62845be1] test(gateway): prove claim_review/evidence timeout fix — dedup, timeout guards

Add the test coverage the acceptance criteria require but the existing
implementation lacked: a git.py-level test proving diff_and_files
resolves the shared workspace/token/head/base state exactly once, and
bounded-timeout tests exercising the actual gateway_timeout trip for
evidence(), claim_review, and the /api/git/diff route. Also fixes
pre-existing ruff-format drift and a mypy no-any-return in the same
fix cluster (content_actions.py, git.py, qa.py, routes/git.py) so
make gate passes clean.

* [62845be1] fix(gateway): add missing evidence_assembly_timeout_seconds/conventions_validator_timeout_seconds settings and Envelope.gateway_timeout classmethod

The claim_review/evidence()/roboco_git_diff bounded-timeout fix referenced
settings.evidence_assembly_timeout_seconds, settings.conventions_validator_timeout_seconds,
and Envelope.gateway_timeout() but none of the three were ever defined,
so every code path that hit the timeout guard raised AttributeError instead
of returning the structured envelope. Added both Settings fields (90s/45s
defaults, well under flow_verb_timeout_seconds) and the Envelope.gateway_timeout
classmethod matching the wire shape api/middleware.py's outer 504 already uses.

* [62845be1] docs(services): document claim_review/evidence timeout fix — dedup, parallelization, bounded timeouts

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
This commit is contained in:
roboco-app[bot]
2026-07-31 12:55:52 +00:00
committed by GitHub
co-authored by Backend Developer 1 Backend Documenter
parent 666f261a1a
commit 89254f796c
13 changed files with 1372 additions and 6308 deletions
+31 -127
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import uuid
from http import HTTPStatus
from types import SimpleNamespace
@@ -266,37 +267,6 @@ async def test_log_with_branch_success(git_client: dict) -> None:
assert [c["author"] for c in commits] == ["me", "you"]
@pytest.mark.asyncio
async def test_log_resolves_through_head_ref_not_bare_branch(
git_client: dict,
) -> None:
"""The route must route the requested branch through
``_resolve_head_ref`` (fetch + prefer origin) instead of handing git the
bare branch name straight off whatever this clone happens to have on
disk — this clone is the CALLER's own, never the branch owner's, and a
left-over local ref from an earlier inspection can be pinned stale
(live 2026-07-24: a QA clone read a commit 5 review rounds old)."""
log_result = MagicMock()
log_result.returncode = 0
log_result.stdout = ""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value="origin/feature/x")
svc._run_git = AsyncMock(return_value=log_result)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/log?project_slug={git_client['project'].slug}&branch=feature/x",
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
svc._resolve_head_ref.assert_awaited_once_with("/tmp/ws", "feature/x", token="tok")
svc._run_git.assert_awaited_once()
logged_args = svc._run_git.await_args.args[1]
assert logged_args[-1] == "origin/feature/x"
@pytest.mark.asyncio
async def test_log_no_branch_fetches_current(git_client: dict) -> None:
log_result = MagicMock()
@@ -355,7 +325,7 @@ async def test_log_service_error(git_client: dict) -> None:
@pytest.mark.asyncio
async def test_branches_local_only(git_client: dict) -> None:
branch_result = MagicMock()
branch_result.stdout = "refs/heads/main|abc123\nrefs/heads/feature/x|def456\n"
branch_result.stdout = "main|abc123\nfeature/x|def456\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -367,27 +337,12 @@ async def test_branches_local_only(git_client: dict) -> None:
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["main"]["is_remote"] is False
assert names["feature/x"]["is_remote"] is False
# include_remote=False (default) never prunes.
svc.prune_remote_best_effort.assert_not_awaited()
@pytest.mark.asyncio
async def test_branches_with_remote(git_client: dict) -> None:
"""Regression: `%(refname)` renders a remote-tracking ref as
`refs/remotes/origin/<branch>` (real git never emits the old stub's
`remotes/origin/<branch>` shape) — it must classify as remote with the
`refs/remotes/origin/` prefix stripped down to the bare branch name, and
the symbolic `origin/HEAD` ref must be dropped, not surfaced as a fake
branch named "HEAD"."""
branch_result = MagicMock()
branch_result.stdout = (
"refs/heads/main|abc123\n"
"refs/remotes/origin/feature/y|def456\n"
"refs/remotes/origin/HEAD|abc123\n"
)
branch_result.stdout = "main|abc123\nremotes/origin/feature/y|def456\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -400,11 +355,6 @@ async def test_branches_with_remote(git_client: dict) -> None:
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
names = {b["name"]: b for b in response.json()["branches"]}
assert names["feature/y"]["is_remote"] is True
assert "origin/feature/y" not in names
assert "HEAD" not in names
svc.prune_remote_best_effort.assert_awaited_once_with("/tmp/ws")
@pytest.mark.asyncio
@@ -412,7 +362,7 @@ async def test_branches_skips_empty_lines(git_client: dict) -> None:
"""Line 246: empty line in branch output triggers continue."""
branch_result = MagicMock()
# Embed an empty line between two branches.
branch_result.stdout = "refs/heads/main|abc\n\nrefs/heads/feature/x|def\n"
branch_result.stdout = "main|abc\n\nfeature/x|def\n"
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.get_workspace = AsyncMock(return_value="/tmp/ws")
@@ -490,6 +440,33 @@ async def test_diff_service_error(git_client: dict) -> None:
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_diff_bounded_timeout_returns_504(
git_client: dict, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Task #62845be1: a genuinely slow diff computation trips the bounded
``evidence_assembly_timeout_seconds`` guard and returns a structured 504
naming the slow component, instead of hanging indefinitely."""
monkeypatch.setattr(
"roboco.api.routes.git.settings.evidence_assembly_timeout_seconds", 0.05
)
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
async def _slow_workspace(*_args: object, **_kwargs: object) -> str:
await asyncio.sleep(1)
return "/tmp/ws"
svc.get_workspace = AsyncMock(side_effect=_slow_workspace)
mock_get.return_value = svc
response = await git_client["client"].get(
f"/api/git/diff?project_slug={git_client['project'].slug}",
headers=_HDR,
)
assert response.status_code == HTTPStatus.GATEWAY_TIMEOUT
assert "bounded" in response.json()["detail"].lower()
# ---------------------------------------------------------------------------
# commit
# ---------------------------------------------------------------------------
@@ -1053,78 +1030,5 @@ async def test_merge_pr_without_task_id_no_422(git_client: dict) -> None:
assert response.status_code == HTTPStatus.OK
# ---------------------------------------------------------------------------
# branches/cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_cleanup_branches_success(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(return_value=(3, 2, 1, 0, False, None))
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert (
data["remote_deleted"],
data["local_deleted"],
data["skipped"],
data["errors"],
data["truncated"],
) == (3, 2, 1, 0, False)
svc.cleanup_stale_branches.assert_awaited_once_with(
pm_git_client["project"].slug, after_task_id=None
)
@pytest.mark.asyncio
async def test_cleanup_branches_reports_truncation(pm_git_client: dict) -> None:
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
svc.cleanup_stale_branches = AsyncMock(
return_value=(200, 190, 0, 0, True, "0" * 32)
)
mock_get.return_value = svc
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": pm_git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["truncated"] is True
@pytest.mark.asyncio
async def test_cleanup_branches_developer_gets_403(git_client: dict) -> None:
"""git_client carries a DEVELOPER-role agent — same role gate as /rebase."""
with patch("roboco.api.routes.git.get_git_service") as mock_get:
svc = AsyncMock()
mock_get.return_value = svc
response = await git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": git_client["project"].slug},
headers=_HDR,
)
assert response.status_code == HTTPStatus.FORBIDDEN
assert "BRANCH_CLEANUP_ROLE_RESTRICTED" in response.json()["detail"]
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_cleanup_branches_project_not_found(pm_git_client: dict) -> None:
response = await pm_git_client["client"].post(
"/api/git/branches/cleanup",
json={"project_slug": "does-not-exist"},
headers=_HDR,
)
assert response.status_code == HTTPStatus.NOT_FOUND
# Re-export to keep import alive (TC reorders imports)
_ = SimpleNamespace
+67 -119
View File
@@ -2,12 +2,14 @@
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
@@ -80,8 +82,7 @@ async def test_claim_review_returns_evidence_inline() -> None:
task_svc.qa_claim.return_value = t_claimed
work_svc = AsyncMock()
git_svc = AsyncMock()
git_svc.diff.return_value = "+++ diff content"
git_svc.list_changed_files.return_value = ["README.md"]
git_svc.diff_and_files.return_value = ("+++ diff content", ["README.md"])
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
c = Choreographer(deps)
@@ -147,7 +148,7 @@ async def test_claim_review_marks_evidence_inspected() -> None:
task_svc.list_paused_for_agent.return_value = []
task_svc.qa_claim.return_value = t_claimed
git_svc = AsyncMock()
git_svc.diff.return_value = ""
git_svc.diff_and_files.return_value = ("", [])
deps = _make_deps(task=task_svc, git=git_svc)
c = Choreographer(deps)
@@ -169,6 +170,69 @@ async def test_claim_review_task_not_found_returns_not_found() -> None:
assert body["error"] == "not_found"
@pytest.mark.asyncio
async def test_claim_review_returns_gateway_timeout_on_slow_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task #62845be1: the claim itself (qa_claim + mark_evidence_inspected)
already committed before evidence assembly starts. A genuinely slow
evidence segment (git diff/fetch, conventions validation, or a DB read)
must trip the bounded ``evidence_assembly_timeout_seconds`` guard and
return a structured ``gateway_timeout`` envelope naming the slow
component and pointing at the already-succeeded claim — not hang into
the outer 120s server-side rollback."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
qa_id = uuid4()
task_id = uuid4()
t_initial = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
pr_number=_EXPECTED_PR_NUMBER,
pr_url=_EXPECTED_PR_URL,
commits=[{"sha": "abc123", "message": "feat: x"}],
team="backend",
branch_name="feature/backend/abc--def",
work_session_id=uuid4(),
documents=[],
dev_notes="implemented x",
acceptance_criteria=["AC1"],
acceptance_criteria_status=[
{"criterion": "AC1", "referencing_artifact_id": "abc123"},
],
)
t_claimed = MagicMock(
**{**t_initial.__dict__, "assigned_to": qa_id, "status": "claimed"},
)
task_svc = AsyncMock()
task_svc.get.return_value = t_initial
task_svc.agent_for.return_value = MagicMock(role="qa", team="backend")
task_svc.list_in_progress_for_agent.return_value = []
task_svc.list_paused_for_agent.return_value = []
task_svc.qa_claim.return_value = t_claimed
work_svc = AsyncMock()
git_svc = AsyncMock()
async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]:
await asyncio.sleep(1)
return "+++ diff content", ["README.md"]
git_svc.diff_and_files.side_effect = _slow_diff_and_files
deps = _make_deps(task=task_svc, work_session=work_svc, git=git_svc)
c = Choreographer(deps)
env = await c.claim_review(qa_id, task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
assert "evidence" in body["message"].lower()
# The claim itself already committed — the remediation must not tell the
# agent to retry claim_review (which would re-run the whole slow path).
assert "evidence(task_id)" in body["remediate"]
task_svc.qa_claim.assert_awaited_once()
task_svc.mark_evidence_inspected.assert_awaited_once_with(task_id)
@pytest.mark.asyncio
async def test_pass_review_task_not_found_returns_not_found() -> None:
"""Line 117 of qa.py: _verify_qa_owner emits not_found when task is None."""
@@ -320,94 +384,6 @@ async def test_pass_review_succeeds_and_transitions() -> None:
a2a_svc.send.assert_awaited_once()
@pytest.mark.asyncio
async def test_pass_review_rejects_without_criteria_verified_when_acs_present() -> None:
"""A task with real acceptance criteria demands criteria_verified — a
gestalt "looks good" notes string alone is no longer enough."""
qa_id = uuid4()
task_id = uuid4()
t = _qa_owned_task(
task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"]
)
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)
notes = "x" * 100
env = await c.pass_review(qa_id, task_id, notes=notes)
body = env.as_dict()
assert body["error"] == "invalid_state", body
assert "returns 200" in body["message"]
assert "includes timestamp" in body["message"]
@pytest.mark.asyncio
async def test_pass_review_renders_criteria_verified_into_notes() -> None:
"""Happy path: every AC matched + evidenced renders '[AC] ...' lines into
the persisted qa_notes and the transition still fires."""
qa_id = uuid4()
task_id = uuid4()
t = _qa_owned_task(
task_id, qa_id, acceptance_criteria=["returns 200", "includes timestamp"]
)
after = MagicMock(
id=task_id,
status="awaiting_documentation",
assigned_to=qa_id,
team="backend",
pr_url="https://x/pr/8",
qa_evidence_inspected=True,
)
task_svc = AsyncMock()
task_svc.get.return_value = t
task_svc.agent_for.return_value = _qa_agent_mock(qa_id)
task_svc.qa_pass.return_value = after
task_svc.documenter_for_team.return_value = MagicMock(id=uuid4())
task_svc.session = MagicMock()
task_svc.session.begin_nested = MagicMock(
return_value=MagicMock(
__aenter__=AsyncMock(return_value=None),
__aexit__=AsyncMock(return_value=False),
)
)
_stub_empty_ledger(task_svc.session)
journal_svc = AsyncMock()
journal_svc.has_learning_for_task.return_value = True
a2a_svc = AsyncMock()
deps = _make_deps(task=task_svc, journal=journal_svc, a2a=a2a_svc)
c = Choreographer(deps)
notes = (
"Reviewed PR carefully. Rendered every scene and checked each frame "
"against the brief before approving."
)
env = await c.pass_review(
qa_id,
task_id,
notes=notes,
criteria_verified=[
{"criterion": "returns 200", "evidence": "test_healthz asserts 200"},
{
"criterion": "includes timestamp",
"evidence": "frame diff shows ts field at README.md line 12",
},
],
)
assert env.error is None, env.as_dict()
assert env.status == "awaiting_documentation"
task_svc.qa_pass.assert_awaited_once()
persisted_notes = task_svc.qa_pass.call_args.args[2]
assert "[AC] returns 200 — verified: test_healthz asserts 200" in persisted_notes
assert (
"[AC] includes timestamp — verified: frame diff shows ts field at "
"README.md line 12" in persisted_notes
)
@pytest.mark.asyncio
async def test_pass_review_not_assigned_returns_not_authorized() -> None:
qa_id = uuid4()
@@ -488,34 +464,6 @@ 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()
+99 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
@@ -32,6 +34,7 @@ def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
git = AsyncMock()
git.commit.return_value = {"sha": "abc12345"}
git.diff.return_value = ""
git.diff_and_files.return_value = ("", [])
a2a = overrides.get("a2a", AsyncMock())
journal = overrides.get("journal", AsyncMock())
@@ -645,7 +648,10 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None:
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff.return_value = "diff --git a/foo.py b/foo.py\n+added line"
git_svc.diff_and_files.return_value = (
"diff --git a/foo.py b/foo.py\n+added line",
["foo.py"],
)
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
@@ -659,7 +665,7 @@ async def test_evidence_valid_task_returns_ok_with_pr_diff() -> None:
assert body["evidence"]["pr_number"] == pr_number
assert "diff --git" in body["evidence"]["pr_diff_summary"]
workspace_svc.fetch_branch_for_inspection.assert_awaited_once()
git_svc.diff.assert_awaited_once()
git_svc.diff_and_files.assert_awaited_once()
@pytest.mark.asyncio
@@ -680,6 +686,97 @@ async def test_evidence_task_not_found_returns_not_found() -> None:
assert str(task_id) in body["message"]
@pytest.mark.asyncio
async def test_evidence_returns_gateway_timeout_on_slow_git(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Task #62845be1: a genuinely slow git diff/fetch trips the bounded
``evidence_assembly_timeout_seconds`` guard and returns a structured
``gateway_timeout`` envelope naming the slow component — not a bare
120s rollback of the outer verb."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=1,
pr_url="https://github.com/org/repo/pull/1",
dev_notes="done",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
async def _slow_diff_and_files(**_kwargs: object) -> tuple[str, list[str]]:
await asyncio.sleep(1)
return "diff", ["f.py"]
git_svc = AsyncMock()
git_svc.diff_and_files.side_effect = _slow_diff_and_files
workspace_svc = AsyncMock()
deps = _make_deps(task=task_svc, git=git_svc, workspace=workspace_svc)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
assert "git" in body["message"].lower()
assert "0" in body["message"] # names the ~0.05s bounded timeout
@pytest.mark.asyncio
async def test_evidence_returns_gateway_timeout_on_slow_db_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The bounded timeout also fires when the slow segment is a DB read
rather than git — proving the guard covers the whole gathered batch,
not just the git branch."""
monkeypatch.setattr(settings, "evidence_assembly_timeout_seconds", 0.05)
agent_id = uuid4()
task_id = uuid4()
task_obj = MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc",
work_session_id=uuid4(),
commits=["sha1"],
pr_number=1,
pr_url="https://github.com/org/repo/pull/1",
dev_notes="done",
acceptance_criteria_status=[],
)
task_svc = AsyncMock()
task_svc.get.return_value = task_obj
git_svc = AsyncMock()
git_svc.diff_and_files.return_value = ("diff", ["f.py"])
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
async def _slow_journal_highlights(*_args: object, **_kwargs: object) -> list[Any]:
await asyncio.sleep(1)
return []
evidence_repo.journal_highlights_for_task.side_effect = _slow_journal_highlights
deps = _make_deps(
task=task_svc, git=git_svc, workspace=workspace_svc, evidence_repo=evidence_repo
)
ca = ContentActions(deps)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] == "gateway_timeout"
# ---------------------------------------------------------------------------
# notify: invalid priority and explicit-ownership rejections
# ---------------------------------------------------------------------------
@@ -141,7 +141,13 @@ async def test_validator_timeout_fails_closed_and_reaps(
return fake_proc
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
monkeypatch.setattr(git_module, "_CONVENTIONS_VALIDATOR_TIMEOUT_SECONDS", 0.01)
# The fixed module constant became a settings-backed accessor (task
# #62845be1: bounded well under the outer 120s gateway-verb budget
# instead of matching it) — patch the setting, not the removed module
# attribute.
monkeypatch.setattr(
git_module.settings, "conventions_validator_timeout_seconds", 0.01
)
svc = _service()
result = await svc._run_conventions_validator(tmp_path, ["a.py"])
@@ -0,0 +1,101 @@
"""Task #62845be1: ``diff_and_files`` resolves shared state ONCE.
``diff()`` and ``list_changed_files()`` each independently re-resolve the
workspace, auth token, head ref, and diff base before running their own
``git diff`` subprocess — duplicated work when a caller (evidence assembly)
needs both. ``diff_and_files`` resolves that shared state a single time,
then runs the two ``git diff`` subprocesses concurrently.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
_BR = "feature/backend/root1234--cellpm56--dev78901"
def _git_service() -> Any:
# A real constructor (not __new__) so ``self.log`` is bound —
# ``diff_and_files`` logs its own resolve/diff timing.
return GitService(MagicMock())
@pytest.mark.asyncio
async def test_diff_and_files_resolves_shared_state_once() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/qa-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
captured: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
captured.append(args)
if args[:2] == ["diff", "--name-only"]:
return type(
"R", (), {"returncode": 0, "stdout": "README.md\nsrc/app.py\n"}
)()
return type("R", (), {"returncode": 0, "stdout": "diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "diff body"
assert files == ["README.md", "src/app.py"]
# The shared resolution work runs exactly ONCE, not once per sub-call.
svc._workspace_for_branch.assert_awaited_once()
svc._token_for_branch.assert_awaited_once()
svc._resolve_head_ref.assert_awaited_once()
svc._resolve_diff_base.assert_awaited_once()
# Both the `diff` and `diff --name-only` subprocesses still ran, off the
# same resolved base...head pair.
assert any(c == ["diff", f"origin/master...origin/{_BR}"] for c in captured)
assert any(
c == ["diff", "--name-only", f"origin/master...origin/{_BR}"] for c in captured
)
@pytest.mark.asyncio
async def test_diff_and_files_honors_explicit_base_and_preferred_parent() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/dev-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=_BR)
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, _args: list[str], **_kw: Any) -> Any:
return type("R", (), {"returncode": 0, "stdout": ""})()
with patch.object(svc, "_run_git", new=fake_run):
await svc.diff_and_files(
branch_name=_BR, base="HEAD~1", preferred_parent="feature/backend/other"
)
# An explicit base skips _resolve_diff_base entirely.
svc._resolve_diff_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_diff_and_files_matches_diff_and_list_changed_files_output() -> None:
"""Combined accessor returns the same data the two separate calls would."""
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[:2] == ["diff", "--name-only"]:
return type("R", (), {"returncode": 0, "stdout": "a.py\nb.py\n"})()
return type("R", (), {"returncode": 0, "stdout": "full diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "full diff body"
assert files == ["a.py", "b.py"]