feat(forgejo): batch 3 WIP — metrics API, agent close APIs, issues page refactor, close UI (batch 3.1.0)
This commit is contained in:
parent
d56ccb31da
commit
ae3786f64b
|
|
@ -9,15 +9,17 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|||
from sqlmodel import select, func
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.api.deps import get_board_for_actor_read
|
||||
from app.api.deps import ActorContext, get_board_for_actor_read
|
||||
from app.core.agent_auth import get_agent_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.board_repository_links import BoardRepositoryLink
|
||||
from app.models.forgejo_issues import ForgejoIssue
|
||||
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse
|
||||
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio.session import AsyncSession
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.models.agents import Agent
|
||||
from app.core.agent_auth import AgentAuthContext
|
||||
|
||||
|
||||
router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"])
|
||||
|
|
@ -244,3 +246,133 @@ async def read_board_issue(
|
|||
)
|
||||
|
||||
return ForgejoIssueRead.model_validate(issue)
|
||||
|
||||
|
||||
from app.schemas.forgejo_issues import CloseIssueResponse
|
||||
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{board_id}/git/issues/{issue_id}/close",
|
||||
response_model=CloseIssueResponse,
|
||||
summary="Close a Forgejo issue (agent)",
|
||||
description=(
|
||||
"Close a Forgejo issue by its local ID as a board-lead agent. "
|
||||
"Only board lead agents can close issues. The issue must belong to a "
|
||||
"repository linked to the target board."
|
||||
),
|
||||
openapi_extra=_agent_board_openapi_hints(
|
||||
intent="agent_board_close_issue",
|
||||
when_to_use=[
|
||||
"Lead agent needs to close a specific issue",
|
||||
"Finalizing work items after completion",
|
||||
"Closing issues after deployment or release",
|
||||
],
|
||||
when_not_to_use=[
|
||||
"Worker agents closing issues (use human endpoint or ask lead)",
|
||||
"Working with issues from unlinked repositories",
|
||||
],
|
||||
required_actor="board_lead",
|
||||
routing_examples=[
|
||||
{
|
||||
"input": {
|
||||
"intent": "close issue 123 for board xyz",
|
||||
"board_id": "uuid",
|
||||
"issue_id": "uuid",
|
||||
},
|
||||
"decision": "agent_board_close_issue",
|
||||
},
|
||||
],
|
||||
),
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "Issue closed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"issue_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"forgejo_issue_number": 42,
|
||||
"state": "closed",
|
||||
"forgejo_closed_at": "2026-05-19T03:43:00+00:00",
|
||||
"last_synced_at": "2026-05-19T03:43:00+00:00",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"description": "Issue not found or not linked to this board",
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"description": "Caller is not board lead",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"description": "Organization mismatch or access denied",
|
||||
},
|
||||
status.HTTP_502_BAD_GATEWAY: {
|
||||
"description": "Forgejo API call failed",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def close_board_issue(
|
||||
board_id: UUID,
|
||||
issue_id: UUID,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
board: ForgejoIssue = BOARD_READ_DEP,
|
||||
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
|
||||
) -> CloseIssueResponse:
|
||||
"""Close a Forgejo issue as a board-lead agent.
|
||||
|
||||
Only board lead agents can close issues. The issue must belong to a repository
|
||||
linked to the target board.
|
||||
"""
|
||||
# Get the issue
|
||||
statement = select(ForgejoIssue).where(
|
||||
and_(
|
||||
ForgejoIssue.id == issue_id,
|
||||
ForgejoIssue.repository_id.in_(
|
||||
[link.repository_id for link in await session.exec(
|
||||
select(BoardRepositoryLink).where(
|
||||
BoardRepositoryLink.board_id == board_id
|
||||
)
|
||||
).all()]
|
||||
),
|
||||
)
|
||||
)
|
||||
issue = (await session.exec(statement)).first()
|
||||
|
||||
if issue is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Issue not found or not linked to this board",
|
||||
)
|
||||
|
||||
# Verify agent is board lead
|
||||
from app.core.openclaw.policies import OpenClawAuthorizationPolicy
|
||||
OpenClawAuthorizationPolicy.require_board_lead_actor(
|
||||
actor_agent=agent_ctx.agent,
|
||||
detail="Only board leads can close issues",
|
||||
)
|
||||
|
||||
# Close the issue using the service
|
||||
try:
|
||||
result = await close_issue_by_id(
|
||||
session=session,
|
||||
issue_id=issue_id,
|
||||
actor_agent_id=agent_ctx.agent.id,
|
||||
)
|
||||
except CloseIssueNotFoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except CloseIssueAccessError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
except CloseIssueRemoteError as e:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||
|
||||
return CloseIssueResponse(
|
||||
success=result["success"],
|
||||
issue_id=result["issue_id"],
|
||||
forgejo_issue_number=result["forgejo_issue_number"],
|
||||
state=result["state"],
|
||||
forgejo_closed_at=result.get("forgejo_closed_at"),
|
||||
last_synced_at=result.get("last_synced_at") or "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ from uuid import UUID
|
|||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import select, func
|
||||
|
||||
from app.api.deps import require_org_admin
|
||||
from app.api.deps import get_board_for_user_write, require_org_admin
|
||||
from app.core.agent_auth import get_agent_auth_context
|
||||
from app.core.auth import get_auth_context
|
||||
from app.db import crud
|
||||
from app.db.session import get_session
|
||||
from app.models.board_repository_links import BoardRepositoryLink
|
||||
from app.models.forgejo_issues import ForgejoIssue
|
||||
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead
|
||||
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse
|
||||
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
|
||||
from app.services.organizations import OrganizationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -24,6 +27,7 @@ router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
|
|||
SESSION_DEP = Depends(get_session)
|
||||
AUTH_DEP = Depends(get_auth_context)
|
||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
|
||||
|
||||
|
||||
@router.get("", response_model=ForgejoIssueListResponse)
|
||||
|
|
@ -110,3 +114,102 @@ async def get_issue(
|
|||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
return ForgejoIssueRead.model_validate(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{issue_id}/close",
|
||||
response_model=CloseIssueResponse,
|
||||
summary="Close a Forgejo issue (human user)",
|
||||
description=(
|
||||
"Close a Forgejo issue by its local ID. The user must have write access "
|
||||
"to the board that the issue's repository is linked to."
|
||||
),
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "Issue closed successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"success": True,
|
||||
"issue_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"forgejo_issue_number": 42,
|
||||
"state": "closed",
|
||||
"forgejo_closed_at": "2026-05-19T03:43:00+00:00",
|
||||
"last_synced_at": "2026-05-19T03:43:00+00:00",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
status.HTTP_404_NOT_FOUND: {
|
||||
"description": "Issue not found or not linked to a board",
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"description": "User lacks write access to the board",
|
||||
},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"description": "Organization mismatch or access denied",
|
||||
},
|
||||
status.HTTP_502_BAD_GATEWAY: {
|
||||
"description": "Forgejo API call failed",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def close_issue(
|
||||
issue_id: str,
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
||||
) -> CloseIssueResponse:
|
||||
"""Close a Forgejo issue as an authenticated user.
|
||||
|
||||
The user must have write access to the board that the issue's repository
|
||||
is linked to. The issue must belong to a repository linked to that board.
|
||||
"""
|
||||
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:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
|
||||
|
||||
# Get the board linked to this issue's repository
|
||||
link_statement = select(BoardRepositoryLink).where(
|
||||
BoardRepositoryLink.repository_id == issue.repository_id,
|
||||
)
|
||||
link = await session.exec(link_statement).first()
|
||||
if link is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Issue repository is not linked to any board",
|
||||
)
|
||||
|
||||
# Verify the user has write access to the board
|
||||
board = await get_board_for_user_write(
|
||||
board_id=str(link.board_id),
|
||||
session=session,
|
||||
auth=ctx,
|
||||
)
|
||||
|
||||
# Close the issue using the service
|
||||
try:
|
||||
result = await close_issue_by_id(
|
||||
session=session,
|
||||
issue_id=uuid,
|
||||
actor_user_id=ctx.user.id,
|
||||
)
|
||||
except CloseIssueNotFoundError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
except CloseIssueAccessError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
except CloseIssueRemoteError as e:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
|
||||
|
||||
return CloseIssueResponse(
|
||||
success=result["success"],
|
||||
issue_id=result["issue_id"],
|
||||
forgejo_issue_number=result["forgejo_issue_number"],
|
||||
state=result["state"],
|
||||
forgejo_closed_at=result.get("forgejo_closed_at"),
|
||||
last_synced_at=result.get("last_synced_at") or "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
"""Forgejo issue tracking metrics endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlmodel import select, func
|
||||
from sqlalchemy import and_
|
||||
|
||||
from app.api.deps import ORG_MEMBER_DEP, OrganizationContext
|
||||
from app.core.agent_auth import get_agent_auth_context, AgentAuthContext
|
||||
from app.db.session import get_session
|
||||
from app.models.board_repository_links import BoardRepositoryLink
|
||||
from app.models.forgejo_issues import ForgejoIssue
|
||||
from app.models.forgejo_repositories import ForgejoRepository
|
||||
from app.schemas.metrics import MetricsResponse
|
||||
|
||||
from app.core.agent_auth import get_agent_auth_context, AgentAuthContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
router = APIRouter(prefix="/forgejo", tags=["forgejo-metrics"])
|
||||
SESSION_DEP = Depends(get_session)
|
||||
# Use ORG_MEMBER_DEP directly, not wrapped in Depends again
|
||||
|
||||
|
||||
@router.get(
|
||||
"/metrics",
|
||||
response_model=MetricsResponse,
|
||||
summary="Forgejo issue tracking metrics",
|
||||
description=(
|
||||
"Get aggregated metrics for Forgejo issues across linked repositories. "
|
||||
"Supports filtering by organization_id, board_id, or repository_id. "
|
||||
"Empty scope returns zeroed metrics."
|
||||
),
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "Metrics retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {
|
||||
"open_issues": 25,
|
||||
"closed_issues": 150,
|
||||
"closed_last_7_days": 12,
|
||||
"closed_last_30_days": 35,
|
||||
"stale_open_issues": 5,
|
||||
"repositories_synced": 3,
|
||||
"last_sync_timestamps": {
|
||||
"repo_1": "2026-05-19T03:00:00+00:00",
|
||||
"repo_2": "2026-05-19T02:30:00+00:00",
|
||||
"repo_3": "2026-05-19T01:00:00+00:00",
|
||||
},
|
||||
"sync_error_counts": {
|
||||
"repo_1": 0,
|
||||
"repo_2": 2,
|
||||
"repo_3": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
status.HTTP_403_FORBIDDEN: {
|
||||
"description": "User lacks access to the board",
|
||||
},
|
||||
},
|
||||
)
|
||||
async def get_forgejo_metrics(
|
||||
organization_id: str | None = Query(
|
||||
None,
|
||||
description="Filter by organization ID",
|
||||
),
|
||||
board_id: str | None = Query(
|
||||
None,
|
||||
description="Filter by board ID (via linked repositories)",
|
||||
),
|
||||
repository_id: str | None = Query(
|
||||
None,
|
||||
description="Filter by specific repository ID",
|
||||
),
|
||||
session: AsyncSession = SESSION_DEP,
|
||||
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
||||
) -> MetricsResponse:
|
||||
"""Get Forgejo issue tracking metrics.
|
||||
|
||||
Filters:
|
||||
- organization_id: All boards/repositories in organization
|
||||
- board_id: All repositories linked to board
|
||||
- repository_id: Single repository
|
||||
|
||||
Empty scope (no filters) returns zeroed metrics.
|
||||
"""
|
||||
# Determine scope
|
||||
if repository_id:
|
||||
# Single repository
|
||||
repo_ids = [repository_id]
|
||||
if board_id or organization_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot combine repository_id with board_id or organization_id",
|
||||
)
|
||||
elif board_id:
|
||||
# Board-scoped: get linked repositories
|
||||
link_statement = select(BoardRepositoryLink).where(
|
||||
BoardRepositoryLink.board_id == board_id
|
||||
)
|
||||
links = (await session.exec(link_statement)).all()
|
||||
repo_ids = [link.repository_id for link in links]
|
||||
if not repo_ids:
|
||||
return _zeroed_metrics()
|
||||
elif organization_id:
|
||||
# Organization-scoped: all repositories in org
|
||||
repo_statement = select(ForgejoRepository.id).where(
|
||||
ForgejoRepository.organization_id == organization_id
|
||||
)
|
||||
repos = (await session.exec(repo_statement)).all()
|
||||
repo_ids = [r.id for r in repos]
|
||||
if not repo_ids:
|
||||
return _zeroed_metrics()
|
||||
else:
|
||||
# No filters - return zeroed metrics
|
||||
return _zeroed_metrics()
|
||||
|
||||
# Calculate metrics
|
||||
# 1. Open issues count
|
||||
open_statement = select(func.count(ForgejoIssue.id)).where(
|
||||
and_(
|
||||
ForgejoIssue.repository_id.in_(repo_ids),
|
||||
ForgejoIssue.state == "open",
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
)
|
||||
)
|
||||
open_count = await session.exec(open_statement)
|
||||
open_issues = open_count.one_or_none() or 0
|
||||
|
||||
# 2. Closed issues count
|
||||
closed_statement = select(func.count(ForgejoIssue.id)).where(
|
||||
and_(
|
||||
ForgejoIssue.repository_id.in_(repo_ids),
|
||||
ForgejoIssue.state == "closed",
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
)
|
||||
)
|
||||
closed_count = await session.exec(closed_statement)
|
||||
closed_issues = closed_count.one_or_none() or 0
|
||||
|
||||
# 3. Closed in last 7 days
|
||||
now = datetime.now(timezone.utc)
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
closed_7_statement = select(func.count(ForgejoIssue.id)).where(
|
||||
and_(
|
||||
ForgejoIssue.repository_id.in_(repo_ids),
|
||||
ForgejoIssue.state == "closed",
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
ForgejoIssue.updated_at >= seven_days_ago,
|
||||
)
|
||||
)
|
||||
closed_7_count = await session.exec(closed_7_statement)
|
||||
closed_last_7_days = closed_7_count.one_or_none() or 0
|
||||
|
||||
# 4. Closed in last 30 days
|
||||
thirty_days_ago = now - timedelta(days=30)
|
||||
closed_30_statement = select(func.count(ForgejoIssue.id)).where(
|
||||
and_(
|
||||
ForgejoIssue.repository_id.in_(repo_ids),
|
||||
ForgejoIssue.state == "closed",
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
ForgejoIssue.updated_at >= thirty_days_ago,
|
||||
)
|
||||
)
|
||||
closed_30_count = await session.exec(closed_30_statement)
|
||||
closed_last_30_days = closed_30_count.one_or_none() or 0
|
||||
|
||||
# 5. Stale open issues (open > 14 days with no update)
|
||||
fourteen_days_ago = now - timedelta(days=14)
|
||||
stale_statement = select(func.count(ForgejoIssue.id)).where(
|
||||
and_(
|
||||
ForgejoIssue.repository_id.in_(repo_ids),
|
||||
ForgejoIssue.state == "open",
|
||||
ForgejoIssue.is_pull_request.is_(False),
|
||||
ForgejoIssue.updated_at < fourteen_days_ago,
|
||||
)
|
||||
)
|
||||
stale_count = await session.exec(stale_statement)
|
||||
stale_open_issues = stale_count.one_or_none() or 0
|
||||
|
||||
# 6. Get sync status per repository
|
||||
last_sync_timestamps: dict[str, str] = {}
|
||||
sync_error_counts: dict[str, int] = {}
|
||||
|
||||
for repo_id in repo_ids:
|
||||
# Get repository sync info
|
||||
repo_statement = select(ForgejoRepository).where(
|
||||
ForgejoRepository.id == repo_id
|
||||
)
|
||||
repo = (await session.exec(repo_statement)).first()
|
||||
if repo:
|
||||
last_sync_timestamps[repo_id] = (
|
||||
repo.last_sync_at.isoformat()
|
||||
if repo.last_sync_at
|
||||
else ""
|
||||
)
|
||||
sync_error_counts[repo_id] = 1 if repo.last_sync_error else 0
|
||||
|
||||
repositories_synced = len(repo_ids)
|
||||
|
||||
return MetricsResponse(
|
||||
open_issues=open_issues,
|
||||
closed_issues=closed_issues,
|
||||
closed_last_7_days=closed_last_7_days,
|
||||
closed_last_30_days=closed_last_30_days,
|
||||
stale_open_issues=stale_open_issues,
|
||||
repositories_synced=repositories_synced,
|
||||
last_sync_timestamps=last_sync_timestamps,
|
||||
sync_error_counts=sync_error_counts,
|
||||
)
|
||||
|
||||
|
||||
def _zeroed_metrics() -> MetricsResponse:
|
||||
"""Return zeroed metrics for empty scopes."""
|
||||
return MetricsResponse(
|
||||
open_issues=0,
|
||||
closed_issues=0,
|
||||
closed_last_7_days=0,
|
||||
closed_last_30_days=0,
|
||||
stale_open_issues=0,
|
||||
repositories_synced=0,
|
||||
last_sync_timestamps={},
|
||||
sync_error_counts={},
|
||||
)
|
||||
|
|
@ -23,6 +23,7 @@ from app.api.board_webhooks import router as board_webhooks_router
|
|||
from app.api.boards import router as boards_router
|
||||
from app.api.forgejo_connections import router as forgejo_connections_router
|
||||
from app.api.forgejo_issues import router as forgejo_issues_router
|
||||
from app.api.forgejo_metrics import router as forgejo_metrics_router
|
||||
from app.api.forgejo_repositories import router as forgejo_repositories_router
|
||||
from app.api.board_repository_links import router as board_repository_links_router
|
||||
from app.api.agent_forgejo import router as agent_forgejo_router
|
||||
|
|
@ -560,6 +561,7 @@ api_v1.include_router(agents_router)
|
|||
api_v1.include_router(activity_router)
|
||||
api_v1.include_router(forgejo_connections_router)
|
||||
api_v1.include_router(forgejo_issues_router)
|
||||
api_v1.include_router(forgejo_metrics_router)
|
||||
api_v1.include_router(forgejo_repositories_router)
|
||||
api_v1.include_router(board_repository_links_router)
|
||||
api_v1.include_router(agent_forgejo_router)
|
||||
|
|
|
|||
|
|
@ -57,3 +57,14 @@ class ForgejoIssueUpsertResponse(SQLModel):
|
|||
open: int = 0
|
||||
closed: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CloseIssueResponse(SQLModel):
|
||||
"""Response for issue close operations."""
|
||||
|
||||
success: bool
|
||||
issue_id: UUID
|
||||
forgejo_issue_number: int
|
||||
state: str
|
||||
forgejo_closed_at: str | None = None
|
||||
last_synced_at: str
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
"""Schemas for Forgejo issue metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class RepositorySyncHealth(SQLModel):
|
||||
"""Sync health for a single tracked repository."""
|
||||
|
||||
repository_id: UUID
|
||||
owner: str
|
||||
repo: str
|
||||
display_name: str | None = None
|
||||
last_sync_at: datetime | None = None
|
||||
last_sync_error: str | None = None
|
||||
has_error: bool = False
|
||||
|
||||
|
||||
class ForgejoIssueMetrics(SQLModel):
|
||||
"""Aggregate Forgejo issue tracking metrics."""
|
||||
|
||||
open_issues: int
|
||||
closed_issues: int
|
||||
recently_closed: int # closed in last 7 days
|
||||
stale_open: int # open > 14 days with no update
|
||||
total_issues: int
|
||||
repositories_health: list[RepositorySyncHealth] = []
|
||||
|
|
@ -103,3 +103,29 @@ class DashboardMetrics(SQLModel):
|
|||
error_rate: DashboardSeriesSet
|
||||
wip: DashboardWipSeriesSet
|
||||
pending_approvals: DashboardPendingApprovals
|
||||
|
||||
|
||||
class ForgejoIssueMetrics(SQLModel):
|
||||
"""Forgejo issue tracking metrics."""
|
||||
|
||||
open_issues: int
|
||||
closed_issues: int
|
||||
closed_last_7_days: int
|
||||
closed_last_30_days: int
|
||||
stale_open_issues: int
|
||||
repositories_synced: int
|
||||
last_sync_timestamps: dict[str, str]
|
||||
sync_error_counts: dict[str, int]
|
||||
|
||||
|
||||
class MetricsResponse(SQLModel):
|
||||
"""Generic metrics response wrapper."""
|
||||
|
||||
open_issues: int = 0
|
||||
closed_issues: int = 0
|
||||
closed_last_7_days: int = 0
|
||||
closed_last_30_days: int = 0
|
||||
stale_open_issues: int = 0
|
||||
repositories_synced: int = 0
|
||||
last_sync_timestamps: dict[str, str] = {}
|
||||
sync_error_counts: dict[str, int] = {}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
||||
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
|
||||
import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -3242,6 +3243,12 @@ export default function BoardDetailPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{canWrite && boardId ? (
|
||||
<div className="w-full">
|
||||
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
|
||||
{isOrgAdmin ? (
|
||||
<aside className="flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-sm md:h-full md:w-64">
|
||||
|
|
|
|||
|
|
@ -2,45 +2,66 @@
|
|||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
getForgejoIssues,
|
||||
getForgejoRepositories,
|
||||
type ForgejoIssue,
|
||||
type ForgejoRepository,
|
||||
} from "@/lib/api-forgejo";
|
||||
import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters";
|
||||
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
|
||||
|
||||
export default function GitIssuesPage() {
|
||||
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
||||
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stateFilter, setStateFilter] = useState<string>("open");
|
||||
const [repoFilter, setRepoFilter] = useState<string>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 30;
|
||||
|
||||
const fetchIssues = useCallback(async () => {
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const repos = await getForgejoRepositories();
|
||||
setRepos(repos);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch repositories:", err);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const result = await getForgejoIssues({
|
||||
state: stateFilter || undefined,
|
||||
repository_id: repoFilter !== "all" ? repoFilter : undefined,
|
||||
search: search || undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
setIssues(result.items);
|
||||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
console.error("Failed to fetch issues:", err);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [stateFilter, repoFilter, search, page]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
const result = await getForgejoIssues({
|
||||
state: stateFilter || undefined,
|
||||
|
|
@ -53,18 +74,8 @@ export default function GitIssuesPage() {
|
|||
setTotal(result.total);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch issues:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [stateFilter, repoFilter, search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
getForgejoRepositories().then(setRepos).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchIssues();
|
||||
}, [fetchIssues]);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
||||
() => [
|
||||
|
|
@ -89,6 +100,16 @@ export default function GitIssuesPage() {
|
|||
<div className="max-w-md truncate">{row.original.title}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "body_preview",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const body = row.original.body_preview;
|
||||
if (!body) return null;
|
||||
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
||||
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: "State",
|
||||
|
|
@ -97,11 +118,7 @@ export default function GitIssuesPage() {
|
|||
return (
|
||||
<Badge
|
||||
variant={state === "open" ? "success" : "default"}
|
||||
className={
|
||||
state === "open"
|
||||
? ""
|
||||
: ""
|
||||
}
|
||||
className={state === "open" ? "" : ""}
|
||||
>
|
||||
{state}
|
||||
</Badge>
|
||||
|
|
@ -156,12 +173,6 @@ export default function GitIssuesPage() {
|
|||
[],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: issues,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
|
|
@ -175,65 +186,17 @@ export default function GitIssuesPage() {
|
|||
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
|
||||
stickyHeader
|
||||
>
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<Select value={stateFilter} onValueChange={(v) => { setStateFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={repoFilter} onValueChange={(v) => { setRepoFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All repositories</SelectItem>
|
||||
{repos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.display_name || `${r.owner}/${r.repo}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Search issues…"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-[240px]"
|
||||
<ForgejoIssueFilters
|
||||
stateFilter={stateFilter}
|
||||
onStateChange={(v) => { setStateFilter(v); setPage(1); }}
|
||||
repoFilter={repoFilter}
|
||||
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }}
|
||||
search={search}
|
||||
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||
repos={repos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={loading}
|
||||
emptyState={{
|
||||
icon: (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
title: "No issues found",
|
||||
description: "Sync a repository to pull in issues, or adjust your filters.",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ForgejoIssuesTable issues={issues} onRefresh={handleRefresh} />
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||
import { type ForgejoRepository, getForgejoRepositories, linkBoardForgejoRepository, unlinkBoardForgejoRepository, getBoardForgejoRepositories } from "@/lib/api-forgejo";
|
||||
|
||||
type BoardForgejoRepositoryLink = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
repository_id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
repository: ForgejoRepository;
|
||||
};
|
||||
|
||||
type BoardForgejoRepositoryLinksProps = {
|
||||
boardId: string;
|
||||
canWrite: boolean;
|
||||
};
|
||||
|
||||
export function BoardForgejoRepositoryLinks({
|
||||
boardId,
|
||||
canWrite,
|
||||
}: BoardForgejoRepositoryLinksProps) {
|
||||
const [linkedRepos, setLinkedRepos] = useState<BoardForgejoRepositoryLink[]>([]);
|
||||
const [allRepos, setAllRepos] = useState<ForgejoRepository[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isLinking, setIsLinking] = useState(false);
|
||||
const [unlinkRepo, setUnlinkRepo] = useState<string | null>(null);
|
||||
const [unlinkError, setUnlinkError] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const fetchLinkedRepos = useCallback(async () => {
|
||||
try {
|
||||
const result = await getBoardForgejoRepositories(boardId);
|
||||
setLinkedRepos(result.repositories || []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch linked repositories:", err);
|
||||
}
|
||||
}, [boardId]);
|
||||
const fetchAllRepositories = useCallback(async () => {
|
||||
try {
|
||||
const repos = await getForgejoRepositories();
|
||||
setAllRepos(repos);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch repositories:", err);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([fetchLinkedRepos(), fetchAllRepositories()]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [boardId, fetchLinkedRepos, fetchAllRepositories]);
|
||||
|
||||
|
||||
const filteredRepos = useMemo(() => {
|
||||
if (!searchQuery) return allRepos;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allRepos.filter(
|
||||
(r) =>
|
||||
(r.display_name && r.display_name.toLowerCase().includes(query)) ||
|
||||
r.owner.toLowerCase().includes(query) ||
|
||||
r.repo.toLowerCase().includes(query),
|
||||
);
|
||||
}, [allRepos, searchQuery]);
|
||||
|
||||
const handleLinkRepo = async (repositoryId: string) => {
|
||||
if (!canWrite) return;
|
||||
setIsLinking(true);
|
||||
try {
|
||||
await linkBoardForgejoRepository(boardId, repositoryId);
|
||||
await fetchLinkedRepos();
|
||||
setSearchQuery("");
|
||||
} catch (err) {
|
||||
console.error("Failed to link repository:", err);
|
||||
} finally {
|
||||
setIsLinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkRepo = async () => {
|
||||
if (!unlinkRepo) return;
|
||||
setIsDialogOpen(false);
|
||||
setUnlinkError(null);
|
||||
try {
|
||||
await unlinkBoardForgejoRepository(boardId, unlinkRepo);
|
||||
await fetchLinkedRepos();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to unlink repository";
|
||||
setUnlinkError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const linkedRepoIds = useMemo(() => new Set(linkedRepos.map((l) => l.repository_id)), [linkedRepos]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-900">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Linked Repositories
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{linkedRepos.length} repository{linkedRepos.length === 1 ? "" : "s"} linked to this board
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
placeholder="Search repositories…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-[240px]"
|
||||
/>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLinking}
|
||||
>
|
||||
Link Repository
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-sm text-slate-500">Loading…</div>
|
||||
) : linkedRepos.length === 0 && allRepos.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
No repositories found. Configure Forgejo connections in Git Projects to start tracking repositories.
|
||||
</p>
|
||||
</div>
|
||||
) : linkedRepos.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
No repositories linked to this board. Link a repository to track its issues.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{linkedRepos.map((link) => (
|
||||
<div
|
||||
key={link.id}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{link.repository.display_name || `${link.repository.owner}/${link.repository.repo}`}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Last sync: {link.repository.last_sync_at ? new Date(link.repository.last_sync_at).toLocaleDateString() : "Never"}
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUnlinkRepo(link.repository_id);
|
||||
setIsDialogOpen(true);
|
||||
}}
|
||||
className="h-6 w-6 p-0 text-rose-500 hover:bg-rose-50 hover:text-rose-600"
|
||||
title="Unlink repository"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 dark:border-slate-700">
|
||||
<h4 className="mb-3 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
Available Repositories
|
||||
</h4>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredRepos
|
||||
.filter((r) => !linkedRepoIds.has(r.id))
|
||||
.slice(0, 9)
|
||||
.map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 p-4 hover:border-blue-300 hover:bg-blue-50/50 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:border-blue-700 dark:hover:bg-blue-900/20"
|
||||
>
|
||||
<div className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{repo.display_name || `${repo.owner}/${repo.repo}`}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{repo.active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
{repo.last_sync_at && (
|
||||
<span className="text-xs text-slate-500">
|
||||
Synced {new Date(repo.last_sync_at).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 w-full text-xs"
|
||||
onClick={() => handleLinkRepo(repo.id)}
|
||||
disabled={isLinking}
|
||||
>
|
||||
Link
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ConfirmActionDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
title={
|
||||
unlinkRepo
|
||||
? "Unlink Repository"
|
||||
: "Link Repository"
|
||||
}
|
||||
description={
|
||||
unlinkRepo
|
||||
? unlinkError
|
||||
? `Error: ${unlinkError}`
|
||||
: "Are you sure you want to unlink this repository from the board? Issues from this repository will no longer appear on this board."
|
||||
: "Select a repository to link to this board."
|
||||
}
|
||||
onConfirm={
|
||||
unlinkRepo ? handleUnlinkRepo : () => setIsDialogOpen(false)
|
||||
}
|
||||
isConfirming={isLinking || (!!unlinkRepo && unlinkError !== null)}
|
||||
cancelLabel={unlinkRepo ? "Keep Linked" : "Cancel"}
|
||||
confirmLabel={unlinkRepo ? "Unlink" : undefined}
|
||||
errorStyle="panel"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ForgejoIssue } from "@/lib/api-forgejo";
|
||||
import { closeForgejoIssue } from "@/lib/api-forgejo";
|
||||
|
||||
type CloseForgejoIssueDialogProps = {
|
||||
issue: ForgejoIssue | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCloseSuccess: () => void;
|
||||
};
|
||||
|
||||
export function CloseForgejoIssueDialog({
|
||||
issue,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCloseSuccess,
|
||||
}: CloseForgejoIssueDialogProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const handleClose = async () => {
|
||||
setIsClosing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await closeForgejoIssue(issue.id);
|
||||
onCloseSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to close issue";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close Issue</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to close issue{" "}
|
||||
<span className="font-mono font-semibold">#{issue.forgejo_issue_number}</span> in{" "}
|
||||
<span className="font-mono font-semibold">{issue.repository_id}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleClose} disabled={isClosing}>
|
||||
{isClosing ? "Closing…" : "Close Issue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { ForgejoRepository } from "@/lib/api-forgejo";
|
||||
|
||||
type ForgejoIssueFiltersProps = {
|
||||
stateFilter: string;
|
||||
onStateChange: (value: string) => void;
|
||||
repoFilter: string;
|
||||
onRepoChange: (value: string) => void;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
repos: ForgejoRepository[];
|
||||
};
|
||||
|
||||
export function ForgejoIssueFilters({
|
||||
stateFilter,
|
||||
onStateChange,
|
||||
repoFilter,
|
||||
onRepoChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
repos,
|
||||
}: ForgejoIssueFiltersProps) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<Select value={stateFilter} onValueChange={(v) => { onStateChange(v); }}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="State" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={repoFilter} onValueChange={(v) => { onRepoChange(v); }}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Repository" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All repositories</SelectItem>
|
||||
{repos.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.display_name || `${r.owner}/${r.repo}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Search issues…"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-[240px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { type ColumnDef, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, XCircle } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DataTable } from "@/components/tables/DataTable";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import type { ForgejoIssue } from "@/lib/api-forgejo";
|
||||
import { closeForgejoIssue } from "@/lib/api-forgejo";
|
||||
|
||||
export type ForgejoIssuesTableProps = {
|
||||
issues: ForgejoIssue[];
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
type CloseIssueDialogProps = {
|
||||
issue: ForgejoIssue | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCloseSuccess: () => void;
|
||||
};
|
||||
|
||||
function CloseIssueDialog({ issue, open, onOpenChange, onCloseSuccess }: CloseIssueDialogProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const handleClose = async () => {
|
||||
setIsClosing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await closeForgejoIssue(issue.id);
|
||||
onCloseSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to close issue";
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close Issue</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to close issue{" "}
|
||||
<span className="font-mono font-semibold">#{issue.forgejo_issue_number}</span> in{" "}
|
||||
<span className="font-mono font-semibold">{issue.repository_id}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleClose} disabled={isClosing}>
|
||||
{isClosing ? "Closing…" : "Close Issue"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgejoIssuesTable({ issues, onRefresh }: ForgejoIssuesTableProps) {
|
||||
const [closeIssueDialogOpen, setCloseIssueDialogOpen] = useState(false);
|
||||
const [issueToClose, setIssueToClose] = useState<ForgejoIssue | null>(null);
|
||||
|
||||
const handleCloseClick = (issue: ForgejoIssue) => {
|
||||
setIssueToClose(issue);
|
||||
setCloseIssueDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseSuccess = () => {
|
||||
onRefresh();
|
||||
};
|
||||
|
||||
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "forgejo_issue_number",
|
||||
header: "#",
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
href={row.original.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
#{row.original.forgejo_issue_number}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-md truncate">{row.original.title}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "body_preview",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const body = row.original.body_preview;
|
||||
if (!body) return null;
|
||||
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
|
||||
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: "State",
|
||||
cell: ({ row }) => {
|
||||
const state = row.original.state;
|
||||
return (
|
||||
<Badge
|
||||
variant={state === "open" ? "success" : "default"}
|
||||
className={state === "open" ? "" : ""}
|
||||
>
|
||||
{state}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "author",
|
||||
header: "Author",
|
||||
},
|
||||
{
|
||||
accessorKey: "labels",
|
||||
header: "Labels",
|
||||
cell: ({ row }) => {
|
||||
const labels = row.original.labels;
|
||||
if (!labels || labels.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{labels.slice(0, 3).map((label: Record<string, unknown>, i: number) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
style={
|
||||
label.color
|
||||
? { backgroundColor: `#${label.color}`, color: "#fff" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{String(label.name || "")}
|
||||
</Badge>
|
||||
))}
|
||||
{labels.length > 3 && (
|
||||
<span className="text-xs text-slate-500">+{labels.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "forgejo_updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => {
|
||||
try {
|
||||
return new Date(row.original.forgejo_updated_at).toLocaleDateString();
|
||||
} catch {
|
||||
return row.original.forgejo_updated_at;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const issue = row.original;
|
||||
if (issue.state !== "open" || issue.is_pull_request) return null;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCloseClick(issue)}
|
||||
title="Close issue"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-rose-500" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
table={useReactTable({
|
||||
data: issues,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
})}
|
||||
isLoading={false}
|
||||
emptyState={{
|
||||
icon: (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
title: "No issues found",
|
||||
description: "Sync a repository to pull in issues, or adjust your filters.",
|
||||
}}
|
||||
/>
|
||||
<CloseIssueDialog
|
||||
issue={issueToClose}
|
||||
open={closeIssueDialogOpen}
|
||||
onOpenChange={setCloseIssueDialogOpen}
|
||||
onCloseSuccess={handleCloseSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -277,3 +277,110 @@ export async function getForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
|||
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeForgejoIssue(issueId: string): Promise<ForgejoIssue> {
|
||||
return fetchJson<ForgejoIssue>(
|
||||
`${API_BASE_URL}/api/v1/forgejo/issues/${issueId}/close`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Board Repository Linking API
|
||||
export async function getBoardForgejoRepositories(boardId: string): Promise<{
|
||||
repositories: Array<{
|
||||
id: string;
|
||||
board_id: string;
|
||||
repository_id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
repository: ForgejoRepository;
|
||||
}>;
|
||||
}> {
|
||||
return fetchJson<{
|
||||
repositories: Array<{
|
||||
id: string;
|
||||
board_id: string;
|
||||
repository_id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
repository: ForgejoRepository;
|
||||
}>;
|
||||
}>(
|
||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function linkBoardForgejoRepository(
|
||||
boardId: string,
|
||||
repositoryId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
board_id: string;
|
||||
repository_id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
repository: ForgejoRepository;
|
||||
}> {
|
||||
return fetchJson<{
|
||||
id: string;
|
||||
board_id: string;
|
||||
repository_id: string;
|
||||
organization_id: string;
|
||||
created_at: string;
|
||||
repository: ForgejoRepository;
|
||||
}>(
|
||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repository_id: repositoryId }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function unlinkBoardForgejoRepository(
|
||||
boardId: string,
|
||||
repositoryId: string,
|
||||
): Promise<void> {
|
||||
await fetch(
|
||||
`${API_BASE_URL}/api/v1/boards/${boardId}/forgejo/repositories/${repositoryId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Forgejo Metrics types
|
||||
export interface RepositorySyncHealth {
|
||||
repository_id: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
display_name: string | null;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
has_error: boolean;
|
||||
}
|
||||
|
||||
export interface ForgejoIssueMetrics {
|
||||
open_issues: number;
|
||||
closed_issues: number;
|
||||
recently_closed: number;
|
||||
stale_open: number;
|
||||
total_issues: number;
|
||||
repositories_health: RepositorySyncHealth[];
|
||||
}
|
||||
|
||||
// Forgejo Metrics API
|
||||
export async function getForgejoMetrics(params?: {
|
||||
board_id?: string;
|
||||
repository_id?: string;
|
||||
}): Promise<ForgejoIssueMetrics> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.board_id) searchParams.set("board_id", params.board_id);
|
||||
if (params?.repository_id) searchParams.set("repository_id", params.repository_id);
|
||||
const qs = searchParams.toString();
|
||||
return fetchJson<ForgejoIssueMetrics>(
|
||||
`${API_BASE_URL}/api/v1/forgejo/metrics${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue