Files
roboco/tests/unit/gateway/test_evidence_populates_files_changed.py
T
ba7135ba50 feat(gateway): carry intake technical depth down the chain + widen review coherence scope (#491)
Two structural issues flagged by the CEO:

1. Task technical-depth dilution — intake's rich analysis (file:line
   targets, code examples, rationale) was getting lost as it traveled
   umbrella -> root-subtask -> cell -> dev. The detail IS preserved in
   Task.description; the dilution was in delegation (PMs re-authoring)
   and the intake prompt not demanding depth.

   Fixes:
   - evidence_repo: ancestor_context_for_task walks the parent chain
     (cycle-guarded, depth-capped 16, desc-clipped 1500) and surfaces it
     as parent_context in the evidence payload, so a leaf dev finally
     sees the upstream intake analysis instead of a bare title.
   - evidence_builder: Task.description now rides in the payload;
     EvidencePayload gains description + parent_context (omit-when-empty
     so no null noise).
   - orchestrator: _description_body (capped 4000) injects the
     description into the dev spawn prompt + SessionStart briefing.
   - role prompts (main_pm/cell_pm/developer/prompter): teach pass-the-
     torch, don't-dim-it; prompter now demands file:line/code-examples
     in the_work/notes (reconciled with the no-code-level-ACs-on-roots
     rule). main_pm's brief-not-a-spec scoped: not-a-spec applies to the
     solution only, facts forward verbatim.

2. PR-review/QA scope too narrow — they only checked the AC checklist,
   not whether the change is coherent with project structure/intent.

   Fixes:
   - qa.md + pr_reviewer.md: Coherence & intent rule (intent via
     description+parent_context, coherence with project patterns,
     standards). Criterion-less major findings allowed for intent drift
     (Finding.criterion is optional).
   - parent_context + description wired into the gate/QA/inbound-PR
     evidence builders (fail-open, logged).

Skipped per YAGNI: a technical_spec JSONB column (detail is already in
description) and a criterion_kind enum (criterion is already optional).

All gates green: ruff, mypy (1152), pytest (12883 passed, 94.82% cov),
xenon, vulture, bandit, pip-audit, deptry, alembic, import-linter,
foundation-check.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-13 08:06:57 +02:00

184 lines
6.6 KiB
Python

"""Task #154: evidence() must populate files_changed + use full PR diff.
Bug:
ContentActions.evidence() hard-coded ``files_changed=[]`` and called
``git.diff(branch_name=..., base="HEAD~1")``. Result: QA / reviewers
inspecting a real PR saw an empty change list and only the latest
commit's delta, even when GitHub showed a multi-commit change set.
Fix:
Pull files via ``git.list_changed_files(branch_name=...)`` (no base
→ full diff vs parent branch). Pull diff with ``base=None`` so the
full PR diff comes through. Both use git as the authoritative source
instead of the legacy ``work_session.files_modified`` field, which
the gateway ``commit()`` does not populate.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
def _deps_for_evidence(
task_svc: AsyncMock,
git_svc: AsyncMock,
workspace_svc: AsyncMock,
evidence_repo: AsyncMock,
) -> ContentActionsDeps:
return ContentActionsDeps(
task=task_svc,
git=git_svc,
a2a=AsyncMock(),
journal=AsyncMock(),
workspace=workspace_svc,
notifications=AsyncMock(),
notification_delivery=AsyncMock(),
evidence_repo=evidence_repo,
)
def _task_with_pr(task_id: object, *, commits: list[str]) -> MagicMock:
return MagicMock(
id=task_id,
status="awaiting_qa",
assigned_to=None,
branch_name="feature/backend/abc12345--def67890",
work_session_id=uuid4(),
commits=commits,
pr_number=20,
pr_url="https://github.com/org/repo/pull/20",
dev_notes="see PR description",
acceptance_criteria_status=[],
)
@pytest.mark.asyncio
async def test_evidence_populates_files_changed_from_git() -> None:
"""The smoke-9 regression: PR #20 has README change on GitHub but
evidence() reports files_changed=[]. The fix queries git directly."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc", "def"])
git_svc = AsyncMock()
git_svc.diff.return_value = "diff --git a/README.md b/README.md\n+added line\n"
git_svc.list_changed_files.return_value = ["README.md", "docs/guide.md"]
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] is None
assert body["evidence"]["files_changed"] == ["README.md", "docs/guide.md"]
assert "diff --git" in body["evidence"]["pr_diff_summary"]
git_svc.list_changed_files.assert_awaited_once()
@pytest.mark.asyncio
async def test_evidence_uses_full_pr_diff_not_head_minus_one() -> None:
"""git.diff must be called with base=None (full PR diff vs parent),
not base='HEAD~1' (only the last commit)."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
# Multi-commit branch — the pre-fix code passed base='HEAD~1' when
# task.commits was non-empty, masking earlier commits' changes.
task_svc.get.return_value = _task_with_pr(task_id, commits=["sha1", "sha2", "sha3"])
git_svc = AsyncMock()
git_svc.diff.return_value = "full diff"
git_svc.list_changed_files.return_value = []
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
await ca.evidence(agent_id=agent_id, task_id=task_id)
git_svc.diff.assert_awaited_once()
call_kwargs = git_svc.diff.await_args.kwargs
# Pre-fix bug: kwargs['base'] would be 'HEAD~1' for any multi-commit
# branch. Post-fix: base is omitted (or explicitly None).
base = call_kwargs.get("base")
assert base in (None, ""), (
f"git.diff must use full-PR diff (base=None), got base={base!r}"
)
assert call_kwargs.get("branch_name") == "feature/backend/abc12345--def67890"
@pytest.mark.asyncio
async def test_evidence_populates_journal_highlights() -> None:
"""evidence() must return journal_highlights so QA gets the dev's
decision/reflection context — same as qa.py's claim_review evidence."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
task_svc.get.return_value = _task_with_pr(task_id, commits=["abc"])
git_svc = AsyncMock()
git_svc.diff.return_value = ""
git_svc.list_changed_files.return_value = []
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
highlights = [
{"scope": "decision", "title": "Use README format X", "content": "..."},
{"scope": "reflect", "title": "Lesson learned", "content": "..."},
]
evidence_repo.journal_highlights_for_task.return_value = highlights
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["evidence"]["journal_highlights"] == highlights
evidence_repo.journal_highlights_for_task.assert_awaited_once_with(
task_id, include_ancestors=True
)
@pytest.mark.asyncio
async def test_evidence_no_branch_skips_git_calls() -> None:
"""A task without a branch_name has no PR yet — skip git entirely,
still return a valid envelope with empty files_changed."""
agent_id = uuid4()
task_id = uuid4()
task_svc = AsyncMock()
no_branch = MagicMock(
id=task_id,
status="claimed",
assigned_to=agent_id,
branch_name=None,
work_session_id=None,
commits=[],
pr_number=None,
pr_url=None,
dev_notes=None,
acceptance_criteria_status=[],
)
task_svc.get.return_value = no_branch
git_svc = AsyncMock()
workspace_svc = AsyncMock()
evidence_repo = AsyncMock()
evidence_repo.journal_highlights_for_task.return_value = []
ca = ContentActions(
_deps_for_evidence(task_svc, git_svc, workspace_svc, evidence_repo)
)
env = await ca.evidence(agent_id=agent_id, task_id=task_id)
body = env.as_dict()
assert body["error"] is None
assert body["evidence"]["files_changed"] == []
assert body["evidence"]["pr_diff_summary"] == ""
git_svc.diff.assert_not_awaited()
git_svc.list_changed_files.assert_not_awaited()