fix(ui): edit git

This commit is contained in:
null 2026-05-21 23:30:19 -05:00
parent e1363e6140
commit 9300f4b670
10 changed files with 1107 additions and 184 deletions

View File

@ -17,9 +17,13 @@ from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_issues import ForgejoIssue
from app.schemas.forgejo_issues import ( from app.schemas.forgejo_issues import (
CloseIssueResponse, CloseIssueResponse,
EditIssueRequest,
EditIssueResponse,
ForgejoIssueDetailRead, ForgejoIssueDetailRead,
ForgejoIssueListResponse, ForgejoIssueListResponse,
ForgejoIssueRead, ForgejoIssueRead,
PostCommentRequest,
PostCommentResponse,
) )
from app.services.activity_log import record_activity from app.services.activity_log import record_activity
from app.services.forgejo_issue_close import ( from app.services.forgejo_issue_close import (
@ -28,6 +32,16 @@ from app.services.forgejo_issue_close import (
CloseIssueRemoteError, CloseIssueRemoteError,
close_issue_by_id, 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 from app.services.organizations import OrganizationContext, list_accessible_board_ids
if TYPE_CHECKING: if TYPE_CHECKING:
@ -324,3 +338,122 @@ async def close_issue(
forgejo_closed_at=result.get("forgejo_closed_at"), forgejo_closed_at=result.get("forgejo_closed_at"),
last_synced_at=result.get("last_synced_at") or "", 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"]),
)

View File

@ -79,3 +79,39 @@ class CloseIssueResponse(SQLModel):
state: str state: str
forgejo_closed_at: str | None = None forgejo_closed_at: str | None = None
last_synced_at: str 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

View File

@ -264,6 +264,48 @@ class ForgejoAPIClient:
page += 1 page += 1
return reactions 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( async def list_user_repos(
self, self,
limit: int = 50, limit: int = 50,

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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<string | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isSubmitting) {
setError(null);
onOpenChange(next);
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit issue</DialogTitle>
<DialogDescription>
Editing{" "}
<span className="font-mono font-semibold text-strong">
{repositoryName}#{issue.forgejo_issue_number}
</span>
. Changes will be saved to the connected Git provider.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="issue-title" className="text-sm font-medium text-strong">Title</label>
<Input
id="issue-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
placeholder="Issue title"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="issue-body" className="text-sm font-medium text-strong">Body</label>
<Textarea
id="issue-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
disabled={isSubmitting}
className="resize-none font-mono text-sm"
placeholder="Issue body (Markdown supported)"
/>
</div>
</div>
{error ? (
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
{error}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim() || !isDirty}
>
{isSubmitting ? "Saving…" : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react"; import { ExternalLink, Loader2, MessageSquarePlus, Pencil } from "lucide-react";
import { import {
Dialog, Dialog,
@ -20,12 +20,15 @@ import {
type ForgejoIssue, type ForgejoIssue,
type ForgejoIssueDetail, type ForgejoIssueDetail,
} from "@/lib/api-forgejo"; } from "@/lib/api-forgejo";
import { PostForgejoCommentDialog } from "@/components/git/PostForgejoCommentDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
type ForgejoIssueDetailDialogProps = { type ForgejoIssueDetailDialogProps = {
issue: ForgejoIssue | null; issue: ForgejoIssue | null;
repositoryName: string; repositoryName: string;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onRefresh?: () => void;
}; };
const formatDateTime = (value: string | null | undefined): string => { const formatDateTime = (value: string | null | undefined): string => {
@ -54,39 +57,41 @@ export function ForgejoIssueDetailDialog({
repositoryName, repositoryName,
open, open,
onOpenChange, onOpenChange,
onRefresh,
}: ForgejoIssueDetailDialogProps) { }: ForgejoIssueDetailDialogProps) {
const [detail, setDetail] = useState<ForgejoIssueDetail | null>(null); const [detail, setDetail] = useState<ForgejoIssueDetail | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("overview");
useEffect(() => { const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
if (!open || !issue) return; const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const loadDetail = (id: string) => {
let cancelled = false; let cancelled = false;
setIsLoading(true);
setError(null);
(async () => { (async () => {
setIsLoading(true);
setError(null);
try { try {
const result = await getForgejoIssue(issue.id); const result = await getForgejoIssue(id);
if (!cancelled) { if (!cancelled) setDetail(result);
setDetail(result);
}
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled)
setError( setError(
err instanceof Error err instanceof Error
? err.message ? err.message
: "Could not load issue details from Pipeline.", : "Could not load issue details from Pipeline.",
); );
}
} finally { } finally {
if (!cancelled) { if (!cancelled) setIsLoading(false);
setIsLoading(false);
}
} }
})(); })();
return () => { return () => { cancelled = true; };
cancelled = true; };
};
useEffect(() => {
if (!open || !issue) return;
return loadDetail(issue.id);
}, [issue, open]); }, [issue, open]);
const comments = useMemo( const comments = useMemo(
@ -108,183 +113,271 @@ export function ForgejoIssueDetailDialog({
const body = detail?.body ?? issue.body ?? issue.body_preview ?? ""; const body = detail?.body ?? issue.body ?? issue.body_preview ?? "";
const stateVariant = active.state === "open" ? "success" : "default"; const stateVariant = active.state === "open" ? "success" : "default";
const handleCommentSuccess = () => {
if (issue) loadDetail(issue.id);
onRefresh?.();
};
const handleEditSuccess = (updated: {
title: string;
body: string | null;
state: string;
}) => {
if (detail) {
setDetail({
...detail,
title: updated.title,
body: updated.body,
state: updated.state,
});
}
if (issue) loadDetail(issue.id);
onRefresh?.();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="max-w-4xl"> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogHeader> <DialogContent className="max-w-4xl">
<div className="flex flex-wrap items-start justify-between gap-3"> <DialogHeader>
<div className="space-y-2"> <div className="flex flex-wrap items-start justify-between gap-3">
<DialogTitle className="break-words text-base sm:text-lg"> <div className="space-y-2">
{active.title} <DialogTitle className="break-words text-base sm:text-lg">
</DialogTitle> {active.title}
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm"> </DialogTitle>
<span className="font-medium text-strong"> <DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
{repositoryName} <span className="font-medium text-strong">
</span> {repositoryName}
<span className="font-mono"> </span>
#{active.forgejo_issue_number} <span className="font-mono">
</span> #{active.forgejo_issue_number}
<Badge variant={stateVariant}>{active.state}</Badge> </span>
<span>Opened {formatDateTime(active.forgejo_created_at)}</span> <Badge variant={stateVariant}>{active.state}</Badge>
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span> <span>Opened {formatDateTime(active.forgejo_created_at)}</span>
</DialogDescription> <span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
</DialogDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl px-3 text-xs font-semibold"
onClick={() => setIsEditDialogOpen(true)}
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 rounded-xl px-3 text-xs font-semibold"
onClick={() => {
setActiveTab("comments");
setIsCommentDialogOpen(true);
}}
>
<MessageSquarePlus className="h-3.5 w-3.5" />
Comment
</Button>
<a
href={active.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 text-xs font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Open in Forgejo
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div> </div>
<a </DialogHeader>
href={active.html_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-9 items-center gap-2 rounded-xl border border-[color:var(--border)] px-3 text-xs font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Open in Forgejo
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</DialogHeader>
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted"> <div className="flex items-center gap-2 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" /> <Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
Loading issue details Loading issue details
</div>
) : null}
{error ? (
<div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
{error}
</div>
) : null}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="comments">
Comments ({comments.length})
</TabsTrigger>
<TabsTrigger value="timeline">
Timeline ({timeline.length})
</TabsTrigger>
<TabsTrigger value="reactions">
Reactions ({reactions.length})
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
{body ? (
<Markdown content={body} variant="comment" />
) : (
<p className="text-sm text-muted">No issue body provided.</p>
)}
</div> </div>
</TabsContent> ) : null}
<TabsContent value="comments"> {error ? (
<div className="space-y-3"> <div className="rounded-xl border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
{comments.length === 0 ? ( {error}
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted"> </div>
No comments on this issue. ) : null}
</div>
) : ( <Tabs value={activeTab} onValueChange={setActiveTab}>
comments.map((comment, idx) => { <TabsList>
const login = <TabsTrigger value="overview">Overview</TabsTrigger>
asString( <TabsTrigger value="comments">
comment.user && Comments ({comments.length})
(comment.user as Record<string, unknown>).login, </TabsTrigger>
) ?? "Unknown"; <TabsTrigger value="timeline">
const bodyText = asString(comment.body) ?? ""; Timeline ({timeline.length})
return ( </TabsTrigger>
<article <TabsTrigger value="reactions">
key={String(comment.id ?? idx)} Reactions ({reactions.length})
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4" </TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4">
{body ? (
<Markdown content={body} variant="comment" />
) : (
<p className="text-sm text-muted">No issue body provided.</p>
)}
</div>
</TabsContent>
<TabsContent value="comments">
<div className="space-y-3">
{comments.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No comments yet.{" "}
<button
type="button"
className="text-[color:var(--accent)] underline-offset-2 hover:underline"
onClick={() => setIsCommentDialogOpen(true)}
> >
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted"> Post the first one.
<span className="font-medium text-strong">{login}</span> </button>
<span> </div>
{formatDateTime(asString(comment.created_at))} ) : (
<>
{comments.map((comment, idx) => {
const login =
asString(
comment.user &&
(comment.user as Record<string, unknown>).login,
) ?? "Unknown";
const bodyText = asString(comment.body) ?? "";
return (
<article
key={String(comment.id ?? idx)}
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4"
>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="font-medium text-strong">
{login}
</span>
<span>
{formatDateTime(asString(comment.created_at))}
</span>
</div>
{bodyText ? (
<Markdown content={bodyText} variant="comment" />
) : (
<p className="text-sm text-muted">
No comment text.
</p>
)}
</article>
);
})}
<Button
variant="outline"
size="sm"
className="w-full gap-2"
onClick={() => setIsCommentDialogOpen(true)}
>
<MessageSquarePlus className="h-4 w-4" />
Post a comment
</Button>
</>
)}
</div>
</TabsContent>
<TabsContent value="timeline">
<div className="space-y-2">
{timeline.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No timeline events found.
</div>
) : (
timeline.map((event, idx) => {
const label =
asString(event.type) ??
asString(event.action) ??
asString(event.event) ??
"event";
const actor =
asString(
event.user &&
(event.user as Record<string, unknown>).login,
) ?? "system";
return (
<div
key={String(event.id ?? idx)}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">{label}</span>{" "}
<span className="text-muted">by {actor}</span>
<span className="ml-2 text-xs text-muted">
{formatDateTime(asString(event.created_at))}
</span> </span>
</div> </div>
{bodyText ? ( );
<Markdown content={bodyText} variant="comment" /> })
) : ( )}
<p className="text-sm text-muted">No comment text.</p> </div>
)} </TabsContent>
</article>
);
})
)}
</div>
</TabsContent>
<TabsContent value="timeline"> <TabsContent value="reactions">
<div className="space-y-2"> <div className="space-y-2">
{timeline.length === 0 ? ( {reactions.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted"> <div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No timeline events found. No reactions on this issue.
</div> </div>
) : ( ) : (
timeline.map((event, idx) => { reactions.map((reaction, idx) => {
const label = const content =
asString(event.type) ?? asString(reaction.content) ?? "reaction";
asString(event.action) ?? const login =
asString(event.event) ?? asString(
"event"; reaction.user &&
const actor = (reaction.user as Record<string, unknown>).login,
asString( ) ?? "Unknown";
event.user && return (
(event.user as Record<string, unknown>).login, <div
) ?? "system"; key={String(reaction.id ?? idx)}
return ( className="flex items-center justify-between rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
<div >
key={String(event.id ?? idx)} <span className="font-medium text-strong">
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm" {content}
> </span>
<span className="font-medium text-strong">{label}</span>{" "} <span className="text-xs text-muted">{login}</span>
<span className="text-muted">by {actor}</span> </div>
<span className="ml-2 text-xs text-muted"> );
{formatDateTime(asString(event.created_at))} })
</span> )}
</div> </div>
); </TabsContent>
}) </Tabs>
)}
</div>
</TabsContent>
<TabsContent value="reactions"> <div className="flex justify-end">
<div className="space-y-2"> <Button variant="outline" onClick={() => onOpenChange(false)}>
{reactions.length === 0 ? ( Close
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted"> </Button>
No reactions on this issue. </div>
</div> </DialogContent>
) : ( </Dialog>
reactions.map((reaction, idx) => {
const content = asString(reaction.content) ?? "reaction";
const login =
asString(
reaction.user &&
(reaction.user as Record<string, unknown>).login,
) ?? "Unknown";
return (
<div
key={String(reaction.id ?? idx)}
className="flex items-center justify-between rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 text-sm"
>
<span className="font-medium text-strong">{content}</span>
<span className="text-xs text-muted">{login}</span>
</div>
);
})
)}
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end"> <PostForgejoCommentDialog
<Button variant="outline" onClick={() => onOpenChange(false)}> issue={issue}
Close repositoryName={repositoryName}
</Button> open={isCommentDialogOpen}
</div> onOpenChange={setIsCommentDialogOpen}
</DialogContent> onSuccess={handleCommentSuccess}
</Dialog> />
<EditForgejoIssueDialog
issue={detail ?? issue}
repositoryName={repositoryName}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSuccess={handleEditSuccess}
/>
</>
); );
} }

View File

@ -8,6 +8,7 @@ import {
ExternalLink, ExternalLink,
Loader2, Loader2,
Milestone, Milestone,
Pencil,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo"; import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
import type { ForgejoRepository } from "@/lib/api-forgejo"; import type { ForgejoRepository } from "@/lib/api-forgejo";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog"; import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog"; import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -184,6 +186,7 @@ export type ForgejoIssuesTableProps = {
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
isLoading?: boolean; isLoading?: boolean;
canClose?: boolean; canClose?: boolean;
canEdit?: boolean;
onRefresh: () => void; onRefresh: () => void;
}; };
@ -192,10 +195,13 @@ export function ForgejoIssuesTable({
repositories, repositories,
isLoading = false, isLoading = false,
canClose = false, canClose = false,
canEdit = false,
onRefresh, onRefresh,
}: ForgejoIssuesTableProps) { }: ForgejoIssuesTableProps) {
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null); const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<ForgejoIssue | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [issueToView, setIssueToView] = useState<ForgejoIssue | null>(null); const [issueToView, setIssueToView] = useState<ForgejoIssue | null>(null);
const [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false); const [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false);
@ -275,6 +281,7 @@ export function ForgejoIssuesTable({
const stateVerb = issue.state === "closed" ? "closed" : "updated"; const stateVerb = issue.state === "closed" ? "closed" : "updated";
const canShowClose = const canShowClose =
canClose && issue.state === "open" && !issue.is_pull_request; canClose && issue.state === "open" && !issue.is_pull_request;
const canShowEdit = canEdit && !issue.is_pull_request;
return ( return (
<article <article
@ -341,6 +348,22 @@ export function ForgejoIssuesTable({
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</a> </a>
{canShowEdit ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-8 rounded-lg p-0 text-muted hover:bg-[color:var(--surface-strong)] hover:text-[color:var(--accent)]"
title={`Edit ${repositoryName}#${issue.forgejo_issue_number}`}
aria-label={`Edit ${repositoryName}#${issue.forgejo_issue_number}`}
onClick={() => {
setIssueToEdit(issue);
setIsEditDialogOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{canShowClose ? ( {canShowClose ? (
<Button <Button
type="button" type="button"
@ -397,6 +420,22 @@ export function ForgejoIssuesTable({
setIssueToView(null); setIssueToView(null);
} }
}} }}
onRefresh={onRefresh}
/>
<EditForgejoIssueDialog
issue={issueToEdit}
repositoryName={
issueToEdit
? (repositoryNameById.get(issueToEdit.repository_id) ??
issueToEdit.repository_id)
: "Repository"
}
open={isEditDialogOpen}
onOpenChange={(nextOpen) => {
setIsEditDialogOpen(nextOpen);
if (!nextOpen) setIssueToEdit(null);
}}
onSuccess={onRefresh}
/> />
</> </>
); );

View File

@ -0,0 +1,119 @@
"use client";
import { useRef, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { ForgejoIssue } from "@/lib/api-forgejo";
import { postForgejoIssueComment } from "@/lib/api-forgejo";
type PostForgejoCommentDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
};
export function PostForgejoCommentDialog({
issue,
repositoryName,
open,
onOpenChange,
onSuccess,
}: PostForgejoCommentDialogProps) {
const [body, setBody] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
if (!issue) return null;
const handleSubmit = async () => {
if (!body.trim()) return;
setIsSubmitting(true);
setError(null);
try {
await postForgejoIssueComment(issue.id, body.trim());
setBody("");
onSuccess();
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to post comment");
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!isSubmitting) {
setBody("");
setError(null);
onOpenChange(next);
}
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Post comment</DialogTitle>
<DialogDescription>
Comment on{" "}
<span className="font-mono font-semibold text-strong">
{repositoryName}#{issue.forgejo_issue_number}
</span>
. The comment will be posted directly to the connected Git provider.
</DialogDescription>
</DialogHeader>
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2">
<p className="truncate text-sm font-medium text-strong">
{issue.title}
</p>
</div>
<Textarea
ref={textareaRef}
placeholder="Write your comment here…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={5}
disabled={isSubmitting}
className="resize-none"
/>
{error ? (
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-xs text-[color:var(--danger)]">
{error}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={isSubmitting || !body.trim()}
>
{isSubmitting ? "Posting…" : "Post Comment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -379,6 +379,53 @@ export async function closeForgejoIssue(
}); });
} }
export interface PostCommentResponse {
success: boolean;
issue_id: string;
comment_id: number | string | null;
body: string;
created_at: string;
}
export async function postForgejoIssueComment(
issueId: string,
body: string,
): Promise<PostCommentResponse> {
return fetchJson<PostCommentResponse>(
`/api/v1/forgejo/issues/${issueId}/comments`,
{
method: "POST",
body: JSON.stringify({ body }),
},
);
}
export interface EditIssueRequest {
title?: string;
body?: string;
state?: string;
}
export interface EditIssueResponse {
success: boolean;
issue_id: string;
forgejo_issue_number: number;
title: string;
body: string | null;
state: string;
forgejo_updated_at: string;
}
export async function editForgejoIssue(
issueId: string,
data: EditIssueRequest,
): Promise<EditIssueResponse> {
return fetchJson<EditIssueResponse>(`/api/v1/forgejo/issues/${issueId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
// Board Repository Linking API // Board Repository Linking API
export interface BoardForgejoRepositoryLink { export interface BoardForgejoRepositoryLink {
id: string; id: string;