"""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 String, cast from sqlmodel import func, select from app.api.deps import require_org_member from app.core.auth import AuthContext, get_auth_context from app.core.time import utcnow from app.db import crud from app.db.session import get_session from app.models.agents import Agent from app.models.board_repository_links import BoardRepositoryLink from app.models.boards import Board from app.models.forgejo_issue_task_links import ForgejoIssueTaskLink from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_repositories import ForgejoRepository from app.models.tasks import Task from app.schemas.forgejo_issues import ( CloseIssueResponse, EditIssueRequest, EditIssueResponse, ForgejoIssueDetailRead, ForgejoIssueListResponse, ForgejoIssueRead, ForgejoIssueTaskRequest, ForgejoIssueTaskResponse, PostCommentRequest, PostCommentResponse, ) 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.forgejo_issue_comment import ( PostCommentNotFoundError, PostCommentRemoteError, post_comment_by_issue_id, ) from app.services.forgejo_issue_edit import ( EditIssueNotFoundError, EditIssueRemoteError, edit_issue_by_id, ) 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) async def _linked_issue_board_ids( session: AsyncSession, *, issue: ForgejoIssue, organization_id: UUID, ) -> list[UUID]: rows = ( await session.exec( select(BoardRepositoryLink.board_id).where( BoardRepositoryLink.organization_id == organization_id, BoardRepositoryLink.repository_id == issue.repository_id, ) ) ).all() return list(rows) async def _resolve_issue_task_board( session: AsyncSession, *, issue: ForgejoIssue, requested_board_id: UUID | None, ctx: OrganizationContext, ) -> Board: linked_board_ids = await _linked_issue_board_ids( session, issue=issue, organization_id=ctx.organization.id, ) if not linked_board_ids: 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_ids = [ board_id for board_id in linked_board_ids if board_id in allowed_board_ids ] if not authorized_board_ids: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Board access denied", ) if requested_board_id is None: if len(authorized_board_ids) > 1: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="board_id is required because this issue is linked to multiple writable boards", ) board_id = authorized_board_ids[0] else: if requested_board_id not in linked_board_ids: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Issue repository is not linked to the requested board", ) if requested_board_id not in allowed_board_ids: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Board access denied", ) board_id = requested_board_id board = await crud.get_by_id(session, Board, board_id) if board is None or board.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found") return board async def _validate_issue_task_agent( session: AsyncSession, *, board: Board, assigned_agent_id: UUID | None, ) -> Agent | None: if assigned_agent_id is None: return None agent = await crud.get_by_id(session, Agent, assigned_agent_id) if agent is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") if agent.board_id != board.id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Agent must belong to the selected board", ) if board.gateway_id is not None and agent.gateway_id not in {None, board.gateway_id}: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Agent gateway does not match the selected board", ) return agent async def _issue_repository_name( session: AsyncSession, *, issue: ForgejoIssue, ) -> str: repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id) if repository is None: return str(issue.repository_id) return repository.display_name or f"{repository.owner}/{repository.repo}" def _task_status_for_issue_task( payload: ForgejoIssueTaskRequest, *, assigned_agent_id: UUID | None, ) -> str: if payload.status is not None: return payload.status if assigned_agent_id is not None and payload.start_immediately: return "in_progress" return "inbox" def _issue_task_title(issue: ForgejoIssue) -> str: return f"Git issue #{issue.forgejo_issue_number}: {issue.title}" def _label_names(issue: ForgejoIssue) -> str: names = [ str(label.get("name")) for label in issue.labels if isinstance(label, dict) and label.get("name") ] return ", ".join(names) def _issue_task_description( *, issue: ForgejoIssue, repository_name: str, instructions: str | None, ) -> str: parts = [ "Source: Forgejo issue", f"Repository: {repository_name}", f"Issue: #{issue.forgejo_issue_number}", f"URL: {issue.html_url}", f"State: {issue.state}", ] labels = _label_names(issue) if labels: parts.append(f"Labels: {labels}") if issue.author: parts.append(f"Author: {issue.author}") if instructions and instructions.strip(): parts.append(f"\nInstructions:\n{instructions.strip()}") if issue.body: parts.append(f"\nIssue body:\n{issue.body.strip()}") elif issue.body_preview: parts.append(f"\nIssue preview:\n{issue.body_preview.strip()}") return "\n".join(parts) async def _existing_issue_task( session: AsyncSession, *, issue_id: UUID, board_id: UUID, ) -> tuple[ForgejoIssueTaskLink, Task] | None: link = ( await session.exec( select(ForgejoIssueTaskLink).where( ForgejoIssueTaskLink.issue_id == issue_id, ForgejoIssueTaskLink.board_id == board_id, ) ) ).first() if link is None: return None task = await crud.get_by_id(session, Task, link.task_id) if task is None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Issue task link points to a missing task", ) return link, task async def _notify_issue_task_assignee( *, session: AsyncSession, board: Board, task: Task, agent: Agent | None, ) -> None: if agent is None: return from app.api.tasks import notify_agent_on_task_assign await notify_agent_on_task_assign( session=session, board=board, task=task, agent=agent, ) def _normalize_issue_task_priority(priority: str) -> str: normalized = priority.strip().lower() return normalized or "medium" def _set_issue_task_status_fields(task: Task, status_value: str) -> None: previous_status = task.status task.status = status_value if status_value == "in_progress": if task.in_progress_at is None or previous_status != "in_progress": task.in_progress_at = utcnow() return if status_value == "inbox": task.in_progress_at = None def _issue_task_response( *, created: bool, issue: ForgejoIssue, task: Task, board_id: UUID, ) -> ForgejoIssueTaskResponse: return ForgejoIssueTaskResponse( success=True, created=created, issue_id=issue.id, task_id=task.id, board_id=board_id, assigned_agent_id=task.assigned_agent_id, status=task.status, title=task.title, ) @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=ForgejoIssueDetailRead) async def get_issue( issue_id: str, session: AsyncSession = SESSION_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoIssueDetailRead: """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 ForgejoIssueDetailRead.model_validate(issue) @router.post( "/{issue_id}/task", response_model=ForgejoIssueTaskResponse, summary="Create or update a Pipeline task for a Forgejo issue", responses={ status.HTTP_200_OK: {"description": "Task created or existing task updated"}, status.HTTP_401_UNAUTHORIZED: {"description": "User authentication required"}, status.HTTP_403_FORBIDDEN: {"description": "User lacks board write access"}, status.HTTP_404_NOT_FOUND: {"description": "Issue, board, or agent not found"}, status.HTTP_409_CONFLICT: {"description": "Existing link points to a missing task"}, status.HTTP_422_UNPROCESSABLE_CONTENT: { "description": "Invalid issue, board, agent, or tasking request" }, }, ) async def task_issue( issue_id: str, payload: ForgejoIssueTaskRequest | None = None, session: AsyncSession = SESSION_DEP, auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoIssueTaskResponse: """Create or update a board task from a cached Forgejo issue.""" payload = payload or ForgejoIssueTaskRequest() 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 or issue.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found") if issue.is_pull_request: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Pull requests cannot be tasked through the issue tasking endpoint", ) if auth.user is None: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) board = await _resolve_issue_task_board( session, issue=issue, requested_board_id=payload.board_id, ctx=ctx, ) agent_requested = "assigned_agent_id" in payload.model_fields_set status_requested = "status" in payload.model_fields_set instructions_requested = "instructions" in payload.model_fields_set priority_requested = "priority" in payload.model_fields_set agent = await _validate_issue_task_agent( session, board=board, assigned_agent_id=payload.assigned_agent_id if agent_requested else None, ) existing = await _existing_issue_task(session, issue_id=issue.id, board_id=board.id) if existing is not None: _link, task = existing previous_assignee_id = task.assigned_agent_id previous_status = task.status if agent_requested: task.assigned_agent_id = agent.id if agent is not None else None if status_requested: _set_issue_task_status_fields(task, payload.status or "inbox") elif agent_requested and agent is not None and payload.start_immediately: _set_issue_task_status_fields(task, "in_progress") elif agent_requested and agent is None: _set_issue_task_status_fields(task, "inbox") if priority_requested: task.priority = _normalize_issue_task_priority(payload.priority) if instructions_requested: repository_name = await _issue_repository_name(session, issue=issue) task.description = _issue_task_description( issue=issue, repository_name=repository_name, instructions=payload.instructions, ) task.updated_at = utcnow() session.add(task) record_activity( session, event_type="forgejo.issue.task.updated", task_id=task.id, board_id=board.id, message=f"Forgejo issue task updated: #{issue.forgejo_issue_number}.", ) await session.commit() await session.refresh(task) notify_agent = agent if notify_agent is None and task.assigned_agent_id and task.status == "in_progress": current_agent = await crud.get_by_id(session, Agent, task.assigned_agent_id) if current_agent is not None and current_agent.board_id == board.id: notify_agent = current_agent should_notify = ( notify_agent is not None and task.assigned_agent_id is not None and ( task.assigned_agent_id != previous_assignee_id or (previous_status != "in_progress" and task.status == "in_progress") ) ) if notify_agent is not None and should_notify: await _notify_issue_task_assignee( session=session, board=board, task=task, agent=notify_agent, ) return _issue_task_response(created=False, issue=issue, task=task, board_id=board.id) status_value = _task_status_for_issue_task( payload, assigned_agent_id=agent.id if agent is not None else None, ) repository_name = await _issue_repository_name(session, issue=issue) task = Task( board_id=board.id, title=_issue_task_title(issue), description=_issue_task_description( issue=issue, repository_name=repository_name, instructions=payload.instructions, ), status=status_value, priority=_normalize_issue_task_priority(payload.priority), assigned_agent_id=agent.id if agent is not None else None, created_by_user_id=auth.user.id, auto_created=True, auto_reason=f"forgejo_issue:{issue.id}", in_progress_at=utcnow() if status_value == "in_progress" else None, ) session.add(task) await session.flush() session.add( ForgejoIssueTaskLink( organization_id=ctx.organization.id, board_id=board.id, issue_id=issue.id, task_id=task.id, created_by_user_id=auth.user.id, ) ) record_activity( session, event_type="forgejo.issue.task.created", task_id=task.id, board_id=board.id, message=f"Forgejo issue task created: #{issue.forgejo_issue_number}.", ) await session.commit() await session.refresh(task) await _notify_issue_task_assignee( session=session, board=board, task=task, agent=agent, ) return _issue_task_response(created=True, issue=issue, task=task, board_id=board.id) @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 "", ) @router.post( "/{issue_id}/comments", response_model=PostCommentResponse, summary="Post a comment on a Forgejo issue", responses={ status.HTTP_404_NOT_FOUND: {"description": "Issue not found"}, status.HTTP_502_BAD_GATEWAY: {"description": "Forgejo API call failed"}, }, ) async def post_comment( issue_id: str, body: PostCommentRequest, session: AsyncSession = SESSION_DEP, auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> PostCommentResponse: """Post a comment on a Forgejo issue as an authenticated user.""" 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 or 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) try: result = await post_comment_by_issue_id( session=session, issue_id=uuid, body=body.body, actor_user_id=auth.user.id, ) except PostCommentNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except PostCommentRemoteError as e: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) await session.commit() return PostCommentResponse( success=True, issue_id=uuid, comment_id=result.get("comment_id"), body=body.body, created_at=str(result.get("created_at") or ""), ) @router.patch( "/{issue_id}", response_model=EditIssueResponse, summary="Edit a Forgejo issue", responses={ status.HTTP_404_NOT_FOUND: {"description": "Issue not found"}, status.HTTP_502_BAD_GATEWAY: {"description": "Forgejo API call failed"}, }, ) async def edit_issue( issue_id: str, body: EditIssueRequest, session: AsyncSession = SESSION_DEP, auth: AuthContext = AUTH_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> EditIssueResponse: """Edit a Forgejo issue's title, body, and/or state.""" 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 or 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) if body.title is None and body.body is None and body.state is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="At least one field must be provided", ) try: result = await edit_issue_by_id( session=session, issue_id=uuid, title=body.title, body=body.body, state=body.state, actor_user_id=auth.user.id, ) except EditIssueNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except EditIssueRemoteError as e: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)) record_activity( session, event_type="forgejo.issue.edited", message=f"Forgejo issue edited by user {auth.user.id}: #{result['forgejo_issue_number']}", ) await session.commit() return EditIssueResponse( success=True, issue_id=uuid, forgejo_issue_number=int(result["forgejo_issue_number"]), title=str(result["title"]), body=result.get("body") if isinstance(result.get("body"), str) else None, state=str(result["state"]), forgejo_updated_at=str(result["forgejo_updated_at"]), )