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.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"]),
)

View File

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

View File

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

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 { ExternalLink, Loader2 } from "lucide-react";
import { ExternalLink, Loader2, MessageSquarePlus, Pencil } from "lucide-react";
import {
Dialog,
@ -20,12 +20,15 @@ import {
type ForgejoIssue,
type ForgejoIssueDetail,
} from "@/lib/api-forgejo";
import { PostForgejoCommentDialog } from "@/components/git/PostForgejoCommentDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
type ForgejoIssueDetailDialogProps = {
issue: ForgejoIssue | null;
repositoryName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onRefresh?: () => void;
};
const formatDateTime = (value: string | null | undefined): string => {
@ -54,39 +57,41 @@ export function ForgejoIssueDetailDialog({
repositoryName,
open,
onOpenChange,
onRefresh,
}: ForgejoIssueDetailDialogProps) {
const [detail, setDetail] = useState<ForgejoIssueDetail | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState("overview");
useEffect(() => {
if (!open || !issue) return;
const [isCommentDialogOpen, setIsCommentDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const loadDetail = (id: string) => {
let cancelled = false;
setIsLoading(true);
setError(null);
(async () => {
setIsLoading(true);
setError(null);
try {
const result = await getForgejoIssue(issue.id);
if (!cancelled) {
setDetail(result);
}
const result = await getForgejoIssue(id);
if (!cancelled) setDetail(result);
} catch (err) {
if (!cancelled) {
if (!cancelled)
setError(
err instanceof Error
? err.message
: "Could not load issue details from Pipeline.",
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
};
useEffect(() => {
if (!open || !issue) return;
return loadDetail(issue.id);
}, [issue, open]);
const comments = useMemo(
@ -108,183 +113,271 @@ export function ForgejoIssueDetailDialog({
const body = detail?.body ?? issue.body ?? issue.body_preview ?? "";
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<DialogTitle className="break-words text-base sm:text-lg">
{active.title}
</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<span className="font-medium text-strong">
{repositoryName}
</span>
<span className="font-mono">
#{active.forgejo_issue_number}
</span>
<Badge variant={stateVariant}>{active.state}</Badge>
<span>Opened {formatDateTime(active.forgejo_created_at)}</span>
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
</DialogDescription>
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-2">
<DialogTitle className="break-words text-base sm:text-lg">
{active.title}
</DialogTitle>
<DialogDescription className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
<span className="font-medium text-strong">
{repositoryName}
</span>
<span className="font-mono">
#{active.forgejo_issue_number}
</span>
<Badge variant={stateVariant}>{active.state}</Badge>
<span>Opened {formatDateTime(active.forgejo_created_at)}</span>
<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>
<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>
</DialogHeader>
</DialogHeader>
{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">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
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>
)}
{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">
<Loader2 className="h-4 w-4 animate-spin text-[color:var(--accent)]" />
Loading issue details
</div>
</TabsContent>
) : null}
<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 on this issue.
</div>
) : (
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"
{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 value={activeTab} onValueChange={setActiveTab}>
<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>
</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">
<span className="font-medium text-strong">{login}</span>
<span>
{formatDateTime(asString(comment.created_at))}
Post the first one.
</button>
</div>
) : (
<>
{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>
</div>
{bodyText ? (
<Markdown content={bodyText} variant="comment" />
) : (
<p className="text-sm text-muted">No comment text.</p>
)}
</article>
);
})
)}
</div>
</TabsContent>
);
})
)}
</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>
</div>
);
})
)}
</div>
</TabsContent>
<TabsContent value="reactions">
<div className="space-y-2">
{reactions.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No reactions on this issue.
</div>
) : (
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>
<TabsContent value="reactions">
<div className="space-y-2">
{reactions.length === 0 ? (
<div className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4 text-sm text-muted">
No reactions on this issue.
</div>
) : (
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">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
<div className="flex justify-end">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
<PostForgejoCommentDialog
issue={issue}
repositoryName={repositoryName}
open={isCommentDialogOpen}
onOpenChange={setIsCommentDialogOpen}
onSuccess={handleCommentSuccess}
/>
<EditForgejoIssueDialog
issue={detail ?? issue}
repositoryName={repositoryName}
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSuccess={handleEditSuccess}
/>
</>
);
}

View File

@ -8,6 +8,7 @@ import {
ExternalLink,
Loader2,
Milestone,
Pencil,
XCircle,
} from "lucide-react";
@ -16,6 +17,7 @@ import { Badge } from "@/components/ui/badge";
import type { ForgejoIssue, ForgejoIssueLabel } from "@/lib/api-forgejo";
import type { ForgejoRepository } from "@/lib/api-forgejo";
import { CloseForgejoIssueDialog } from "@/components/git/CloseForgejoIssueDialog";
import { EditForgejoIssueDialog } from "@/components/git/EditForgejoIssueDialog";
import { ForgejoIssueDetailDialog } from "@/components/git/ForgejoIssueDetailDialog";
import { cn } from "@/lib/utils";
@ -184,6 +186,7 @@ export type ForgejoIssuesTableProps = {
repositories: ForgejoRepository[];
isLoading?: boolean;
canClose?: boolean;
canEdit?: boolean;
onRefresh: () => void;
};
@ -192,10 +195,13 @@ export function ForgejoIssuesTable({
repositories,
isLoading = false,
canClose = false,
canEdit = false,
onRefresh,
}: ForgejoIssuesTableProps) {
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
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 [isIssueDetailOpen, setIsIssueDetailOpen] = useState(false);
@ -275,6 +281,7 @@ export function ForgejoIssuesTable({
const stateVerb = issue.state === "closed" ? "closed" : "updated";
const canShowClose =
canClose && issue.state === "open" && !issue.is_pull_request;
const canShowEdit = canEdit && !issue.is_pull_request;
return (
<article
@ -341,6 +348,22 @@ export function ForgejoIssuesTable({
>
<ExternalLink className="h-4 w-4" />
</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 ? (
<Button
type="button"
@ -397,6 +420,22 @@ export function ForgejoIssuesTable({
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
export interface BoardForgejoRepositoryLink {
id: string;