Pipeline/backend/tests/test_forgejo_issue_tasking_...

380 lines
13 KiB
Python

# 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()