Pipeline/backend/app/api/forgejo_issues.py

216 lines
8.0 KiB
Python

"""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_admin
from app.core.agent_auth import get_agent_auth_context
from app.core.auth import 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_ADMIN_DEP = Depends(require_org_admin)
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
@router.get("", response_model=ForgejoIssueListResponse)
async def list_issues(
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_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_ADMIN_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,
ctx: OrganizationContext = ORG_ADMIN_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
board = await get_board_for_user_write(
board_id=str(link.board_id),
session=session,
auth=ctx,
)
# Close the issue using the service
try:
result = await close_issue_by_id(
session=session,
issue_id=uuid,
actor_user_id=ctx.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 "",
)