fix(ui): edit git
This commit is contained in:
parent
e1363e6140
commit
9300f4b670
|
|
@ -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"]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue