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 sqlalchemy import and_
from app.api.deps import get_board_for_actor_read
from app.api.deps import ActorContext, get_board_for_actor_read
from app.core.agent_auth import get_agent_auth_context
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse
from app.schemas.forgejo_issues import ForgejoIssueRead, ForgejoIssueListResponse, CloseIssueResponse
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio.session import AsyncSession
from sqlmodel.ext.asyncio.session import AsyncSession
from app.models.agents import Agent
from app.core.agent_auth import AgentAuthContext
router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"])
@ -244,3 +246,133 @@ async def read_board_issue(
)
return ForgejoIssueRead.model_validate(issue)
from app.schemas.forgejo_issues import CloseIssueResponse
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
@router.post(
"/{board_id}/git/issues/{issue_id}/close",
response_model=CloseIssueResponse,
summary="Close a Forgejo issue (agent)",
description=(
"Close a Forgejo issue by its local ID as a board-lead agent. "
"Only board lead agents can close issues. The issue must belong to a "
"repository linked to the target board."
),
openapi_extra=_agent_board_openapi_hints(
intent="agent_board_close_issue",
when_to_use=[
"Lead agent needs to close a specific issue",
"Finalizing work items after completion",
"Closing issues after deployment or release",
],
when_not_to_use=[
"Worker agents closing issues (use human endpoint or ask lead)",
"Working with issues from unlinked repositories",
],
required_actor="board_lead",
routing_examples=[
{
"input": {
"intent": "close issue 123 for board xyz",
"board_id": "uuid",
"issue_id": "uuid",
},
"decision": "agent_board_close_issue",
},
],
),
responses={
status.HTTP_200_OK: {
"description": "Issue closed successfully",
"content": {
"application/json": {
"example": {
"success": True,
"issue_id": "123e4567-e89b-12d3-a456-426614174000",
"forgejo_issue_number": 42,
"state": "closed",
"forgejo_closed_at": "2026-05-19T03:43:00+00:00",
"last_synced_at": "2026-05-19T03:43:00+00:00",
}
}
},
},
status.HTTP_404_NOT_FOUND: {
"description": "Issue not found or not linked to this board",
},
status.HTTP_403_FORBIDDEN: {
"description": "Caller is not board lead",
},
status.HTTP_409_CONFLICT: {
"description": "Organization mismatch or access denied",
},
status.HTTP_502_BAD_GATEWAY: {
"description": "Forgejo API call failed",
},
},
)
async def close_board_issue(
board_id: UUID,
issue_id: UUID,
session: AsyncSession = SESSION_DEP,
board: ForgejoIssue = BOARD_READ_DEP,
agent_ctx: AgentAuthContext = AGENT_CTX_DEP,
) -> CloseIssueResponse:
"""Close a Forgejo issue as a board-lead agent.
Only board lead agents can close issues. The issue must belong to a repository
linked to the target board.
"""
# Get the issue
statement = select(ForgejoIssue).where(
and_(
ForgejoIssue.id == issue_id,
ForgejoIssue.repository_id.in_(
[link.repository_id for link in await session.exec(
select(BoardRepositoryLink).where(
BoardRepositoryLink.board_id == board_id
)
).all()]
),
)
)
issue = (await session.exec(statement)).first()
if issue is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue not found or not linked to this board",
)
# Verify agent is board lead
from app.core.openclaw.policies import OpenClawAuthorizationPolicy
OpenClawAuthorizationPolicy.require_board_lead_actor(
actor_agent=agent_ctx.agent,
detail="Only board leads can close issues",
)
# Close the issue using the service
try:
result = await close_issue_by_id(
session=session,
issue_id=issue_id,
actor_agent_id=agent_ctx.agent.id,
)
except CloseIssueNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except CloseIssueAccessError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except CloseIssueRemoteError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
return CloseIssueResponse(
success=result["success"],
issue_id=result["issue_id"],
forgejo_issue_number=result["forgejo_issue_number"],
state=result["state"],
forgejo_closed_at=result.get("forgejo_closed_at"),
last_synced_at=result.get("last_synced_at") or "",
)

View File

@ -8,12 +8,15 @@ from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import select, func
from app.api.deps import require_org_admin
from app.api.deps import get_board_for_user_write, require_org_admin
from app.core.agent_auth import get_agent_auth_context
from app.core.auth import get_auth_context
from app.db import crud
from app.db.session import get_session
from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_issues import ForgejoIssue
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead
from app.schemas.forgejo_issues import ForgejoIssueListResponse, ForgejoIssueRead, CloseIssueResponse
from app.services.forgejo_issue_close import close_issue_by_id, CloseIssueNotFoundError, CloseIssueAccessError, CloseIssueRemoteError
from app.services.organizations import OrganizationContext
if TYPE_CHECKING:
@ -24,6 +27,7 @@ router = APIRouter(prefix="/forgejo/issues", tags=["forgejo-issues"])
SESSION_DEP = Depends(get_session)
AUTH_DEP = Depends(get_auth_context)
ORG_ADMIN_DEP = Depends(require_org_admin)
BOARD_WRITE_DEP = Depends(get_board_for_user_write)
@router.get("", response_model=ForgejoIssueListResponse)
@ -110,3 +114,102 @@ async def get_issue(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return ForgejoIssueRead.model_validate(issue)
@router.post(
"/{issue_id}/close",
response_model=CloseIssueResponse,
summary="Close a Forgejo issue (human user)",
description=(
"Close a Forgejo issue by its local ID. The user must have write access "
"to the board that the issue's repository is linked to."
),
responses={
status.HTTP_200_OK: {
"description": "Issue closed successfully",
"content": {
"application/json": {
"example": {
"success": True,
"issue_id": "123e4567-e89b-12d3-a456-426614174000",
"forgejo_issue_number": 42,
"state": "closed",
"forgejo_closed_at": "2026-05-19T03:43:00+00:00",
"last_synced_at": "2026-05-19T03:43:00+00:00",
}
}
},
},
status.HTTP_404_NOT_FOUND: {
"description": "Issue not found or not linked to a board",
},
status.HTTP_403_FORBIDDEN: {
"description": "User lacks write access to the board",
},
status.HTTP_409_CONFLICT: {
"description": "Organization mismatch or access denied",
},
status.HTTP_502_BAD_GATEWAY: {
"description": "Forgejo API call failed",
},
},
)
async def close_issue(
issue_id: str,
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> CloseIssueResponse:
"""Close a Forgejo issue as an authenticated user.
The user must have write access to the board that the issue's repository
is linked to. The issue must belong to a repository linked to that board.
"""
try:
uuid = UUID(issue_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Invalid issue_id format")
issue = await crud.get_by_id(session, ForgejoIssue, uuid)
if issue is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Issue not found")
# Get the board linked to this issue's repository
link_statement = select(BoardRepositoryLink).where(
BoardRepositoryLink.repository_id == issue.repository_id,
)
link = await session.exec(link_statement).first()
if link is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue repository is not linked to any board",
)
# Verify the user has write access to the board
board = await get_board_for_user_write(
board_id=str(link.board_id),
session=session,
auth=ctx,
)
# Close the issue using the service
try:
result = await close_issue_by_id(
session=session,
issue_id=uuid,
actor_user_id=ctx.user.id,
)
except CloseIssueNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
except CloseIssueAccessError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except CloseIssueRemoteError as e:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
return CloseIssueResponse(
success=result["success"],
issue_id=result["issue_id"],
forgejo_issue_number=result["forgejo_issue_number"],
state=result["state"],
forgejo_closed_at=result.get("forgejo_closed_at"),
last_synced_at=result.get("last_synced_at") or "",
)

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.forgejo_connections import router as forgejo_connections_router
from app.api.forgejo_issues import router as forgejo_issues_router
from app.api.forgejo_metrics import router as forgejo_metrics_router
from app.api.forgejo_repositories import router as forgejo_repositories_router
from app.api.board_repository_links import router as board_repository_links_router
from app.api.agent_forgejo import router as agent_forgejo_router
@ -560,6 +561,7 @@ api_v1.include_router(agents_router)
api_v1.include_router(activity_router)
api_v1.include_router(forgejo_connections_router)
api_v1.include_router(forgejo_issues_router)
api_v1.include_router(forgejo_metrics_router)
api_v1.include_router(forgejo_repositories_router)
api_v1.include_router(board_repository_links_router)
api_v1.include_router(agent_forgejo_router)

View File

@ -57,3 +57,14 @@ class ForgejoIssueUpsertResponse(SQLModel):
open: int = 0
closed: int = 0
total: int = 0
class CloseIssueResponse(SQLModel):
"""Response for issue close operations."""
success: bool
issue_id: UUID
forgejo_issue_number: int
state: str
forgejo_closed_at: str | None = None
last_synced_at: str

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
wip: DashboardWipSeriesSet
pending_approvals: DashboardPendingApprovals
class ForgejoIssueMetrics(SQLModel):
"""Forgejo issue tracking metrics."""
open_issues: int
closed_issues: int
closed_last_7_days: int
closed_last_30_days: int
stale_open_issues: int
repositories_synced: int
last_sync_timestamps: dict[str, str]
sync_error_counts: dict[str, int]
class MetricsResponse(SQLModel):
"""Generic metrics response wrapper."""
open_issues: int = 0
closed_issues: int = 0
closed_last_7_days: int = 0
closed_last_30_days: int = 0
stale_open_issues: int = 0
repositories_synced: int = 0
last_sync_timestamps: dict[str, str] = {}
sync_error_counts: dict[str, int] = {}

View File

@ -36,6 +36,7 @@ import {
import { DashboardShell } from "@/components/templates/DashboardShell";
import { BoardChatComposer } from "@/components/BoardChatComposer";
import { TaskCustomFieldsEditor } from "./TaskCustomFieldsEditor";
import { BoardForgejoRepositoryLinks } from "@/components/git/BoardForgejoRepositoryLinks";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -3242,6 +3243,12 @@ export default function BoardDetailPage() {
</div>
</div>
{canWrite && boardId ? (
<div className="w-full">
<BoardForgejoRepositoryLinks boardId={boardId} canWrite={canWrite} />
</div>
) : null}
<div className="relative flex flex-col gap-4 p-4 md:flex-row md:gap-6 md:p-6">
{isOrgAdmin ? (
<aside className="flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-sm md:h-full md:w-64">

View File

@ -2,45 +2,66 @@
export const dynamic = "force-dynamic";
import { useMemo, useState, useEffect, useCallback } from "react";
import { useMemo, useState, useEffect } from "react";
import {
type ColumnDef,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { DataTable } from "@/components/tables/DataTable";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {
getForgejoIssues,
getForgejoRepositories,
type ForgejoIssue,
type ForgejoRepository,
} from "@/lib/api-forgejo";
import { ForgejoIssueFilters } from "@/components/git/ForgejoIssueFilters";
import { ForgejoIssuesTable } from "@/components/git/ForgejoIssuesTable";
export default function GitIssuesPage() {
const [issues, setIssues] = useState<ForgejoIssue[]>([]);
const [repos, setRepos] = useState<ForgejoRepository[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [stateFilter, setStateFilter] = useState<string>("open");
const [repoFilter, setRepoFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const limit = 30;
const fetchIssues = useCallback(async () => {
setLoading(true);
useEffect(() => {
(async () => {
try {
const repos = await getForgejoRepositories();
setRepos(repos);
} catch (err) {
console.error("Failed to fetch repositories:", err);
}
})();
}, []);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const result = await getForgejoIssues({
state: stateFilter || undefined,
repository_id: repoFilter !== "all" ? repoFilter : undefined,
search: search || undefined,
page,
limit,
});
setIssues(result.items);
setTotal(result.total);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") return;
console.error("Failed to fetch issues:", err);
}
})();
return () => controller.abort();
}, [stateFilter, repoFilter, search, page]);
const handleRefresh = async () => {
try {
const result = await getForgejoIssues({
state: stateFilter || undefined,
@ -53,18 +74,8 @@ export default function GitIssuesPage() {
setTotal(result.total);
} catch (err) {
console.error("Failed to fetch issues:", err);
} finally {
setLoading(false);
}
}, [stateFilter, repoFilter, search, page]);
useEffect(() => {
getForgejoRepositories().then(setRepos).catch(console.error);
}, []);
useEffect(() => {
fetchIssues();
}, [fetchIssues]);
};
const columns: ColumnDef<ForgejoIssue>[] = useMemo(
() => [
@ -89,6 +100,16 @@ export default function GitIssuesPage() {
<div className="max-w-md truncate">{row.original.title}</div>
),
},
{
accessorKey: "body_preview",
header: "Description",
cell: ({ row }) => {
const body = row.original.body_preview;
if (!body) return null;
const truncated = body.length > 120 ? body.slice(0, 120) + "…" : body;
return <div className="max-w-xs truncate text-sm text-slate-500">{truncated}</div>;
},
},
{
accessorKey: "state",
header: "State",
@ -97,11 +118,7 @@ export default function GitIssuesPage() {
return (
<Badge
variant={state === "open" ? "success" : "default"}
className={
state === "open"
? ""
: ""
}
className={state === "open" ? "" : ""}
>
{state}
</Badge>
@ -156,12 +173,6 @@ export default function GitIssuesPage() {
[],
);
const table = useReactTable({
data: issues,
columns,
getCoreRowModel: getCoreRowModel(),
});
const totalPages = Math.ceil(total / limit);
return (
@ -175,65 +186,17 @@ export default function GitIssuesPage() {
description={`${total} issue${total === 1 ? "" : "s"} from tracked repositories.`}
stickyHeader
>
<div className="mb-4 flex flex-wrap gap-3">
<Select value={stateFilter} onValueChange={(v) => { setStateFilter(v); setPage(1); }}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="State" />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<Select value={repoFilter} onValueChange={(v) => { setRepoFilter(v); setPage(1); }}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Repository" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All repositories</SelectItem>
{repos.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.display_name || `${r.owner}/${r.repo}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search issues…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-[240px]"
<ForgejoIssueFilters
stateFilter={stateFilter}
onStateChange={(v) => { setStateFilter(v); setPage(1); }}
repoFilter={repoFilter}
onRepoChange={(v) => { setRepoFilter(v); setPage(1); }}
search={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
repos={repos}
/>
</div>
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
<DataTable
table={table}
isLoading={loading}
emptyState={{
icon: (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
),
title: "No issues found",
description: "Sync a repository to pull in issues, or adjust your filters.",
}}
/>
</div>
<ForgejoIssuesTable issues={issues} onRefresh={handleRefresh} />
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">

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