"""API endpoints for Forgejo issue operations.""" 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 app.api.deps import get_board_for_user_write, require_org_member from app.core.auth import AuthContext, get_auth_context from app.db import crud 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 ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError from app.services.organizations import OrganizationContext if TYPE_CHECKING: from sqlmodel.ext.asyncio.session import AsyncSession router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"]) SESSION_DEP = Depends(get_session) AUTH_DEP = Depends(get_auth_context) ORG_MEMBER_DEP = Depends(require_org_member) @router.get("", response_model=ForgejoIssueListResponse) async def list_issues( session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, repository_id: str | None = Query(None, description="Filter by repository ID"), state: str | None = Query(None, description="Filter by state (open, closed)"), label: str | None = Query(None, description="Filter by label name"), assignee: str | None = Query(None, description="Filter by assignee login"), search: str | None = Query(None, description="Search in title and body"), page: int = Query(1, ge=1, description="Page number"), limit: int = Query(30, ge=1, le=100, description="Items per page"), ) -> ForgejoIssueListResponse: """List cached issues with optional filters.""" # Build query with filters statement = select(ForgejoIssue).where(ForgejoIssue.organization_id == ctx.organization.id) if repository_id: try: repo_uuid = UUID(repository_id) except ValueError: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid repository_id format") statement = statement.where(ForgejoIssue.repository_id == repo_uuid) 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 total_statement = select(func.count()).select_from(ForgejoIssue).where(ForgejoIssue.organization_id == ctx.organization.id) if repository_id: try: repo_uuid = UUID(repository_id) total_statement = total_statement.where(ForgejoIssue.repository_id == repo_uuid) except ValueError: pass if state: total_statement = total_statement.where(ForgejoIssue.state == state) if search: total_statement = total_statement.where( (ForgejoIssue.title.ilike(f"%{search}%")) | (ForgejoIssue.body_preview.ilike(f"%{search}%")) ) total_result = await session.exec(total_statement) total = total_result.one() # Pagination offset = (page - 1) * limit statement = statement.offset(offset).limit(limit).order_by(ForgejoIssue.forgejo_issue_number.desc()) issues = (await session.exec(statement)).all() items = [ForgejoIssueRead.model_validate(issue) for issue in issues] return ForgejoIssueListResponse( items=items, total=total, page=page, limit=limit, ) @router.get("/{issue_id}", response_model=ForgejoIssueRead) async def get_issue( issue_id: str, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoIssueRead: """Get one cached issue by ID.""" try: uuid = UUID(issue_id) except ValueError: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format") issue = await crud.get_by_id(session, ForgejoIssue, uuid) if issue is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) if issue.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) return ForgejoIssueRead.model_validate(issue) @router.post( "/{issue_id}/close", response_model=CloseIssueResponse, summary="Close a Forgejo issue (human user)", description=( "Close a Forgejo issue by its local ID. The user must have write access " "to the board that the issue's repository is linked to." ), 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 a board", }, status.HTTP_403_FORBIDDEN: { "description": "User lacks write access to the board", }, status.HTTP_409_CONFLICT: { "description": "Organization mismatch or access denied", }, status.HTTP_502_BAD_GATEWAY: { "description": "Forgejo API call failed", }, }, ) async def close_issue( issue_id: str, session: AsyncSession = SESSION_DEP, auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> CloseIssueResponse: """Close a Forgejo issue as an authenticated user. The user must have write access to the board that the issue's repository is linked to. The issue must belong to a repository linked to that board. """ try: uuid = UUID(issue_id) except ValueError: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format") issue = await crud.get_by_id(session, ForgejoIssue, uuid) if issue is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found") # Get the board linked to this issue's repository link_statement = select(BoardRepositoryLink).where( BoardRepositoryLink.repository_id == issue.repository_id, ) link = (await session.exec(link_statement)).first() if link is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Issue repository is not linked to any board", ) # Verify the user has write access to the board await get_board_for_user_write( board_id=str(link.board_id), session=session, auth=auth, ) if auth.user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) # Close the issue using the service try: result = await close_issue_by_id( session=session, issue_id=uuid, actor_user_id=auth.user.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 "", )