# ruff: noqa: INP001 """Integration tests for tasking Forgejo issues to Pipeline agents.""" from __future__ import annotations from datetime import datetime from types import SimpleNamespace from uuid import UUID, uuid4 import pytest from fastapi import APIRouter, FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine from sqlmodel import SQLModel, select from sqlmodel.ext.asyncio.session import AsyncSession from app import models as _models # noqa: F401 from app.api.deps import require_org_member from app.api.forgejo_issues import router as forgejo_issues_router from app.core.auth import AuthContext, get_auth_context from app.db.session import get_session from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.board_repository_links import BoardRepositoryLink from app.models.boards import Board from app.models.forgejo_connections import ForgejoConnection from app.models.forgejo_issue_task_links import ForgejoIssueTaskLink from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_repositories import ForgejoRepository from app.models.gateways import Gateway from app.models.organization_board_access import OrganizationBoardAccess from app.models.organization_members import OrganizationMember from app.models.organizations import Organization from app.models.tasks import Task from app.models.users import User from app.services.organizations import OrganizationContext async def _make_engine() -> AsyncEngine: engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync(SQLModel.metadata.create_all) return engine def _build_test_app( session_maker: async_sessionmaker[AsyncSession], *, org_ctx: OrganizationContext, auth_ctx: AuthContext, ) -> FastAPI: app = FastAPI() api_v1 = APIRouter(prefix="/api/v1") api_v1.include_router(forgejo_issues_router) app.include_router(api_v1) async def _override_get_session() -> AsyncSession: async with session_maker() as session: yield session async def _override_require_org_member() -> OrganizationContext: return org_ctx async def _override_get_auth_context() -> AuthContext: return auth_ctx app.dependency_overrides[get_session] = _override_get_session app.dependency_overrides[require_org_member] = _override_require_org_member app.dependency_overrides[get_auth_context] = _override_get_auth_context return app async def _seed(session: AsyncSession) -> SimpleNamespace: org = Organization(id=uuid4(), name="Pipeline Org") user = User( id=uuid4(), clerk_user_id="user_123", email="user@example.com", name="User", active_organization_id=org.id, ) member = OrganizationMember( id=uuid4(), organization_id=org.id, user_id=user.id, role="member", all_boards_read=False, all_boards_write=False, ) connection = ForgejoConnection( id=uuid4(), organization_id=org.id, name="Dream Forgejo", base_url="https://forgejo.example.local", token="temp-token", token_last_eight="p-token", ) repository = ForgejoRepository( id=uuid4(), organization_id=org.id, connection_id=connection.id, owner="openclaw", repo="pipeline", display_name="Pipeline", ) board = Board( id=uuid4(), organization_id=org.id, name="Pipeline Board", slug="pipeline-board", ) board_access = OrganizationBoardAccess( id=uuid4(), organization_member_id=member.id, board_id=board.id, can_read=True, can_write=True, ) link = BoardRepositoryLink( id=uuid4(), organization_id=org.id, board_id=board.id, repository_id=repository.id, ) issue = ForgejoIssue( id=uuid4(), organization_id=org.id, repository_id=repository.id, forgejo_issue_number=42, title="Implement agent tasking", body="Wire the backend so agents can work this issue.", body_preview="Wire the backend", state="open", is_pull_request=False, labels=[{"name": "backend"}, {"name": "agents"}], assignees=[], forgejo_payload={"number": 42, "state": "open"}, author="kaspa", html_url="https://forgejo.example.local/openclaw/pipeline/issues/42", forgejo_created_at=datetime(2026, 5, 19, 12, 0, 0), forgejo_updated_at=datetime(2026, 5, 19, 12, 5, 0), ) gateway = Gateway( id=uuid4(), organization_id=org.id, name="Gateway", url="https://gateway.example.local", workspace_root="/workspace", ) worker_agent = Agent( id=uuid4(), board_id=board.id, gateway_id=gateway.id, name="Worker Agent", is_board_lead=False, ) session.add(org) session.add(user) session.add(member) session.add(connection) session.add(repository) session.add(board) session.add(board_access) session.add(link) session.add(issue) session.add(gateway) session.add(worker_agent) await session.commit() return SimpleNamespace( org=org, user=user, member=member, repository=repository, board=board, issue=issue, gateway=gateway, worker_agent=worker_agent, ) def _client_for( session_maker: async_sessionmaker[AsyncSession], seeded: SimpleNamespace, ) -> AsyncClient: org_ctx = OrganizationContext(organization=seeded.org, member=seeded.member) auth_ctx = AuthContext(actor_type="user", user=seeded.user) app = _build_test_app(session_maker, org_ctx=org_ctx, auth_ctx=auth_ctx) return AsyncClient(transport=ASGITransport(app=app), base_url="http://testserver") @pytest.mark.asyncio async def test_human_writer_can_create_task_from_issue_and_assign_agent() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) try: async with session_maker() as session: seeded = await _seed(session) async with _client_for(session_maker, seeded) as client: response = await client.post( f"/api/v1/forgejo/issues/{seeded.issue.id}/task", json={ "board_id": str(seeded.board.id), "assigned_agent_id": str(seeded.worker_agent.id), "instructions": "Start by reproducing the issue locally.", }, ) assert response.status_code == 200 payload = response.json() assert payload["success"] is True assert payload["created"] is True assert payload["issue_id"] == str(seeded.issue.id) assert payload["board_id"] == str(seeded.board.id) assert payload["assigned_agent_id"] == str(seeded.worker_agent.id) assert payload["status"] == "in_progress" assert payload["title"] == "Git issue #42: Implement agent tasking" async with session_maker() as session: task = await session.get(Task, UUID(payload["task_id"])) assert task is not None assert task.board_id == seeded.board.id assert task.assigned_agent_id == seeded.worker_agent.id assert task.auto_created is True assert task.auto_reason == f"forgejo_issue:{seeded.issue.id}" assert task.in_progress_at is not None assert task.description is not None assert "Repository: Pipeline" in task.description assert "Labels: backend, agents" in task.description assert "Start by reproducing the issue locally." in task.description links = (await session.exec(select(ForgejoIssueTaskLink))).all() assert len(links) == 1 assert links[0].issue_id == seeded.issue.id assert links[0].task_id == task.id events = (await session.exec(select(ActivityEvent))).all() assert len(events) == 1 assert events[0].event_type == "forgejo.issue.task.created" assert events[0].board_id == seeded.board.id assert events[0].task_id == task.id finally: await engine.dispose() @pytest.mark.asyncio async def test_tasking_same_issue_reuses_existing_task() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) try: async with session_maker() as session: seeded = await _seed(session) async with _client_for(session_maker, seeded) as client: first = await client.post( f"/api/v1/forgejo/issues/{seeded.issue.id}/task", json={"board_id": str(seeded.board.id)}, ) second = await client.post( f"/api/v1/forgejo/issues/{seeded.issue.id}/task", json={ "board_id": str(seeded.board.id), "assigned_agent_id": str(seeded.worker_agent.id), "priority": "high", }, ) assert first.status_code == 200 assert second.status_code == 200 first_payload = first.json() second_payload = second.json() assert first_payload["created"] is True assert second_payload["created"] is False assert second_payload["task_id"] == first_payload["task_id"] assert second_payload["assigned_agent_id"] == str(seeded.worker_agent.id) assert second_payload["status"] == "in_progress" async with session_maker() as session: tasks = (await session.exec(select(Task))).all() links = (await session.exec(select(ForgejoIssueTaskLink))).all() events = (await session.exec(select(ActivityEvent))).all() assert len(tasks) == 1 assert len(links) == 1 assert tasks[0].priority == "high" assert len(events) == 2 assert [event.event_type for event in events] == [ "forgejo.issue.task.created", "forgejo.issue.task.updated", ] finally: await engine.dispose() @pytest.mark.asyncio async def test_tasking_issue_rejects_agent_from_another_board() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) try: async with session_maker() as session: seeded = await _seed(session) other_board = Board( id=uuid4(), organization_id=seeded.org.id, name="Other Board", slug="other-board", ) other_agent = Agent( id=uuid4(), board_id=other_board.id, gateway_id=seeded.gateway.id, name="Other Agent", ) session.add(other_board) session.add(other_agent) await session.commit() async with _client_for(session_maker, seeded) as client: response = await client.post( f"/api/v1/forgejo/issues/{seeded.issue.id}/task", json={ "board_id": str(seeded.board.id), "assigned_agent_id": str(other_agent.id), }, ) assert response.status_code == 422 assert response.json()["detail"] == "Agent must belong to the selected board" finally: await engine.dispose() @pytest.mark.asyncio async def test_tasking_issue_requires_board_when_multiple_linked_boards_are_writable() -> None: engine = await _make_engine() session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) try: async with session_maker() as session: seeded = await _seed(session) second_board = Board( id=uuid4(), organization_id=seeded.org.id, name="Second Board", slug="second-board", ) session.add(second_board) session.add( OrganizationBoardAccess( id=uuid4(), organization_member_id=seeded.member.id, board_id=second_board.id, can_read=True, can_write=True, ) ) session.add( BoardRepositoryLink( id=uuid4(), organization_id=seeded.org.id, board_id=second_board.id, repository_id=seeded.repository.id, ) ) await session.commit() async with _client_for(session_maker, seeded) as client: response = await client.post(f"/api/v1/forgejo/issues/{seeded.issue.id}/task") assert response.status_code == 422 assert ( response.json()["detail"] == "board_id is required because this issue is linked to multiple writable boards" ) finally: await engine.dispose()