"""Agent-scoped Forgejo issue read APIs for board-linked repositories.""" from __future__ import annotations from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlmodel import select, func from sqlalchemy import and_ from app.api.deps import get_board_for_actor_read from app.core.agent_auth import get_agent_auth_context from app.db.session import get_session from app.models.boards import Board from app.models.board_repository_links import BoardRepositoryLink from app.models.forgejo_issues import ForgejoIssue 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: from sqlmodel.ext.asyncio.session import AsyncSession from app.core.agent_auth import AgentAuthContext router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"]) SESSION_DEP = Depends(get_session) BOARD_READ_DEP = Depends(get_board_for_actor_read) AGENT_CTX_DEP = Depends(get_agent_auth_context) def _agent_board_openapi_hints( *, intent: str, when_to_use: list[str], routing_examples: list[dict[str, object]], required_actor: str = "any_agent", when_not_to_use: list[str] | None = None, routing_policy: list[str] | None = None, ) -> dict[str, object]: """Generate LLM routing hints for board-scoped agent endpoints.""" return { "x-llm-intent": intent, "x-when-to-use": when_to_use, "x-when-not-to-use": when_not_to_use or [ "Use a more specific endpoint for direct state mutation or direct messaging.", ], "x-required-actor": required_actor, "x-prerequisites": [ "Authenticated agent token", "Board access is validated before execution", ], "x-side-effects": [ "Read-only access to issues linked to board repositories", ], "x-negative-guidance": [ "Avoid this endpoint when a focused sibling endpoint handles the action.", ], "x-routing-policy": routing_policy or [ "Use when the request intent matches this board-scoped route.", "Prefer dedicated mutation/read routes once intent is narrowed.", ], "x-routing-policy-examples": routing_examples, } @router.get( "/{board_id}/git/issues", response_model=ForgejoIssueListResponse, summary="List issues for board's linked repositories", description=( "List Forgejo issues from repositories linked to the specified board.\n\n" "Use this endpoint when an agent needs to discover issues across all " "repositories associated with its assigned board.\n\n" "LLM Routing Guidance:\n" "- Use when you need to list issues for a specific board's repositories\n" "- Filter by state, search text, or assignee for targeted results\n" "- Use this instead of generic issue lists when board context matters\n" "- Exclude pull requests automatically" ), openapi_extra=_agent_board_openapi_hints( intent="board_issue_discovery", when_to_use=[ "Need to list issues for a specific board's repositories", "Looking for issues to assign or track", "Discovering work items related to a board's scope", ], when_not_to_use=[ "Listing all issues across all repositories (use forgejo issues list)", "Working with issues from unlinked repositories", "Need repository-agnostic issue search", ], routing_examples=[ { "input": { "intent": "list issues for board xyz", "board_id": "uuid", }, "decision": "agent_board_list_issues", }, { "input": { "intent": "find issues assigned to me", "assignee": "me", }, "decision": "agent_board_list_issues (with assignee filter)", }, ], ), ) async def list_board_issues( board_id: UUID, session: AsyncSession = SESSION_DEP, board: ForgejoIssue = BOARD_READ_DEP, state: str | None = Query(default=None, description="Filter by issue state (open/closed)"), search: str | None = Query(default=None, description="Search in title/body"), page: int = Query(default=1, ge=1, description="Page number"), limit: int = Query(default=30, ge=1, le=100, description="Items per page"), ) -> ForgejoIssueListResponse: """List issues for repositories linked to a board.""" # Get linked repositories link_statement = select(BoardRepositoryLink).where( BoardRepositoryLink.board_id == board_id ) links = (await session.exec(link_statement)).all() if not links: return ForgejoIssueListResponse( items=[], total=0, page=page, limit=limit, ) repository_ids = [link.repository_id for link in links] # Build base query statement = select(ForgejoIssue).where( ForgejoIssue.repository_id.in_(repository_ids) ) # Apply filters if state: statement = statement.where(ForgejoIssue.state == state) if search: statement = statement.where( ForgejoIssue.title.ilike(f"%{search}%") | ForgejoIssue.body_preview.ilike(f"%{search}%") ) # Count total count_statement = select(func.count(ForgejoIssue.id)).where( ForgejoIssue.repository_id.in_(repository_ids) ) if state: count_statement = count_statement.where(ForgejoIssue.state == state) if search: count_statement = count_statement.where( ForgejoIssue.title.ilike(f"%{search}%") | ForgejoIssue.body_preview.ilike(f"%{search}%") ) total = await session.scalar(count_statement) or 0 # Apply pagination and execute offset = (page - 1) * limit statement = statement.offset(offset).limit(limit) issues = (await session.exec(statement)).all() return ForgejoIssueListResponse( items=[ForgejoIssueRead.model_validate(issue) for issue in issues], total=total, page=page, limit=limit, ) @router.get( "/{board_id}/git/issues/{issue_id}", response_model=ForgejoIssueRead, summary="Read one issue from board-linked repositories", description=( "Read a specific Forgejo issue by id, ensuring it belongs to a repository " "linked to the specified board.\n\n" "Use this endpoint when an agent needs to inspect a specific issue " "within the context of a board's repositories." ), openapi_extra=_agent_board_openapi_hints( intent="board_issue_inspection", when_to_use=[ "Need to read a specific issue's details", "Verifying issue state before taking action", "Inspecting issue metadata for board context", ], when_not_to_use=[ "Listing multiple issues (use list endpoint)", "Working with issues from unlinked repositories", ], routing_examples=[ { "input": { "intent": "read issue 123 for board xyz", "board_id": "uuid", "issue_id": "uuid", }, "decision": "agent_board_read_issue", }, ], ), ) async def read_board_issue( board_id: UUID, issue_id: UUID, session: AsyncSession = SESSION_DEP, board: ForgejoIssue = BOARD_READ_DEP, ) -> ForgejoIssueRead: """Read one issue from board-linked repositories.""" # Get linked repositories link_statement = select(BoardRepositoryLink).where( BoardRepositoryLink.board_id == board_id ) links = (await session.exec(link_statement)).all() if not links: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="No repositories linked to this board", ) repository_ids = [link.repository_id for link in links] # Find issue statement = select(ForgejoIssue).where( and_( ForgejoIssue.id == issue_id, ForgejoIssue.repository_id.in_(repository_ids), ) ) issue = (await session.exec(statement)).first() if issue is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found or not linked to this board", ) return ForgejoIssueRead.model_validate(issue) @router.post( "/{board_id}/git/issues/{issue_id}/close", response_model=CloseIssueResponse, summary="Close a Forgejo issue (agent)", description=( "Close a Forgejo issue by its local ID as a board-lead agent. " "Only board lead agents can close issues. The issue must belong to a " "repository linked to the target board." ), openapi_extra=_agent_board_openapi_hints( intent="agent_board_close_issue", when_to_use=[ "Lead agent needs to close a specific issue", "Finalizing work items after completion", "Closing issues after deployment or release", ], when_not_to_use=[ "Worker agents closing issues (use human endpoint or ask lead)", "Working with issues from unlinked repositories", ], required_actor="board_lead", routing_examples=[ { "input": { "intent": "close issue 123 for board xyz", "board_id": "uuid", "issue_id": "uuid", }, "decision": "agent_board_close_issue", }, ], ), responses={ status.HTTP_200_OK: { "description": "Issue closed successfully", "content": { "application/json": { "example": { "success": True, "issue_id": "123e4567-e89b-12d3-a456-426614174000", "forgejo_issue_number": 42, "state": "closed", "forgejo_closed_at": "2026-05-19T03:43:00+00:00", "last_synced_at": "2026-05-19T03:43:00+00:00", } } }, }, status.HTTP_404_NOT_FOUND: { "description": "Issue not found or not linked to this board", }, status.HTTP_403_FORBIDDEN: { "description": "Caller is not board lead", }, status.HTTP_409_CONFLICT: { "description": "Organization mismatch or access denied", }, status.HTTP_502_BAD_GATEWAY: { "description": "Forgejo API call failed", }, }, ) async def close_board_issue( board_id: UUID, issue_id: UUID, session: AsyncSession = SESSION_DEP, board: Board = BOARD_READ_DEP, agent_ctx: AgentAuthContext = AGENT_CTX_DEP, ) -> CloseIssueResponse: """Close a Forgejo issue as a board-lead agent. Only board lead agents can close issues. The issue must belong to a repository linked to the target board. """ 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( and_( ForgejoIssue.id == issue_id, ForgejoIssue.repository_id.in_(repository_ids), ), ) issue = (await session.exec(statement)).first() if issue is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found or not linked to this board", ) # Verify agent is board lead. OpenClawAuthorizationPolicy.require_board_lead_actor( actor_agent=agent_ctx.agent, detail="Only board leads can close issues", ) # Close the issue using the service. try: result = await close_issue_by_id( session=session, issue_id=issue_id, actor_agent_id=agent_ctx.agent.id, ) except CloseIssueNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except CloseIssueAccessError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) except CloseIssueRemoteError as 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( success=result["success"], issue_id=result["issue_id"], forgejo_issue_number=result["forgejo_issue_number"], state=result["state"], forgejo_closed_at=result.get("forgejo_closed_at"), last_synced_at=result.get("last_synced_at") or "", )