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.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"]),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 { 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;
|
||||||
(async () => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
(async () => {
|
||||||
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,7 +113,30 @@ 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}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-4xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -129,6 +157,28 @@ export function ForgejoIssueDetailDialog({
|
||||||
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
|
<span>Updated {formatDateTime(active.forgejo_updated_at)}</span>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</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
|
<a
|
||||||
href={active.html_url}
|
href={active.html_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -139,6 +189,7 @@ export function ForgejoIssueDetailDialog({
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -154,7 +205,7 @@ export function ForgejoIssueDetailDialog({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Tabs defaultValue="overview">
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="comments">
|
<TabsTrigger value="comments">
|
||||||
|
|
@ -182,10 +233,18 @@ export function ForgejoIssueDetailDialog({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{comments.length === 0 ? (
|
{comments.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 comments on this issue.
|
No comments yet.{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-[color:var(--accent)] underline-offset-2 hover:underline"
|
||||||
|
onClick={() => setIsCommentDialogOpen(true)}
|
||||||
|
>
|
||||||
|
Post the first one.
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
comments.map((comment, idx) => {
|
<>
|
||||||
|
{comments.map((comment, idx) => {
|
||||||
const login =
|
const login =
|
||||||
asString(
|
asString(
|
||||||
comment.user &&
|
comment.user &&
|
||||||
|
|
@ -198,7 +257,9 @@ export function ForgejoIssueDetailDialog({
|
||||||
className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-4"
|
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">
|
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||||
<span className="font-medium text-strong">{login}</span>
|
<span className="font-medium text-strong">
|
||||||
|
{login}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
{formatDateTime(asString(comment.created_at))}
|
{formatDateTime(asString(comment.created_at))}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -206,11 +267,23 @@ export function ForgejoIssueDetailDialog({
|
||||||
{bodyText ? (
|
{bodyText ? (
|
||||||
<Markdown content={bodyText} variant="comment" />
|
<Markdown content={bodyText} variant="comment" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">No comment text.</p>
|
<p className="text-sm text-muted">
|
||||||
|
No comment text.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</article>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -258,7 +331,8 @@ export function ForgejoIssueDetailDialog({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
reactions.map((reaction, idx) => {
|
reactions.map((reaction, idx) => {
|
||||||
const content = asString(reaction.content) ?? "reaction";
|
const content =
|
||||||
|
asString(reaction.content) ?? "reaction";
|
||||||
const login =
|
const login =
|
||||||
asString(
|
asString(
|
||||||
reaction.user &&
|
reaction.user &&
|
||||||
|
|
@ -269,7 +343,9 @@ export function ForgejoIssueDetailDialog({
|
||||||
key={String(reaction.id ?? idx)}
|
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"
|
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="font-medium text-strong">
|
||||||
|
{content}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted">{login}</span>
|
<span className="text-xs text-muted">{login}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -286,5 +362,22 @@ export function ForgejoIssueDetailDialog({
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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,
|
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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Board Repository Linking API
|
||||||
export interface BoardForgejoRepositoryLink {
|
export interface BoardForgejoRepositoryLink {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue