"""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 sqlalchemy import cast, String from sqlmodel import select, func from app.api.deps import 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.activity_log import record_activity from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError from app.services.organizations import OrganizationContext, list_accessible_board_ids 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"), board_id: str | None = Query(None, description="Filter by board ID (returns issues from all repos linked to this board)"), 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, ForgejoIssue.is_pull_request.is_(False), ) if board_id: try: board_uuid = UUID(board_id) except ValueError: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid board_id format") linked_repo_ids = ( await session.exec( select(BoardRepositoryLink.repository_id).where( BoardRepositoryLink.board_id == board_uuid, BoardRepositoryLink.organization_id == ctx.organization.id, ) ) ).all() if not linked_repo_ids: return ForgejoIssueListResponse(items=[], total=0, page=page, limit=limit) statement = statement.where(ForgejoIssue.repository_id.in_(linked_repo_ids)) 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 label: # Filter by label name — search within the JSON labels array cast to text statement = statement.where( cast(ForgejoIssue.labels, String).ilike(f"%{label}%") ) if assignee: # Filter by assignee login — search within the JSON assignees array cast to text statement = statement.where( cast(ForgejoIssue.assignees, String).ilike(f"%{assignee}%") ) if search: statement = statement.where( (ForgejoIssue.title.ilike(f"%{search}%")) | (ForgejoIssue.body_preview.ilike(f"%{search}%")) | (ForgejoIssue.body.ilike(f"%{search}%")) ) # Count total total_statement = select(func.count()).select_from(ForgejoIssue).where( ForgejoIssue.organization_id == ctx.organization.id, ForgejoIssue.is_pull_request.is_(False), ) if board_id: try: board_uuid = UUID(board_id) linked_repo_ids_for_count = ( await session.exec( select(BoardRepositoryLink.repository_id).where( BoardRepositoryLink.board_id == board_uuid, BoardRepositoryLink.organization_id == ctx.organization.id, ) ) ).all() if linked_repo_ids_for_count: total_statement = total_statement.where( ForgejoIssue.repository_id.in_(linked_repo_ids_for_count) ) except ValueError: pass 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 label: total_statement = total_statement.where( cast(ForgejoIssue.labels, String).ilike(f"%{label}%") ) if assignee: total_statement = total_statement.where( cast(ForgejoIssue.assignees, String).ilike(f"%{assignee}%") ) if search: total_statement = total_statement.where( (ForgejoIssue.title.ilike(f"%{search}%")) | (ForgejoIssue.body_preview.ilike(f"%{search}%")) | (ForgejoIssue.body.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") 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 boards linked to this issue's repository for this organization. links = ( await session.exec( select(BoardRepositoryLink).where( BoardRepositoryLink.organization_id == ctx.organization.id, BoardRepositoryLink.repository_id == issue.repository_id, ) ) ).all() if not links: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Issue repository is not linked to any board", ) allowed_board_ids = set( await list_accessible_board_ids(session, member=ctx.member, write=True) ) authorized_board_id = next( (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. 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)) 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( 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 "", )