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 sqlmodel import select, func
|
||||||
from sqlalchemy import and_
|
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.core.agent_auth import get_agent_auth_context
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models.board_repository_links import BoardRepositoryLink
|
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 ForgejoIssueRead, ForgejoIssueListResponse
|
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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"])
|
router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"])
|
||||||
|
|
@ -244,3 +246,133 @@ async def read_board_issue(
|
||||||
)
|
)
|
||||||
|
|
||||||
return ForgejoIssueRead.model_validate(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 fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlmodel import select, func
|
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.core.auth import get_auth_context
|
||||||
from app.db import crud
|
from app.db import crud
|
||||||
from app.db.session import get_session
|
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_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
|
from app.services.organizations import OrganizationContext
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
@ -24,6 +27,7 @@ router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
|
||||||
SESSION_DEP = Depends(get_session)
|
SESSION_DEP = Depends(get_session)
|
||||||
AUTH_DEP = Depends(get_auth_context)
|
AUTH_DEP = Depends(get_auth_context)
|
||||||
ORG_ADMIN_DEP = Depends(require_org_admin)
|
ORG_ADMIN_DEP = Depends(require_org_admin)
|
||||||
|
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ForgejoIssueListResponse)
|
@router.get("", response_model=ForgejoIssueListResponse)
|
||||||
|
|
@ -110,3 +114,102 @@ async def get_issue(
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
return ForgejoIssueRead.model_validate(issue)
|
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.boards import router as boards_router
|
||||||
from app.api.forgejo_connections import router as forgejo_connections_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_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.forgejo_repositories import router as forgejo_repositories_router
|
||||||
from app.api.board_repository_links import router as board_repository_links_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
|
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(activity_router)
|
||||||
api_v1.include_router(forgejo_connections_router)
|
api_v1.include_router(forgejo_connections_router)
|
||||||
api_v1.include_router(forgejo_issues_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(forgejo_repositories_router)
|
||||||
api_v1.include_router(board_repository_links_router)
|
api_v1.include_router(board_repository_links_router)
|
||||||
api_v1.include_router(agent_forgejo_router)
|
api_v1.include_router(agent_forgejo_router)
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,14 @@ class ForgejoIssueUpsertResponse(SQLModel):
|
||||||
open: int = 0
|
open: int = 0
|
||||||
closed: int = 0
|
closed: int = 0
|
||||||
total: 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
|
error_rate: DashboardSeriesSet
|
||||||
wip: DashboardWipSeriesSet
|
wip: DashboardWipSeriesSet
|
||||||
pending_approvals: DashboardPendingApprovals
|
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 { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
import { BoardChatComposer } from "@/components/BoardChatComposer";
|
||||||
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
|
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
|
||||||
|
import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -3242,6 +3243,12 @@ export default function BoardDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="relative flex flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
|
||||||
{isOrgAdmin ? (
|
{isOrgAdmin ? (
|
||||||
<aside className="flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-sm md:h-full md:w-64">
|
<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";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect, useCallback } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
|
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { DataTable } from "@/components/tables/DataTable";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
getForgejoIssues,
|
getForgejoIssues,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
type ForgejoIssue,
|
type ForgejoIssue,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
} from "@/lib/api-forgejo";
|
} from "@/lib/api-forgejo";
|
||||||
|
import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters";
|
||||||
|
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
|
||||||
|
|
||||||
export default function GitIssuesPage() {
|
export default function GitIssuesPage() {
|
||||||
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
|
||||||
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
|
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [stateFilter, setStateFilter] = useState<string>("open");
|
const [stateFilter, setStateFilter] = useState<string>("open");
|
||||||
const [repoFilter, setRepoFilter] = useState<string>("all");
|
const [repoFilter, setRepoFilter] = useState<string>("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const limit = 30;
|
const limit = 30;
|
||||||
|
|
||||||
const fetchIssues = useCallback(async () => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
(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 {
|
try {
|
||||||
const result = await getForgejoIssues({
|
const result = await getForgejoIssues({
|
||||||
state: stateFilter || undefined,
|
state: stateFilter || undefined,
|
||||||
|
|
@ -53,18 +74,8 @@ export default function GitIssuesPage() {
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch issues:", 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(
|
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|
@ -89,6 +100,16 @@ export default function GitIssuesPage() {
|
||||||
<div className="max-w-md truncate">{row.original.title}</div>
|
<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",
|
accessorKey: "state",
|
||||||
header: "State",
|
header: "State",
|
||||||
|
|
@ -97,11 +118,7 @@ export default function GitIssuesPage() {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant={state === "open" ? "success" : "default"}
|
variant={state === "open" ? "success" : "default"}
|
||||||
className={
|
className={state === "open" ? "" : ""}
|
||||||
state === "open"
|
|
||||||
? ""
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{state}
|
{state}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -156,12 +173,6 @@ export default function GitIssuesPage() {
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: issues,
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -175,65 +186,17 @@ export default function GitIssuesPage() {
|
||||||
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
|
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<ForgejoIssueFilters
|
||||||
<Select value={stateFilter} onValueChange={(v) => { setStateFilter(v); setPage(1); }}>
|
stateFilter={stateFilter}
|
||||||
<SelectTrigger className="w-[120px]">
|
onStateChange={(v) => { setStateFilter(v); setPage(1); }}
|
||||||
<SelectValue placeholder="State" />
|
repoFilter={repoFilter}
|
||||||
</SelectTrigger>
|
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }}
|
||||||
<SelectContent>
|
search={search}
|
||||||
<SelectItem value="open">Open</SelectItem>
|
onSearchChange={(v) => { setSearch(v); setPage(1); }}
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
repos={repos}
|
||||||
<SelectItem value="all">All</SelectItem>
|
/>
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={repoFilter} onValueChange={(v) => { setRepoFilter(v); setPage(1); }}>
|
<ForgejoIssuesTable issues={issues} onRefresh={handleRefresh} />
|
||||||
<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]"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
|
<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}`,
|
`${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