feat: Human And Agent Close Issue APIs
This commit is contained in:
parent
7c35c889dc
commit
fbc79e6777
|
|
@ -9,16 +9,24 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import select, func
|
from sqlmodel import select, func
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from app.api.deps import ActorContext, get_board_for_actor_read
|
from app.api.deps import get_board_for_actor_read
|
||||||
from app.core.agent_auth import get_agent_auth_context
|
from app.core.agent_auth import get_agent_auth_context
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
|
from app.models.boards import Board
|
||||||
from app.models.board_repository_links import BoardRepositoryLink
|
from app.models.board_repository_links import BoardRepositoryLink
|
||||||
from app.models.forgejo_issues import ForgejoIssue
|
from app.models.forgejo_issues import ForgejoIssue
|
||||||
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
|
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
|
||||||
|
from app.services.activity_log import record_activity
|
||||||
|
from app.services.forgejo_issue_close import (
|
||||||
|
CloseIssueAccessError,
|
||||||
|
CloseIssueNotFoundError,
|
||||||
|
CloseIssueRemoteError,
|
||||||
|
close_issue_by_id,
|
||||||
|
)
|
||||||
|
from app.services.openclaw.policies import OpenClawAuthorizationPolicy
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
from app.models.agents import Agent
|
|
||||||
from app.core.agent_auth import AgentAuthContext
|
from app.core.agent_auth import AgentAuthContext
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -248,10 +256,6 @@ async def read_board_issue(
|
||||||
return ForgejoIssueRead.model_validate(issue)
|
return ForgejoIssueRead.model_validate(issue)
|
||||||
|
|
||||||
|
|
||||||
from app.schemas.forgejo_issues import CloseIssueResponse
|
|
||||||
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{board_id}/git/issues/{issue_id}/close",
|
"/{board_id}/git/issues/{issue_id}/close",
|
||||||
response_model=CloseIssueResponse,
|
response_model=CloseIssueResponse,
|
||||||
|
|
@ -318,7 +322,7 @@ async def close_board_issue(
|
||||||
board_id: UUID,
|
board_id: UUID,
|
||||||
issue_id: UUID,
|
issue_id: UUID,
|
||||||
session: AsyncSession = SESSION_DEP,
|
session: AsyncSession = SESSION_DEP,
|
||||||
board: ForgejoIssue = BOARD_READ_DEP,
|
board: Board = BOARD_READ_DEP,
|
||||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||||
) -> CloseIssueResponse:
|
) -> CloseIssueResponse:
|
||||||
"""Close a Forgejo issue as a board-lead agent.
|
"""Close a Forgejo issue as a board-lead agent.
|
||||||
|
|
@ -326,18 +330,26 @@ async def close_board_issue(
|
||||||
Only board lead agents can close issues. The issue must belong to a repository
|
Only board lead agents can close issues. The issue must belong to a repository
|
||||||
linked to the target board.
|
linked to the target board.
|
||||||
"""
|
"""
|
||||||
# Get the issue
|
links = (
|
||||||
|
await session.exec(
|
||||||
|
select(BoardRepositoryLink).where(
|
||||||
|
BoardRepositoryLink.organization_id == board.organization_id,
|
||||||
|
BoardRepositoryLink.board_id == board_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
repository_ids = [link.repository_id for link in links]
|
||||||
|
if not repository_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Issue not found or not linked to this board",
|
||||||
|
)
|
||||||
|
|
||||||
statement = select(ForgejoIssue).where(
|
statement = select(ForgejoIssue).where(
|
||||||
and_(
|
and_(
|
||||||
ForgejoIssue.id == issue_id,
|
ForgejoIssue.id == issue_id,
|
||||||
ForgejoIssue.repository_id.in_(
|
ForgejoIssue.repository_id.in_(repository_ids),
|
||||||
[link.repository_id for link in await session.exec(
|
),
|
||||||
select(BoardRepositoryLink).where(
|
|
||||||
BoardRepositoryLink.board_id == board_id
|
|
||||||
)
|
|
||||||
).all()]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
issue = (await session.exec(statement)).first()
|
issue = (await session.exec(statement)).first()
|
||||||
|
|
||||||
|
|
@ -347,14 +359,13 @@ async def close_board_issue(
|
||||||
detail="Issue not found or not linked to this board",
|
detail="Issue not found or not linked to this board",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify agent is board lead
|
# Verify agent is board lead.
|
||||||
from app.core.openclaw.policies import OpenClawAuthorizationPolicy
|
|
||||||
OpenClawAuthorizationPolicy.require_board_lead_actor(
|
OpenClawAuthorizationPolicy.require_board_lead_actor(
|
||||||
actor_agent=agent_ctx.agent,
|
actor_agent=agent_ctx.agent,
|
||||||
detail="Only board leads can close issues",
|
detail="Only board leads can close issues",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Close the issue using the service
|
# Close the issue using the service.
|
||||||
try:
|
try:
|
||||||
result = await close_issue_by_id(
|
result = await close_issue_by_id(
|
||||||
session=session,
|
session=session,
|
||||||
|
|
@ -368,6 +379,19 @@ async def close_board_issue(
|
||||||
except CloseIssueRemoteError as e:
|
except CloseIssueRemoteError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||||
|
|
||||||
|
repository_full_name = str(result.get("repository_full_name") or "unknown/unknown")
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="forgejo.issue.closed",
|
||||||
|
message=(
|
||||||
|
"Forgejo issue closed by agent "
|
||||||
|
f"{agent_ctx.agent.id}: {repository_full_name}#{result['forgejo_issue_number']}"
|
||||||
|
),
|
||||||
|
board_id=board_id,
|
||||||
|
agent_id=agent_ctx.agent.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return CloseIssueResponse(
|
return CloseIssueResponse(
|
||||||
success=result["success"],
|
success=result["success"],
|
||||||
issue_id=result["issue_id"],
|
issue_id=result["issue_id"],
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,16 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import cast, String
|
from sqlalchemy import cast, String
|
||||||
from sqlmodel import select, func
|
from sqlmodel import select, func
|
||||||
|
|
||||||
from app.api.deps import get_board_for_user_write, require_org_member
|
from app.api.deps import require_org_member
|
||||||
from app.core.auth import AuthContext, get_auth_context
|
from app.core.auth import AuthContext, get_auth_context
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.board_repository_links import BoardRepositoryLink
|
from app.models.board_repository_links import BoardRepositoryLink
|
||||||
from app.models.forgejo_issues import ForgejoIssue
|
from app.models.forgejo_issues import ForgejoIssue
|
||||||
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse
|
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse
|
||||||
|
from app.services.activity_log import record_activity
|
||||||
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
||||||
from app.services.organizations import OrganizationContext
|
from app.services.organizations import OrganizationContext, list_accessible_board_ids
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
@ -235,28 +236,40 @@ async def close_issue(
|
||||||
issue = await crud.get_by_id(session, ForgejoIssue, uuid)
|
issue = await crud.get_by_id(session, ForgejoIssue, uuid)
|
||||||
if issue is None:
|
if issue is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
||||||
|
if issue.organization_id != ctx.organization.id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
||||||
|
if auth.user is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
# Get the board linked to this issue's repository
|
# Get boards linked to this issue's repository for this organization.
|
||||||
link_statement = select(BoardRepositoryLink).where(
|
links = (
|
||||||
BoardRepositoryLink.repository_id == issue.repository_id,
|
await session.exec(
|
||||||
)
|
select(BoardRepositoryLink).where(
|
||||||
link = (await session.exec(link_statement)).first()
|
BoardRepositoryLink.organization_id == ctx.organization.id,
|
||||||
if link is None:
|
BoardRepositoryLink.repository_id == issue.repository_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
if not links:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail="Issue repository is not linked to any board",
|
detail="Issue repository is not linked to any board",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify the user has write access to the board
|
allowed_board_ids = set(
|
||||||
await get_board_for_user_write(
|
await list_accessible_board_ids(session, member=ctx.member, write=True)
|
||||||
board_id=str(link.board_id),
|
|
||||||
session=session,
|
|
||||||
auth=auth,
|
|
||||||
)
|
)
|
||||||
if auth.user is None:
|
authorized_board_id = next(
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
(link.board_id for link in links if link.board_id in allowed_board_ids),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if authorized_board_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Board access denied",
|
||||||
|
)
|
||||||
|
|
||||||
# Close the issue using the service
|
# Close the issue using the service.
|
||||||
try:
|
try:
|
||||||
result = await close_issue_by_id(
|
result = await close_issue_by_id(
|
||||||
session=session,
|
session=session,
|
||||||
|
|
@ -270,6 +283,18 @@ async def close_issue(
|
||||||
except CloseIssueRemoteError as e:
|
except CloseIssueRemoteError as e:
|
||||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||||
|
|
||||||
|
repository_full_name = str(result.get("repository_full_name") or "unknown/unknown")
|
||||||
|
record_activity(
|
||||||
|
session,
|
||||||
|
event_type="forgejo.issue.closed",
|
||||||
|
message=(
|
||||||
|
"Forgejo issue closed by user "
|
||||||
|
f"{auth.user.id}: {repository_full_name}#{result['forgejo_issue_number']}"
|
||||||
|
),
|
||||||
|
board_id=authorized_board_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
return CloseIssueResponse(
|
return CloseIssueResponse(
|
||||||
success=result["success"],
|
success=result["success"],
|
||||||
issue_id=result["issue_id"],
|
issue_id=result["issue_id"],
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ async def close_cached_issue(
|
||||||
# Close issue on Forgejo first
|
# Close issue on Forgejo first
|
||||||
try:
|
try:
|
||||||
async with get_forgejo_client(connection) as client:
|
async with get_forgejo_client(connection) as client:
|
||||||
result = await client.close_issue(
|
await client.close_issue(
|
||||||
owner=repository.owner,
|
owner=repository.owner,
|
||||||
repo=repository.repo,
|
repo=repository.repo,
|
||||||
issue_number=issue.forgejo_issue_number,
|
issue_number=issue.forgejo_issue_number,
|
||||||
|
|
@ -111,6 +111,8 @@ async def close_cached_issue(
|
||||||
"success": True,
|
"success": True,
|
||||||
"issue_id": str(issue.id),
|
"issue_id": str(issue.id),
|
||||||
"forgejo_issue_number": issue.forgejo_issue_number,
|
"forgejo_issue_number": issue.forgejo_issue_number,
|
||||||
|
"repository_id": str(repository.id),
|
||||||
|
"repository_full_name": f"{repository.owner}/{repository.repo}",
|
||||||
"state": "closed",
|
"state": "closed",
|
||||||
"forgejo_closed_at": issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None,
|
"forgejo_closed_at": issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None,
|
||||||
"last_synced_at": issue.last_synced_at.isoformat(),
|
"last_synced_at": issue.last_synced_at.isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,407 @@
|
||||||
|
# ruff: noqa: INP001
|
||||||
|
"""Integration tests for Forgejo issue close endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import 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
|
||||||
|
from app.api import deps as deps_api
|
||||||
|
from app.api.agent_forgejo import router as agent_forgejo_router
|
||||||
|
from app.api.forgejo_issues import router as forgejo_issues_router
|
||||||
|
from app.api.deps import require_org_member
|
||||||
|
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context
|
||||||
|
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_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.users import User
|
||||||
|
from app.services import forgejo_issue_close
|
||||||
|
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 | None = None,
|
||||||
|
auth_ctx: AuthContext | None = None,
|
||||||
|
agent_ctx: AgentAuthContext | None = None,
|
||||||
|
board: Board | None = None,
|
||||||
|
) -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
api_v1 = APIRouter(prefix="/api/v1")
|
||||||
|
api_v1.include_router(forgejo_issues_router)
|
||||||
|
api_v1.include_router(agent_forgejo_router)
|
||||||
|
app.include_router(api_v1)
|
||||||
|
|
||||||
|
async def _override_get_session() -> AsyncSession:
|
||||||
|
async with session_maker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
app.dependency_overrides[get_session] = _override_get_session
|
||||||
|
|
||||||
|
if org_ctx is not None:
|
||||||
|
async def _override_require_org_member() -> OrganizationContext:
|
||||||
|
return org_ctx
|
||||||
|
|
||||||
|
app.dependency_overrides[require_org_member] = _override_require_org_member
|
||||||
|
|
||||||
|
if auth_ctx is not None:
|
||||||
|
async def _override_get_auth_context() -> AuthContext:
|
||||||
|
return auth_ctx
|
||||||
|
|
||||||
|
app.dependency_overrides[get_auth_context] = _override_get_auth_context
|
||||||
|
|
||||||
|
if agent_ctx is not None:
|
||||||
|
async def _override_get_agent_auth_context() -> AgentAuthContext:
|
||||||
|
return agent_ctx
|
||||||
|
|
||||||
|
app.dependency_overrides[get_agent_auth_context] = _override_get_agent_auth_context
|
||||||
|
|
||||||
|
if board is not None:
|
||||||
|
async def _override_get_board_for_actor_read() -> Board:
|
||||||
|
return board
|
||||||
|
|
||||||
|
app.dependency_overrides[deps_api.get_board_for_actor_read] = _override_get_board_for_actor_read
|
||||||
|
|
||||||
|
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="Cached issue",
|
||||||
|
body_preview="Cached body",
|
||||||
|
state="open",
|
||||||
|
is_pull_request=False,
|
||||||
|
labels=[],
|
||||||
|
assignees=[],
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
lead_agent = Agent(
|
||||||
|
id=uuid4(),
|
||||||
|
board_id=board.id,
|
||||||
|
gateway_id=gateway.id,
|
||||||
|
name="Lead Agent",
|
||||||
|
is_board_lead=True,
|
||||||
|
)
|
||||||
|
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(lead_agent)
|
||||||
|
session.add(worker_agent)
|
||||||
|
await session.commit()
|
||||||
|
return SimpleNamespace(
|
||||||
|
org=org,
|
||||||
|
user=user,
|
||||||
|
member=member,
|
||||||
|
board=board,
|
||||||
|
issue=issue,
|
||||||
|
lead_agent=lead_agent,
|
||||||
|
worker_agent=worker_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _StubForgejoClient:
|
||||||
|
def __init__(self, *, should_fail: bool) -> None:
|
||||||
|
self._should_fail = should_fail
|
||||||
|
|
||||||
|
async def __aenter__(self) -> _StubForgejoClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def close_issue(self, **_kwargs: object) -> dict[str, object]:
|
||||||
|
if self._should_fail:
|
||||||
|
raise RuntimeError("forgejo down")
|
||||||
|
return {"state": "closed"}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_forgejo_client(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
*,
|
||||||
|
should_fail: bool,
|
||||||
|
) -> None:
|
||||||
|
def _factory(_connection: object) -> _StubForgejoClient:
|
||||||
|
return _StubForgejoClient(should_fail=should_fail)
|
||||||
|
|
||||||
|
monkeypatch.setattr(forgejo_issue_close, "get_forgejo_client", _factory)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_human_writer_can_close_linked_issue_and_records_activity(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
_patch_forgejo_client(monkeypatch, should_fail=False)
|
||||||
|
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)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(f"/api/v1/forgejo/issues/{seeded.issue.id}/close")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["state"] == "closed"
|
||||||
|
assert payload["forgejo_issue_number"] == 42
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
stored_issue = await session.get(ForgejoIssue, seeded.issue.id)
|
||||||
|
assert stored_issue is not None
|
||||||
|
assert stored_issue.state == "closed"
|
||||||
|
assert stored_issue.forgejo_closed_at is not None
|
||||||
|
assert stored_issue.last_synced_at is not None
|
||||||
|
|
||||||
|
events = (await session.exec(select(ActivityEvent))).all()
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.event_type == "forgejo.issue.closed"
|
||||||
|
assert event.board_id == seeded.board.id
|
||||||
|
assert event.agent_id is None
|
||||||
|
assert event.message is not None
|
||||||
|
assert f"user {seeded.user.id}" in event.message
|
||||||
|
assert "openclaw/pipeline#42" in event.message
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_board_lead_agent_can_close_linked_issue_and_records_activity(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
_patch_forgejo_client(monkeypatch, should_fail=False)
|
||||||
|
agent_ctx = AgentAuthContext(actor_type="agent", agent=seeded.lead_agent)
|
||||||
|
app = _build_test_app(
|
||||||
|
session_maker,
|
||||||
|
agent_ctx=agent_ctx,
|
||||||
|
board=seeded.board,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/agent/boards/{seeded.board.id}/git/issues/{seeded.issue.id}/close"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["state"] == "closed"
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
stored_issue = await session.get(ForgejoIssue, seeded.issue.id)
|
||||||
|
assert stored_issue is not None
|
||||||
|
assert stored_issue.state == "closed"
|
||||||
|
|
||||||
|
events = (await session.exec(select(ActivityEvent))).all()
|
||||||
|
assert len(events) == 1
|
||||||
|
event = events[0]
|
||||||
|
assert event.event_type == "forgejo.issue.closed"
|
||||||
|
assert event.board_id == seeded.board.id
|
||||||
|
assert event.agent_id == seeded.lead_agent.id
|
||||||
|
assert event.message is not None
|
||||||
|
assert f"agent {seeded.lead_agent.id}" in event.message
|
||||||
|
assert "openclaw/pipeline#42" in event.message
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_worker_agent_cannot_close_issue(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
_patch_forgejo_client(monkeypatch, should_fail=False)
|
||||||
|
worker_ctx = AgentAuthContext(actor_type="agent", agent=seeded.worker_agent)
|
||||||
|
app = _build_test_app(
|
||||||
|
session_maker,
|
||||||
|
agent_ctx=worker_ctx,
|
||||||
|
board=seeded.board,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/agent/boards/{seeded.board.id}/git/issues/{seeded.issue.id}/close"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json()["detail"] == "Only board leads can close issues"
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
stored_issue = await session.get(ForgejoIssue, seeded.issue.id)
|
||||||
|
assert stored_issue is not None
|
||||||
|
assert stored_issue.state == "open"
|
||||||
|
events = (await session.exec(select(ActivityEvent))).all()
|
||||||
|
assert events == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forgejo_close_failure_does_not_update_local_issue(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> 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)
|
||||||
|
|
||||||
|
_patch_forgejo_client(monkeypatch, should_fail=True)
|
||||||
|
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)
|
||||||
|
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=app),
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
response = await client.post(f"/api/v1/forgejo/issues/{seeded.issue.id}/close")
|
||||||
|
|
||||||
|
assert response.status_code == 502
|
||||||
|
assert "Failed to close issue on Forgejo" in response.json()["detail"]
|
||||||
|
|
||||||
|
async with session_maker() as session:
|
||||||
|
stored_issue = await session.get(ForgejoIssue, seeded.issue.id)
|
||||||
|
assert stored_issue is not None
|
||||||
|
assert stored_issue.state == "open"
|
||||||
|
assert stored_issue.forgejo_closed_at is None
|
||||||
|
events = (await session.exec(select(ActivityEvent))).all()
|
||||||
|
assert events == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
Loading…
Reference in New Issue