Pipeline/backend/app/api/forgejo_issues.py

928 lines
32 KiB
Python
Raw Normal View History

"""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
2026-05-21 22:47:24 -05:00
from sqlalchemy import String, cast
from sqlmodel import func, select
2026-05-20 03:49:57 -05:00
from app.api.deps import require_org_member
from app.core.auth import AuthContext, get_auth_context
2026-05-22 00:12:15 -05:00
from app.core.time import utcnow
from app.db import crud
from app.db.session import get_session
2026-05-22 00:12:15 -05:00
from app.models.agents import Agent
from app.models.board_repository_links import BoardRepositoryLink
2026-05-22 00:12:15 -05:00
from app.models.boards import Board
from app.models.forgejo_issue_task_links import ForgejoIssueTaskLink
from app.models.forgejo_issues import ForgejoIssue
2026-05-22 00:12:15 -05:00
from app.models.forgejo_repositories import ForgejoRepository
from app.models.tasks import Task
2026-05-21 22:47:24 -05:00
from app.schemas.forgejo_issues import (
CloseIssueResponse,
2026-05-22 01:44:39 -05:00
CreateIssueRequest,
CreateIssueResponse,
2026-05-21 23:30:19 -05:00
EditIssueRequest,
EditIssueResponse,
2026-05-21 22:47:24 -05:00
ForgejoIssueDetailRead,
ForgejoIssueListResponse,
ForgejoIssueRead,
2026-05-22 00:12:15 -05:00
ForgejoIssueTaskRequest,
ForgejoIssueTaskResponse,
2026-05-21 23:30:19 -05:00
PostCommentRequest,
PostCommentResponse,
2026-05-21 22:47:24 -05:00
)
2026-05-20 03:49:57 -05:00
from app.services.activity_log import record_activity
2026-05-22 01:44:39 -05:00
from app.services.forgejo_issue_create import (
CreateIssueNotFoundError,
CreateIssueRemoteError,
create_issue_on_repository,
)
2026-05-21 22:47:24 -05:00
from app.services.forgejo_issue_close import (
CloseIssueAccessError,
CloseIssueNotFoundError,
CloseIssueRemoteError,
close_issue_by_id,
)
2026-05-21 23:30:19 -05:00
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,
)
2026-05-20 03:49:57 -05:00
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)
2026-05-22 00:12:15 -05:00
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"),
2026-05-21 22:47:24 -05:00
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
2026-05-20 03:09:22 -05:00
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:
2026-05-21 22:47:24 -05:00
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:
2026-05-21 22:47:24 -05:00
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)
2026-05-20 01:44:31 -05:00
if label:
# Filter by label name — search within the JSON labels array cast to text
2026-05-21 22:47:24 -05:00
statement = statement.where(cast(ForgejoIssue.labels, String).ilike(f"%{label}%"))
2026-05-20 01:44:31 -05:00
if assignee:
# Filter by assignee login — search within the JSON assignees array cast to text
2026-05-21 22:47:24 -05:00
statement = statement.where(cast(ForgejoIssue.assignees, String).ilike(f"%{assignee}%"))
2026-05-20 01:44:31 -05:00
if search:
statement = statement.where(
2026-05-21 22:47:24 -05:00
(ForgejoIssue.title.ilike(f"%{search}%"))
| (ForgejoIssue.body_preview.ilike(f"%{search}%"))
| (ForgejoIssue.body.ilike(f"%{search}%"))
)
# Count total
2026-05-21 22:47:24 -05:00
total_statement = (
select(func.count())
.select_from(ForgejoIssue)
.where(
ForgejoIssue.organization_id == ctx.organization.id,
ForgejoIssue.is_pull_request.is_(False),
)
2026-05-20 03:09:22 -05:00
)
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)
2026-05-20 01:44:31 -05:00
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(
2026-05-21 22:47:24 -05:00
(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
2026-05-21 22:47:24 -05:00
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,
)
2026-05-21 22:47:24 -05:00
@router.get("/{issue_id}", response_model=ForgejoIssueDetailRead)
async def get_issue(
issue_id: str,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
2026-05-21 22:47:24 -05:00
) -> ForgejoIssueDetailRead:
"""Get one cached issue by ID."""
try:
uuid = UUID(issue_id)
except ValueError:
2026-05-21 22:47:24 -05:00
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)
2026-05-21 22:47:24 -05:00
return ForgejoIssueDetailRead.model_validate(issue)
2026-05-22 00:12:15 -05:00
@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.
2026-05-21 22:47:24 -05:00
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:
2026-05-21 22:47:24 -05:00
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")
2026-05-20 03:49:57 -05:00
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)
2026-05-20 03:49:57 -05:00
# 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",
)
2026-05-21 22:47:24 -05:00
allowed_board_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=True))
2026-05-20 03:49:57 -05:00
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",
)
2026-05-20 03:49:57 -05:00
# 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))
2026-05-20 03:49:57 -05:00
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 "",
)
2026-05-21 23:30:19 -05:00
@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"]),
)
2026-05-22 01:44:39 -05:00
@router.post(
"",
response_model=CreateIssueResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a new Forgejo issue",
responses={
status.HTTP_404_NOT_FOUND: {"description": "Repository not found"},
status.HTTP_502_BAD_GATEWAY: {"description": "Forgejo API call failed"},
},
)
async def create_issue(
body: CreateIssueRequest,
session: AsyncSession = SESSION_DEP,
auth: AuthContext = AUTH_DEP,
ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> CreateIssueResponse:
"""Create a new issue on a Forgejo repository and cache it locally."""
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
repository = await crud.get_by_id(session, ForgejoRepository, body.repository_id)
if repository is None or repository.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Repository not found")
try:
issue = await create_issue_on_repository(
session,
repository=repository,
title=body.title,
body=body.body,
)
except CreateIssueNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except CreateIssueRemoteError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
record_activity(
session,
event_type="forgejo.issue.created",
message=f"Issue created by user {auth.user.id}: {body.title}",
)
await session.commit()
return CreateIssueResponse(
success=True,
issue_id=issue.id,
forgejo_issue_number=issue.forgejo_issue_number,
html_url=issue.html_url,
repository_id=issue.repository_id,
)