feat(forgejo): batch 3 WIP — metrics API, agent close APIs, issues page refactor, close UI (batch 3.1.0)

This commit is contained in:
null 2026-05-19 04:02:04 -05:00
parent d56ccb31da
commit ae3786f64b
14 changed files with 1350 additions and 101 deletions

View File

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

View File

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

View File

@ -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={},
)

View File

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

View File

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

View File

@ -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] = []

View File

@ -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] = {}

View File

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

View File

@ -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">
@ -260,4 +223,4 @@ export default function GitIssuesPage() {
)} )}
</DashboardPageLayout> </DashboardPageLayout>
); );
} }

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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}
/>
</>
);
}

View File

@ -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}` : ""}`,
);
}