feat(gateway): add submit_for_qa verb so devs can open PRs

Gate E made i_am_done strict (requires pr_number set), but the only
verb that opened PRs was i_am_done_with_catchup which lives off the
dev manifest. Devs hit NO_PR with no escape. Adds submit_for_qa as
the explicit push+PR step, leaving i_am_done to do the strict submit.
This commit is contained in:
Renn F
2026-05-03 05:14:07 +02:00
parent 6643b4c375
commit 3d5f14815d
7 changed files with 265 additions and 5 deletions
+175
View File
@@ -0,0 +1,175 @@
"""submit_for_qa 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_submit_for_qa_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.submit_for_qa(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_submit_for_qa_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.submit_for_qa(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_submit_for_qa_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.submit_for_qa(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_submit_for_qa_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.submit_for_qa(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_submit_for_qa_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.submit_for_qa(aid, tid)
assert env.error == "not_found"