mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Extends structured-content enforcement from the content tools to every flow verb that carries agent free-text, closing the last hole where a dev/PM could pass word soup: i_am_blocked(reason), i_am_done(notes), submit_up/submit_root/complete(notes), escalate_up/escalate_to_ceo(reason), pass_review(notes), fail_review/pr_fail(issues), pr_pass(notes), i_documented(notes), delegate(title/description). Plans (i_will_plan / i_will_work_on) keep their existing >=150-char approach + sub_task gates and are skipped here so recovery re-entry with thin values still works. Shared helpers on the choreographer: _free_text_soup (bare envelope, list aware) and _soup_or_decision_env (folds the soup check into a verb's existing spec-gate return so no verb gains a return or tips the xenon bound). reject_trivial now also catches all-filler multi-token strings. base.md documents the broadened rule for agents.
372 lines
11 KiB
Python
372 lines
11 KiB
Python
"""Tests for ContentActions.pr_update — gateway verb behavior matrix.
|
|
|
|
Covers: missing pr_number, all-None fields, non-assignee non-PM rejection,
|
|
per-field forwarding, and GitError surfacing.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from roboco.exceptions import GitError
|
|
from roboco.services.gateway.content_actions import ContentActions, ContentActionsDeps
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ContentActionsDeps:
|
|
"""Wire ContentActionsDeps with mocks; honour caller overrides."""
|
|
task = overrides.get("task", AsyncMock())
|
|
git = overrides.get("git", AsyncMock())
|
|
return ContentActionsDeps(
|
|
task=task,
|
|
git=git,
|
|
messaging=overrides.get("messaging", AsyncMock()),
|
|
a2a=overrides.get("a2a", AsyncMock()),
|
|
journal=overrides.get("journal", AsyncMock()),
|
|
workspace=overrides.get("workspace", AsyncMock()),
|
|
notifications=overrides.get("notifications", AsyncMock()),
|
|
notification_delivery=overrides.get("notification_delivery", AsyncMock()),
|
|
)
|
|
|
|
|
|
def _task(
|
|
*,
|
|
pr_number: int | None = 7,
|
|
assigned_to: UUID,
|
|
team: str = "backend",
|
|
) -> MagicMock:
|
|
return MagicMock(
|
|
id=uuid4(),
|
|
status="in_progress",
|
|
pr_number=pr_number,
|
|
pr_url=(
|
|
f"https://github.com/acme/repo/pull/{pr_number}"
|
|
if pr_number is not None
|
|
else None
|
|
),
|
|
assigned_to=assigned_to,
|
|
team=team,
|
|
)
|
|
|
|
|
|
def _agent(role: str, team: str | None = None) -> MagicMock:
|
|
return MagicMock(id=uuid4(), role=role, team=team)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_missing_pr_number_returns_invalid_state() -> None:
|
|
"""task.pr_number is None → invalid_state with remediate 'call open_pr'."""
|
|
agent_id = uuid4()
|
|
task = _task(pr_number=None, assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
deps = _make_deps(task=task_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "invalid_state"
|
|
assert "open_pr" in body["remediate"]
|
|
deps.git.update_pr_for_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_all_fields_none_returns_invalid_state() -> None:
|
|
"""All of title/body/reviewers None → invalid_state."""
|
|
agent_id = uuid4()
|
|
task = _task(assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
deps = _make_deps(task=task_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id, task_id=task.id, title=None, body=None, reviewers=None
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "invalid_state"
|
|
assert "at least one" in body["remediate"]
|
|
deps.git.update_pr_for_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_non_assignee_non_pm_returns_not_authorized() -> None:
|
|
"""A developer who is neither the assignee nor a PM → not_authorized."""
|
|
assignee_id = uuid4()
|
|
caller_id = uuid4()
|
|
task = _task(assigned_to=assignee_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
deps = _make_deps(task=task_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=caller_id,
|
|
task_id=task.id,
|
|
title="new title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_authorized"
|
|
deps.git.update_pr_for_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_assignee_title_only_forwarded() -> None:
|
|
"""Assignee + title only forwards (title=X, body=None, reviewers=None)."""
|
|
agent_id = uuid4()
|
|
task = _task(assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.return_value = {
|
|
"pr_number": 7,
|
|
"pr_url": task.pr_url,
|
|
"updated_fields": ["title"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=task.id,
|
|
title="new title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
assert body["evidence"]["updated_fields"] == ["title"]
|
|
git_svc.update_pr_for_task.assert_awaited_once()
|
|
call_kwargs = git_svc.update_pr_for_task.call_args.kwargs
|
|
assert call_kwargs["title"] == "new title"
|
|
assert call_kwargs["body"] is None
|
|
assert call_kwargs["reviewers"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_reviewers_only_forwarded() -> None:
|
|
"""Assignee + reviewers only → forwarded as the only non-None arg."""
|
|
agent_id = uuid4()
|
|
task = _task(assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.return_value = {
|
|
"pr_number": 7,
|
|
"pr_url": task.pr_url,
|
|
"updated_fields": ["reviewers"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=task.id,
|
|
title=None,
|
|
body=None,
|
|
reviewers=["be-dev-2"],
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
git_svc.update_pr_for_task.assert_awaited_once()
|
|
call_kwargs = git_svc.update_pr_for_task.call_args.kwargs
|
|
assert call_kwargs["title"] is None
|
|
assert call_kwargs["body"] is None
|
|
assert call_kwargs["reviewers"] == ["be-dev-2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_all_three_forwarded() -> None:
|
|
"""All three fields → all forwarded; updated_fields reflects all three."""
|
|
agent_id = uuid4()
|
|
task = _task(assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.return_value = {
|
|
"pr_number": 7,
|
|
"pr_url": task.pr_url,
|
|
"updated_fields": ["title", "body", "reviewers"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body="a substantive PR body",
|
|
reviewers=["be-dev-2"],
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
call_kwargs = git_svc.update_pr_for_task.call_args.kwargs
|
|
assert call_kwargs["title"] == "updated PR title"
|
|
assert call_kwargs["body"] == "a substantive PR body"
|
|
assert call_kwargs["reviewers"] == ["be-dev-2"]
|
|
assert set(body["evidence"]["updated_fields"]) == {"title", "body", "reviewers"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_cell_pm_on_same_team_allowed() -> None:
|
|
"""Cell PM whose team == task.team can update the PR (PM authority)."""
|
|
pm_id = uuid4()
|
|
assignee_id = uuid4()
|
|
task = _task(assigned_to=assignee_id, team="backend")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("cell_pm", "backend")
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.return_value = {
|
|
"pr_number": 7,
|
|
"pr_url": task.pr_url,
|
|
"updated_fields": ["title"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=pm_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] is None
|
|
git_svc.update_pr_for_task.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_cell_pm_on_other_team_rejected() -> None:
|
|
"""Cell PM on a different team than the task → not_authorized."""
|
|
pm_id = uuid4()
|
|
assignee_id = uuid4()
|
|
task = _task(assigned_to=assignee_id, team="backend")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("cell_pm", "frontend")
|
|
|
|
deps = _make_deps(task=task_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=pm_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
|
|
assert env.as_dict()["error"] == "not_authorized"
|
|
deps.git.update_pr_for_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_main_pm_any_team_allowed() -> None:
|
|
"""Main PM is cross-team and may update any task's PR."""
|
|
pm_id = uuid4()
|
|
assignee_id = uuid4()
|
|
task = _task(assigned_to=assignee_id, team="backend")
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("main_pm", team=None)
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.return_value = {
|
|
"pr_number": 7,
|
|
"pr_url": task.pr_url,
|
|
"updated_fields": ["title"],
|
|
}
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=pm_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
|
|
assert env.as_dict()["error"] is None
|
|
git_svc.update_pr_for_task.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_task_not_found_returns_not_found() -> None:
|
|
"""Unknown task_id → not_found envelope; git layer never invoked."""
|
|
agent_id = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = None
|
|
deps = _make_deps(task=task_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=uuid4(),
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "not_found"
|
|
deps.git.update_pr_for_task.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pr_update_git_error_returned_as_invalid_state() -> None:
|
|
"""GitService raises GitError → mapped to invalid_state envelope with detail."""
|
|
agent_id = uuid4()
|
|
task = _task(assigned_to=agent_id)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = task
|
|
task_svc.agent_for.return_value = _agent("developer", "backend")
|
|
|
|
git_svc = AsyncMock()
|
|
git_svc.update_pr_for_task.side_effect = GitError("PR not found: #7 on acme/repo")
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
ca = ContentActions(deps)
|
|
|
|
env = await ca.pr_update(
|
|
agent_id=agent_id,
|
|
task_id=task.id,
|
|
title="updated PR title",
|
|
body=None,
|
|
reviewers=None,
|
|
)
|
|
body = env.as_dict()
|
|
|
|
assert body["error"] == "invalid_state"
|
|
assert "PR not found" in body["message"]
|