From ae3786f64b2ab8cbca803091ba5aa289ba6daeaa Mon Sep 17 00:00:00 2001 From: null Date: Tue, 19 May 2026 04:02:04 -0500 Subject: [PATCH] =?UTF-8?q?feat(forgejo):=20batch=203=20WIP=20=E2=80=94=20?= =?UTF-8?q?metrics=20API,=20agent=20close=20APIs,=20issues=20page=20refact?= =?UTF-8?q?or,=20close=20UI=20(batch=203.1.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/agent_forgejo.py | 138 ++++++++- backend/app/api/forgejo_issues.py | 107 ++++++- backend/app/api/forgejo_metrics.py | 232 ++++++++++++++++ backend/app/main.py | 2 + backend/app/schemas/forgejo_issues.py | 11 + backend/app/schemas/forgejo_metrics.py | 31 +++ backend/app/schemas/metrics.py | 26 ++ frontend/src/app/boards/[boardId]/page.tsx | 7 + frontend/src/app/git-projects/issues/page.tsx | 155 ++++------- .../git/BoardForgejoRepositoryLinks.tsx | 261 ++++++++++++++++++ .../git/CloseForgejoIssueDialog.tsx | 70 +++++ .../components/git/ForgejoIssueFilters.tsx | 63 +++++ .../src/components/git/ForgejoIssuesTable.tsx | 241 ++++++++++++++++ frontend/src/lib/api-forgejo.ts | 107 +++++++ 14 files changed, 1350 insertions(+), 101 deletions(-) create mode 100644 backend/app/api/forgejo_metrics.py create mode 100644 backend/app/schemas/forgejo_metrics.py create mode 100644 frontend/src/components/git/BoardForgejoRepositoryLinks.tsx create mode 100644 frontend/src/components/git/CloseForgejoIssueDialog.tsx create mode 100644 frontend/src/components/git/ForgejoIssueFilters.tsx create mode 100644 frontend/src/components/git/ForgejoIssuesTable.tsx diff --git a/backend/app/api/agent_forgejo.py b/backend/app/api/agent_forgejo.py index d52b7a9..c54e101 100644 --- a/backend/app/api/agent_forgejo.py +++ b/backend/app/api/agent_forgejo.py @@ -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 "", + ) diff --git a/backend/app/api/forgejo_issues.py b/backend/app/api/forgejo_issues.py index 32bf1fb..8fa1dda 100644 --- a/backend/app/api/forgejo_issues.py +++ b/backend/app/api/forgejo_issues.py @@ -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 "", + ) diff --git a/backend/app/api/forgejo_metrics.py b/backend/app/api/forgejo_metrics.py new file mode 100644 index 0000000..ace4468 --- /dev/null +++ b/backend/app/api/forgejo_metrics.py @@ -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={}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index da35181..2bf33e6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/schemas/forgejo_issues.py b/backend/app/schemas/forgejo_issues.py index b04cf77..eb7bfc3 100644 --- a/backend/app/schemas/forgejo_issues.py +++ b/backend/app/schemas/forgejo_issues.py @@ -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 diff --git a/backend/app/schemas/forgejo_metrics.py b/backend/app/schemas/forgejo_metrics.py new file mode 100644 index 0000000..647461f --- /dev/null +++ b/backend/app/schemas/forgejo_metrics.py @@ -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] = [] \ No newline at end of file diff --git a/backend/app/schemas/metrics.py b/backend/app/schemas/metrics.py index 23c6e13..ca0ab87 100644 --- a/backend/app/schemas/metrics.py +++ b/backend/app/schemas/metrics.py @@ -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] = {} diff --git a/frontend/src/app/boards/[boardId]/page.tsx b/frontend/src/app/boards/[boardId]/page.tsx index 8ec639c..e4dd34c 100644 --- a/frontend/src/app/boards/[boardId]/page.tsx +++ b/frontend/src/app/boards/[boardId]/page.tsx @@ -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() { + {canWrite && boardId ? ( +
+ +
+ ) : null} +
{isOrgAdmin ? (