mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Pre-fix, submit_for_qa opened a PR (side effect) and returned OK with next='call i_am_done' — agents read the verb name, assumed they were done with QA handoff, never called i_am_done, and PRs ended up orphaned (PR #12 in the 2026-05-08 trace). Two changes: 1. Rename submit_for_qa -> open_pr so the verb name matches the semantic. The PR opens here; the actual QA handoff happens at i_am_done. Renamed across: - choreographer/_impl.py (method) - mcp/flow_server.py (tool registration + _TOOLS dict) - api/routes/v2/flow_dev.py (route + handler) - api/schemas/v2/flow.py (OpenPrRequest) - services/gateway/verb_gates.py (_STATE_VERBS) - services/gateway/role_config.py (developer flow manifest) - services/gateway/content_actions.py (commit-success next= hint) - agent_sdk/server.py (post-tool guidance map) - runtime/orchestrator.py (developer prompt) - agents/prompts/{base,roles/developer,_generated/*}.md - tests/unit/gateway/test_submit_for_qa.py -> test_open_pr.py - tests/unit/api/routes/v2/test_flow_dev.py - tests/unit/gateway/test_verb_gates.py - tests/unit/api/test_correlation_id.py - tests/unit/mcp_servers/test_flow_server.py - tests/integration/test_full_lifecycle_real_db.py 2. New regression test (test_open_pr_does_not_create_pr_if_no_commits) pins the atomic invariant: preconditions (assignee, commits, no-prior-PR) must be checked BEFORE git.create_pr/push_branch run. Any future re-ordering breaks the test. Tests: 3128 passing (3127 + 1 new), 100% coverage, ruff clean. Note: TaskService.submit_for_qa() (the v1-layer service method) is INTENTIONALLY not renamed — it's a different layer used by the v1 routes. The rename here is only the gateway verb surface.
176 lines
4.9 KiB
Python
176 lines
4.9 KiB
Python
"""open_pr pushes the current branch and opens a PR.
|
|
|
|
Gate E (commit c5c2016) made `i_am_done` strict — requires `pr_number` set.
|
|
The catch-up flow is off the dev manifest. Devs need an explicit verb that
|
|
pushes + opens the PR so a subsequent `i_am_done` can do the strict submit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
|
|
|
|
|
|
def _make_deps(**overrides: AsyncMock) -> ChoreographerDeps:
|
|
"""Local dep-builder. Established pattern: per-test-file, not centralized."""
|
|
task = overrides.get("task", AsyncMock())
|
|
work_session = overrides.get("work_session", AsyncMock())
|
|
git = overrides.get("git", AsyncMock())
|
|
a2a = overrides.get("a2a", AsyncMock())
|
|
journal = overrides.get("journal", AsyncMock())
|
|
audit = overrides.get("audit", AsyncMock())
|
|
evidence_repo = overrides.get("evidence_repo", AsyncMock())
|
|
for method in (
|
|
"list_unread_a2a",
|
|
"list_unread_mentions",
|
|
"list_pending_notifications",
|
|
"task_metadata_gaps",
|
|
"recent_team_activity",
|
|
"blockers_in_lane",
|
|
):
|
|
getattr(evidence_repo, method).return_value = []
|
|
return ChoreographerDeps(
|
|
task=task,
|
|
work_session=work_session,
|
|
git=git,
|
|
a2a=a2a,
|
|
journal=journal,
|
|
audit=audit,
|
|
evidence_repo=evidence_repo,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_pr_pushes_and_opens_pr() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = MagicMock(
|
|
id=tid,
|
|
status="in_progress",
|
|
assigned_to=aid,
|
|
plan="x",
|
|
commits=[{"sha": "abc"}],
|
|
pr_number=None,
|
|
branch_name="feature/backend/abc12345",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
git_svc = AsyncMock()
|
|
git_svc.push_branch.return_value = ("feature/backend/abc12345", 1)
|
|
git_svc.create_pr.return_value = {
|
|
"pr_number": 42,
|
|
"pr_url": "https://gh/x/42",
|
|
"is_root_pr": False,
|
|
}
|
|
work_session_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc, work_session=work_session_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.open_pr(aid, tid)
|
|
|
|
git_svc.push_branch.assert_awaited()
|
|
git_svc.create_pr.assert_awaited()
|
|
assert env.error is None
|
|
assert env.next is not None
|
|
assert "42" in env.next # remediate points to i_am_done with PR ref
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_pr_rejects_when_not_assigned() -> None:
|
|
aid = uuid4()
|
|
other = uuid4()
|
|
tid = uuid4()
|
|
t = MagicMock(
|
|
id=tid,
|
|
status="in_progress",
|
|
assigned_to=other,
|
|
plan="x",
|
|
commits=[{"sha": "abc"}],
|
|
pr_number=None,
|
|
branch_name="feature/backend/abc12345",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.open_pr(aid, tid)
|
|
|
|
git_svc.push_branch.assert_not_awaited()
|
|
git_svc.create_pr.assert_not_awaited()
|
|
assert env.error == "not_authorized"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_pr_rejects_when_no_commits() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = MagicMock(
|
|
id=tid,
|
|
status="in_progress",
|
|
assigned_to=aid,
|
|
plan="x",
|
|
commits=[],
|
|
pr_number=None,
|
|
branch_name="feature/backend/abc12345",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.open_pr(aid, tid)
|
|
|
|
git_svc.push_branch.assert_not_awaited()
|
|
git_svc.create_pr.assert_not_awaited()
|
|
assert env.error == "invalid_state"
|
|
assert env.remediate is not None
|
|
assert "commit" in env.remediate.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_pr_idempotent_when_pr_already_open() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
t = MagicMock(
|
|
id=tid,
|
|
status="in_progress",
|
|
assigned_to=aid,
|
|
plan="x",
|
|
commits=[{"sha": "abc"}],
|
|
pr_number=7,
|
|
branch_name="feature/backend/abc12345",
|
|
)
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = t
|
|
git_svc = AsyncMock()
|
|
deps = _make_deps(task=task_svc, git=git_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.open_pr(aid, tid)
|
|
|
|
git_svc.push_branch.assert_not_awaited()
|
|
git_svc.create_pr.assert_not_awaited()
|
|
assert env.error is None
|
|
assert env.next is not None
|
|
assert "7" in env.next
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_open_pr_returns_not_found_for_unknown_task() -> None:
|
|
aid = uuid4()
|
|
tid = uuid4()
|
|
task_svc = AsyncMock()
|
|
task_svc.get.return_value = None
|
|
deps = _make_deps(task=task_svc)
|
|
c = Choreographer(deps)
|
|
|
|
env = await c.open_pr(aid, tid)
|
|
|
|
assert env.error == "not_found"
|