Pipeline/backend/app/api/agent_forgejo.py

379 lines
13 KiB
Python

"""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 ActorContext, 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.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
if TYPE_CHECKING:
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.agents import Agent
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)
from app.schemas.forgejo_issues import CloseIssueResponse
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
@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: ForgejoIssue = 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.
"""
# Get the issue
statement = select(ForgejoIssue).where(
and_(
ForgejoIssue.id == issue_id,
ForgejoIssue.repository_id.in_(
[link.repository_id for link in await session.exec(
select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id
)
).all()]
),
)
)
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
from app.core.openclaw.policies import OpenClawAuthorizationPolicy
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))
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 "",
)