From 9300f4b67091fa689ea82b38c1c8653f1caed04c Mon Sep 17 00:00:00 2001 From: null Date: Thu, 21 May 2026 23:30:19 -0500 Subject: [PATCH] fix(ui): edit git --- backend/app/api/forgejo_issues.py | 133 +++++ backend/app/schemas/forgejo_issues.py | 36 ++ backend/app/services/forgejo_client.py | 42 ++ backend/app/services/forgejo_issue_comment.py | 121 +++++ backend/app/services/forgejo_issue_edit.py | 150 ++++++ .../components/git/EditForgejoIssueDialog.tsx | 143 ++++++ .../git/ForgejoIssueDetailDialog.tsx | 461 +++++++++++------- .../src/components/git/ForgejoIssuesTable.tsx | 39 ++ .../git/PostForgejoCommentDialog.tsx | 119 +++++ frontend/src/lib/api-forgejo.ts | 47 ++ 10 files changed, 1107 insertions(+), 184 deletions(-) create mode 100644 backend/app/services/forgejo_issue_comment.py create mode 100644 backend/app/services/forgejo_issue_edit.py create mode 100644 frontend/src/components/git/EditForgejoIssueDialog.tsx create mode 100644 frontend/src/components/git/PostForgejoCommentDialog.tsx diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py index 0fd17d6..bb5a130 100644 --- a/backend/app/api/forgejo_issues.py +++ b/backend/app/api/forgejo_issues.py @@ -17,9 +17,13 @@ from app.models.board_repository_links import BoardRepositoryLink from app.models.forgejo_issues import ForgejoIssue from app.schemas.forgejo_issues import ( CloseIssueResponse, + EditIssueRequest, + EditIssueResponse, ForgejoIssueDetailRead, ForgejoIssueListResponse, ForgejoIssueRead, + PostCommentRequest, + PostCommentResponse, ) from app.services.activity_log import record_activity from app.services.forgejo_issue_close import ( @@ -28,6 +32,16 @@ from app.services.forgejo_issue_close import ( 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: @@ -324,3 +338,122 @@ async def close_issue( 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"]), + ) diff --git a/backend/app/schemas/forgejo_issues.py b/backend/app/schemas/forgejo_issues.py index 6294252..1701874 100644 --- a/backend/app/schemas/forgejo_issues.py +++ b/backend/app/schemas/forgejo_issues.py @@ -79,3 +79,39 @@ class CloseIssueResponse(SQLModel): state: str forgejo_closed_at: str | None = None last_synced_at: str + + +class PostCommentRequest(SQLModel): + """Request body for posting a comment on an issue.""" + + body: str + + +class PostCommentResponse(SQLModel): + """Response for comment post operations.""" + + success: bool + issue_id: UUID + comment_id: int | str | None = None + body: str + created_at: str + + +class EditIssueRequest(SQLModel): + """Request body for editing an issue. All fields are optional.""" + + title: str | None = None + body: str | None = None + state: str | None = None + + +class EditIssueResponse(SQLModel): + """Response for issue edit operations.""" + + success: bool + issue_id: UUID + forgejo_issue_number: int + title: str + body: str | None = None + state: str + forgejo_updated_at: str diff --git a/backend/app/services/forgejo_client.py b/backend/app/services/forgejo_client.py index 0f939bb..ba521f2 100644 --- a/backend/app/services/forgejo_client.py +++ b/backend/app/services/forgejo_client.py @@ -264,6 +264,48 @@ class ForgejoAPIClient: page += 1 return reactions + async def create_comment( + self, + owner: str, + repo: str, + issue_number: int, + body: str, + ) -> dict[str, object]: + """Post a new comment on an issue.""" + client = await self._get_client() + response = await client.post( + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}/comments", + json={"body": body}, + ) + response.raise_for_status() + return response.json() + + async def edit_issue( + self, + owner: str, + repo: str, + issue_number: int, + *, + title: str | None = None, + body: str | None = None, + state: str | None = None, + ) -> dict[str, object]: + """Edit an issue's title, body, and/or state.""" + payload: dict[str, object] = {} + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if state is not None: + payload["state"] = state + client = await self._get_client() + response = await client.patch( + f"/api/v1/repos/{owner}/{repo}/issues/{issue_number}", + json=payload, + ) + response.raise_for_status() + return response.json() + async def list_user_repos( self, limit: int = 50, diff --git a/backend/app/services/forgejo_issue_comment.py b/backend/app/services/forgejo_issue_comment.py new file mode 100644 index 0000000..ad4b39d --- /dev/null +++ b/backend/app/services/forgejo_issue_comment.py @@ -0,0 +1,121 @@ +"""Service for posting comments on Forgejo issues and updating local cache.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import select + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_client import get_forgejo_client + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + +logger = get_logger(__name__) + + +class PostCommentError(Exception): + """Base exception for post comment errors.""" + + +class PostCommentNotFoundError(PostCommentError): + """Raised when issue, repository, or connection is not found.""" + + +class PostCommentRemoteError(PostCommentError): + """Raised when the Forgejo API call fails.""" + + +async def post_comment_on_issue( + session: AsyncSession, + issue: ForgejoIssue, + body: str, + actor_user_id: UUID | None = None, + actor_agent_id: UUID | None = None, +) -> dict[str, object]: + """Post a comment on a Forgejo issue and append it to the local cache. + + Raises: + PostCommentNotFoundError: If repository or connection not found. + PostCommentRemoteError: If the Forgejo API call fails. + """ + repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id) + if repository is None: + raise PostCommentNotFoundError("Repository not found") + + connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) + if connection is None: + raise PostCommentNotFoundError("Connection not found") + + try: + async with get_forgejo_client(connection) as client: + created = await client.create_comment( + owner=repository.owner, + repo=repository.repo, + issue_number=issue.forgejo_issue_number, + body=body, + ) + except Exception as exc: + raise PostCommentRemoteError(f"Failed to post comment on Forgejo: {exc}") from exc + + existing = list(issue.forgejo_comments_payload or []) + existing.append(created) + issue.forgejo_comments_payload = existing + issue.last_synced_at = utcnow() + session.add(issue) + await session.flush() + + raw_created_at = created.get("created_at") + created_at_str = ( + raw_created_at.isoformat() + if isinstance(raw_created_at, datetime) + else str(raw_created_at or "") + ) + + logger.info( + "forgejo.issue.comment.posted", + extra={ + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "repository_id": str(repository.id), + "comment_id": str(created.get("id", "")), + "actor_user_id": str(actor_user_id) if actor_user_id else None, + "actor_agent_id": str(actor_agent_id) if actor_agent_id else None, + }, + ) + + return { + "success": True, + "issue_id": str(issue.id), + "comment_id": created.get("id"), + "body": body, + "created_at": created_at_str, + } + + +async def post_comment_by_issue_id( + session: AsyncSession, + issue_id: UUID, + body: str, + actor_user_id: UUID | None = None, + actor_agent_id: UUID | None = None, +) -> dict[str, object]: + """Post a comment by issue ID.""" + issue = (await session.exec(select(ForgejoIssue).where(ForgejoIssue.id == issue_id))).first() + if issue is None: + raise PostCommentNotFoundError("Issue not found") + return await post_comment_on_issue( + session=session, + issue=issue, + body=body, + actor_user_id=actor_user_id, + actor_agent_id=actor_agent_id, + ) diff --git a/backend/app/services/forgejo_issue_edit.py b/backend/app/services/forgejo_issue_edit.py new file mode 100644 index 0000000..2f476c5 --- /dev/null +++ b/backend/app/services/forgejo_issue_edit.py @@ -0,0 +1,150 @@ +"""Service for editing Forgejo issues and updating local cache.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import select + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_client import get_forgejo_client + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + +logger = get_logger(__name__) + + +class EditIssueError(Exception): + """Base exception for edit issue errors.""" + + +class EditIssueNotFoundError(EditIssueError): + """Raised when issue, repository, or connection is not found.""" + + +class EditIssueRemoteError(EditIssueError): + """Raised when the Forgejo API call fails.""" + + +async def edit_cached_issue( + session: AsyncSession, + issue: ForgejoIssue, + *, + title: str | None = None, + body: str | None = None, + state: str | None = None, + actor_user_id: UUID | None = None, + actor_agent_id: UUID | None = None, +) -> dict[str, object]: + """Edit a Forgejo issue remotely and sync changes to the local cache. + + Raises: + EditIssueNotFoundError: If repository or connection not found. + EditIssueRemoteError: If the Forgejo API call fails. + """ + repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id) + if repository is None: + raise EditIssueNotFoundError("Repository not found") + + connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) + if connection is None: + raise EditIssueNotFoundError("Connection not found") + + try: + async with get_forgejo_client(connection) as client: + updated = await client.edit_issue( + owner=repository.owner, + repo=repository.repo, + issue_number=issue.forgejo_issue_number, + title=title, + body=body, + state=state, + ) + except Exception as exc: + raise EditIssueRemoteError(f"Failed to edit issue on Forgejo: {exc}") from exc + + now = utcnow() + + if title is not None: + issue.title = title + if body is not None: + issue.body = body + issue.body_preview = body[:1000] if body else None + if state is not None: + issue.state = state + if state == "closed" and issue.forgejo_closed_at is None: + issue.forgejo_closed_at = now + + issue.forgejo_updated_at = now + issue.last_synced_at = now + + if isinstance(issue.forgejo_payload, dict): + payload = dict(issue.forgejo_payload) + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if state is not None: + payload["state"] = state + payload["updated_at"] = now.isoformat() + issue.forgejo_payload = payload + + session.add(issue) + await session.flush() + + logger.info( + "forgejo.issue.edited", + extra={ + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "repository_id": str(repository.id), + "fields_changed": [ + f for f, v in [("title", title), ("body", body), ("state", state)] if v is not None + ], + "actor_user_id": str(actor_user_id) if actor_user_id else None, + "actor_agent_id": str(actor_agent_id) if actor_agent_id else None, + }, + ) + + _ = updated + return { + "success": True, + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "title": issue.title, + "body": issue.body, + "state": issue.state, + "forgejo_updated_at": issue.forgejo_updated_at.isoformat(), + } + + +async def edit_issue_by_id( + session: AsyncSession, + issue_id: UUID, + *, + title: str | None = None, + body: str | None = None, + state: str | None = None, + actor_user_id: UUID | None = None, + actor_agent_id: UUID | None = None, +) -> dict[str, object]: + """Edit a Forgejo issue by its local ID.""" + issue = (await session.exec(select(ForgejoIssue).where(ForgejoIssue.id == issue_id))).first() + if issue is None: + raise EditIssueNotFoundError("Issue not found") + return await edit_cached_issue( + session=session, + issue=issue, + title=title, + body=body, + state=state, + actor_user_id=actor_user_id, + actor_agent_id=actor_agent_id, + ) diff --git a/frontend/src/components/git/EditForgejoIssueDialog.tsx b/frontend/src/components/git/EditForgejoIssueDialog.tsx new file mode 100644 index 0000000..61bf21f --- /dev/null +++ b/frontend/src/components/git/EditForgejoIssueDialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import type { ForgejoIssue } from "@/lib/api-forgejo"; +import { editForgejoIssue } from "@/lib/api-forgejo"; + +type EditForgejoIssueDialogProps = { + issue: ForgejoIssue | null; + repositoryName: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess: (updated: { title: string; body: string | null; state: string }) => void; +}; + +export function EditForgejoIssueDialog({ + issue, + repositoryName, + open, + onOpenChange, + onSuccess, +}: EditForgejoIssueDialogProps) { + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open && issue) { + setTitle(issue.title); + setBody(issue.body ?? issue.body_preview ?? ""); + setError(null); + } + }, [open, issue]); + + if (!issue) return null; + + const isDirty = + title.trim() !== issue.title || body !== (issue.body ?? issue.body_preview ?? ""); + + const handleSubmit = async () => { + if (!title.trim()) return; + setIsSubmitting(true); + setError(null); + try { + const patch: { title?: string; body?: string } = {}; + if (title.trim() !== issue.title) patch.title = title.trim(); + if (body !== (issue.body ?? issue.body_preview ?? "")) patch.body = body; + + const result = await editForgejoIssue(issue.id, patch); + onSuccess({ title: result.title, body: result.body, state: result.state }); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to edit issue"); + } finally { + setIsSubmitting(false); + } + }; + + return ( + { + if (!isSubmitting) { + setError(null); + onOpenChange(next); + } + }} + > + + + Edit issue + + Editing{" "} + + {repositoryName}#{issue.forgejo_issue_number} + + . Changes will be saved to the connected Git provider. + + + +
+
+ + setTitle(e.target.value)} + disabled={isSubmitting} + placeholder="Issue title" + /> +
+ +
+ +