diff --git a/backend/app/api/agent_forgejo.py b/backend/app/api/agent_forgejo.py new file mode 100644 index 0000000..d52b7a9 --- /dev/null +++ b/backend/app/api/agent_forgejo.py @@ -0,0 +1,246 @@ +"""Agent-scoped Forgejo issue read APIs for board-linked repositories.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +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.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 + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + + +router = APIRouter(prefix="/agent/boards", tags=["agent-board-issues"]) +SESSION_DEP = Depends(get_session) +BOARD_READ_DEP = Depends(get_board_for_actor_read) +AGENT_CTX_DEP = Depends(get_agent_auth_context) + + +def _agent_board_openapi_hints( + *, + intent: str, + when_to_use: list[str], + routing_examples: list[dict[str, object]], + required_actor: str = "any_agent", + when_not_to_use: list[str] | None = None, + routing_policy: list[str] | None = None, +) -> dict[str, object]: + """Generate LLM routing hints for board-scoped agent endpoints.""" + return { + "x-llm-intent": intent, + "x-when-to-use": when_to_use, + "x-when-not-to-use": when_not_to_use + or [ + "Use a more specific endpoint for direct state mutation or direct messaging.", + ], + "x-required-actor": required_actor, + "x-prerequisites": [ + "Authenticated agent token", + "Board access is validated before execution", + ], + "x-side-effects": [ + "Read-only access to issues linked to board repositories", + ], + "x-negative-guidance": [ + "Avoid this endpoint when a focused sibling endpoint handles the action.", + ], + "x-routing-policy": routing_policy + or [ + "Use when the request intent matches this board-scoped route.", + "Prefer dedicated mutation/read routes once intent is narrowed.", + ], + "x-routing-policy-examples": routing_examples, + } + + +@router.get( + "/{board_id}/git/issues", + response_model=ForgejoIssueListResponse, + summary="List issues for board's linked repositories", + description=( + "List Forgejo issues from repositories linked to the specified board.\n\n" + "Use this endpoint when an agent needs to discover issues across all " + "repositories associated with its assigned board.\n\n" + "LLM Routing Guidance:\n" + "- Use when you need to list issues for a specific board's repositories\n" + "- Filter by state, search text, or assignee for targeted results\n" + "- Use this instead of generic issue lists when board context matters\n" + "- Exclude pull requests automatically" + ), + openapi_extra=_agent_board_openapi_hints( + intent="board_issue_discovery", + when_to_use=[ + "Need to list issues for a specific board's repositories", + "Looking for issues to assign or track", + "Discovering work items related to a board's scope", + ], + when_not_to_use=[ + "Listing all issues across all repositories (use forgejo issues list)", + "Working with issues from unlinked repositories", + "Need repository-agnostic issue search", + ], + routing_examples=[ + { + "input": { + "intent": "list issues for board xyz", + "board_id": "uuid", + }, + "decision": "agent_board_list_issues", + }, + { + "input": { + "intent": "find issues assigned to me", + "assignee": "me", + }, + "decision": "agent_board_list_issues (with assignee filter)", + }, + ], + ), +) +async def list_board_issues( + board_id: UUID, + session: AsyncSession = SESSION_DEP, + board: ForgejoIssue = BOARD_READ_DEP, + state: str | None = Query(default=None, description="Filter by issue state (open/closed)"), + search: str | None = Query(default=None, description="Search in title/body"), + page: int = Query(default=1, ge=1, description="Page number"), + limit: int = Query(default=30, ge=1, le=100, description="Items per page"), +) -> ForgejoIssueListResponse: + """List issues for repositories linked to a board.""" + # Get linked repositories + link_statement = select(BoardRepositoryLink).where( + BoardRepositoryLink.board_id == board_id + ) + links = (await session.exec(link_statement)).all() + + if not links: + return ForgejoIssueListResponse( + items=[], + total=0, + page=page, + limit=limit, + ) + + repository_ids = [link.repository_id for link in links] + + # Build base query + statement = select(ForgejoIssue).where( + ForgejoIssue.repository_id.in_(repository_ids) + ) + + # Apply filters + if state: + statement = statement.where(ForgejoIssue.state == state) + if search: + statement = statement.where( + ForgejoIssue.title.ilike(f"%{search}%") | + ForgejoIssue.body_preview.ilike(f"%{search}%") + ) + + # Count total + count_statement = select(func.count(ForgejoIssue.id)).where( + ForgejoIssue.repository_id.in_(repository_ids) + ) + if state: + count_statement = count_statement.where(ForgejoIssue.state == state) + if search: + count_statement = count_statement.where( + ForgejoIssue.title.ilike(f"%{search}%") | + ForgejoIssue.body_preview.ilike(f"%{search}%") + ) + + total = await session.scalar(count_statement) or 0 + + # Apply pagination and execute + offset = (page - 1) * limit + statement = statement.offset(offset).limit(limit) + issues = (await session.exec(statement)).all() + + return ForgejoIssueListResponse( + items=[ForgejoIssueRead.model_validate(issue) for issue in issues], + total=total, + page=page, + limit=limit, + ) + + +@router.get( + "/{board_id}/git/issues/{issue_id}", + response_model=ForgejoIssueRead, + summary="Read one issue from board-linked repositories", + description=( + "Read a specific Forgejo issue by id, ensuring it belongs to a repository " + "linked to the specified board.\n\n" + "Use this endpoint when an agent needs to inspect a specific issue " + "within the context of a board's repositories." + ), + openapi_extra=_agent_board_openapi_hints( + intent="board_issue_inspection", + when_to_use=[ + "Need to read a specific issue's details", + "Verifying issue state before taking action", + "Inspecting issue metadata for board context", + ], + when_not_to_use=[ + "Listing multiple issues (use list endpoint)", + "Working with issues from unlinked repositories", + ], + routing_examples=[ + { + "input": { + "intent": "read issue 123 for board xyz", + "board_id": "uuid", + "issue_id": "uuid", + }, + "decision": "agent_board_read_issue", + }, + ], + ), +) +async def read_board_issue( + board_id: UUID, + issue_id: UUID, + session: AsyncSession = SESSION_DEP, + board: ForgejoIssue = BOARD_READ_DEP, +) -> ForgejoIssueRead: + """Read one issue from board-linked repositories.""" + # Get linked repositories + link_statement = select(BoardRepositoryLink).where( + BoardRepositoryLink.board_id == board_id + ) + links = (await session.exec(link_statement)).all() + + if not links: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No repositories linked to this board", + ) + + repository_ids = [link.repository_id for link in links] + + # Find issue + statement = select(ForgejoIssue).where( + and_( + ForgejoIssue.id == issue_id, + ForgejoIssue.repository_id.in_(repository_ids), + ) + ) + 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", + ) + + return ForgejoIssueRead.model_validate(issue) diff --git a/backend/app/api/board_repository_links.py b/backend/app/api/board_repository_links.py new file mode 100644 index 0000000..b8c7760 --- /dev/null +++ b/backend/app/api/board_repository_links.py @@ -0,0 +1,116 @@ +"""API routes for board-to-repository linking operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import select +from sqlalchemy.exc import IntegrityError + +from app.api.deps import ( + get_board_for_actor_read, + get_board_for_actor_write, +) +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.db.session import get_session +from app.models.forgejo_repositories import ForgejoRepository +from app.models.board_repository_links import BoardRepositoryLink +from app.schemas.board_repository_links import ( + BoardRepositoryLinkCreate, + BoardRepositoryLinkDeleteResponse, + BoardRepositoryLinkRead, + BoardRepositoryLinkResponse, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + + +router = APIRouter(prefix="/boards/{board_id}/forgejo/repositories", tags=["board-repositories"]) +logger = get_logger(__name__) +SESSION_DEP = Depends(get_session) +BOARD_READ_DEP = Depends(get_board_for_actor_read) +BOARD_WRITE_DEP = Depends(get_board_for_actor_write) + + +@router.get("", response_model=list[BoardRepositoryLinkRead]) +async def list_board_repositories( + board_id: UUID, + session: AsyncSession = SESSION_DEP, + board: BoardRepositoryLink = BOARD_READ_DEP, +) -> list[BoardRepositoryLinkRead]: + """List repositories linked to a board.""" + statement = select(BoardRepositoryLink).where( + BoardRepositoryLink.board_id == board_id + ) + links = (await session.exec(statement)).all() + return [BoardRepositoryLinkRead.model_validate(link) for link in links] + + +@router.post("", response_model=BoardRepositoryLinkResponse) +async def link_repository_to_board( + board_id: UUID, + payload: BoardRepositoryLinkCreate, + session: AsyncSession = SESSION_DEP, + board: BoardRepositoryLink = BOARD_WRITE_DEP, +) -> BoardRepositoryLinkResponse: + """Link a Forgejo repository to a board.""" + # Verify repository belongs to same organization as board + repository = await crud.get_by_id(session, ForgejoRepository, payload.repository_id) + if repository is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repository not found or access denied", + ) + if repository.organization_id != board.organization_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Repository must belong to the same organization as the board", + ) + + # Create the link + link = BoardRepositoryLink( + board_id=board_id, + repository_id=payload.repository_id, + organization_id=board.organization_id, + ) + try: + await crud.create(session, BoardRepositoryLink, **link.model_dump()) + await session.flush() + link_read = BoardRepositoryLinkRead.model_validate(link) + return BoardRepositoryLinkResponse(link=link_read) + except IntegrityError: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Repository is already linked to this board", + ) + + +@router.delete("/{repository_id}", response_model=BoardRepositoryLinkDeleteResponse) +async def unlink_repository_from_board( + board_id: UUID, + repository_id: UUID, + session: AsyncSession = SESSION_DEP, + board: BoardRepositoryLink = BOARD_WRITE_DEP, +) -> BoardRepositoryLinkDeleteResponse: + """Remove a repository link from a board.""" + statement = select(BoardRepositoryLink).where( + BoardRepositoryLink.board_id == board_id, + BoardRepositoryLink.repository_id == repository_id, + ) + link = (await session.exec(statement)).first() + if link is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Repository link not found", + ) + await session.delete(link) + await session.commit() + return BoardRepositoryLinkDeleteResponse( + success=True, + message="Repository unlinked successfully", + ) diff --git a/backend/app/main.py b/backend/app/main.py index 9f0befb..da35181 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -24,6 +24,8 @@ 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_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 from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router from app.api.metrics import router as metrics_router @@ -559,6 +561,8 @@ 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_repositories_router) +api_v1.include_router(board_repository_links_router) +api_v1.include_router(agent_forgejo_router) api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2f03aef..0c16d8c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -8,6 +8,7 @@ from app.models.board_group_memory import BoardGroupMemory from app.models.board_groups import BoardGroup from app.models.board_memory import BoardMemory from app.models.board_onboarding import BoardOnboardingSession +from app.models.board_repository_links import BoardRepositoryLink from app.models.board_webhook_payloads import BoardWebhookPayload from app.models.board_webhooks import BoardWebhook from app.models.boards import Board @@ -44,6 +45,7 @@ __all__ = [ "BoardOnboardingSession", "BoardGroup", "Board", + "BoardRepositoryLink", "ForgejoConnection", "ForgejoRepository", "Gateway", @@ -64,4 +66,5 @@ __all__ = [ "Tag", "TagAssignment", "User", + "BoardRepositoryLink", ] diff --git a/backend/app/models/board_repository_links.py b/backend/app/models/board_repository_links.py new file mode 100644 index 0000000..c57a066 --- /dev/null +++ b/backend/app/models/board_repository_links.py @@ -0,0 +1,33 @@ +"""Board-to-Forgejo-repository link model for board-specific issue filtering.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import UniqueConstraint +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.tenancy import TenantScoped + +RUNTIME_ANNOTATION_TYPES = (datetime,) + + +class BoardRepositoryLink(TenantScoped, table=True): + """Link between a board and a Forgejo-tracked repository.""" + + __tablename__ = "board_repository_links" # pyright: ignore[reportAssignmentType] + __table_args__ = ( + UniqueConstraint( + "board_id", + "repository_id", + name="uq_board_repository_links_board_repository", + ), + ) + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + repository_id: UUID = Field(foreign_key="forgejo_repositories.id", index=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/board_repository_links.py b/backend/app/schemas/board_repository_links.py new file mode 100644 index 0000000..a000507 --- /dev/null +++ b/backend/app/schemas/board_repository_links.py @@ -0,0 +1,47 @@ +"""Schemas for board-to-repository linking operations.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict +from sqlmodel import SQLModel + + +class BoardRepositoryLinkCreate(SQLModel): + """Schema for creating a board repository link.""" + + repository_id: UUID + + +class BoardRepositoryLinkRead(SQLModel): + """Schema for reading a board repository link.""" + + id: UUID + board_id: UUID + repository_id: UUID + organization_id: UUID + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class BoardRepositoryLinkList(BaseModel): + """List response for board repository links.""" + + links: list[BoardRepositoryLinkRead] + + +class BoardRepositoryLinkResponse(BaseModel): + """Single link response with success status.""" + + success: bool = True + link: BoardRepositoryLinkRead | None = None + + +class BoardRepositoryLinkDeleteResponse(BaseModel): + """Delete response with success status.""" + + success: bool = True + message: str | None = None diff --git a/backend/app/services/forgejo_issue_close.py b/backend/app/services/forgejo_issue_close.py new file mode 100644 index 0000000..07504b8 --- /dev/null +++ b/backend/app/services/forgejo_issue_close.py @@ -0,0 +1,156 @@ +"""Service for closing Forgejo issues and updating local cache.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlmodel import select + +from app.core.logging import get_logger +from app.core.time import utcnow +from app.db import crud +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_issues import ForgejoIssue +from app.models.forgejo_repositories import ForgejoRepository +from app.services.forgejo_client import get_forgejo_client + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio.session import AsyncSession + +logger = get_logger(__name__) + + +class CloseIssueError(Exception): + """Base exception for close issue errors.""" + + +class CloseIssueNotFoundError(CloseIssueError): + """Raised when issue or repository is not found.""" + + +class CloseIssueAccessError(CloseIssueError): + """Raised when access is denied to close issue.""" + + +class CloseIssueRemoteError(CloseIssueError): + """Raised when remote Forgejo API call fails.""" + + +async def close_cached_issue( + session: AsyncSession, + issue: ForgejoIssue, + actor_agent_id: UUID | None = None, + actor_user_id: UUID | None = None, +) -> dict[str, object]: + """ + Close a Forgejo issue remotely and update local cache. + + Args: + session: Async session for database operations + issue: ForgejoIssue to close + actor_agent_id: Optional agent ID performing the close + actor_user_id: Optional user ID performing the close + + Returns: + Normalized result dict with success status and details + + Raises: + CloseIssueNotFoundError: If repository or connection not found + CloseIssueAccessError: If organization mismatch + CloseIssueRemoteError: If Forgejo API call fails + """ + # Load repository and connection + repository = await crud.get_by_id(session, ForgejoRepository, issue.repository_id) + if repository is None: + raise CloseIssueNotFoundError("Repository not found") + + connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) + if connection is None: + raise CloseIssueNotFoundError("Connection not found") + + # Verify organization matches + if issue.organization_id != repository.organization_id: + raise CloseIssueAccessError("Organization mismatch") + if issue.organization_id != connection.organization_id: + raise CloseIssueAccessError("Organization mismatch") + + # Close issue on Forgejo first + try: + async with get_forgejo_client(connection) as client: + result = await client.close_issue( + owner=repository.owner, + repo=repository.repo, + issue_number=issue.forgejo_issue_number, + ) + except Exception as e: + raise CloseIssueRemoteError(f"Failed to close issue on Forgejo: {e}") + + # Only update local cache if Forgejo call succeeded + issue.state = "closed" + issue.forgejo_closed_at = utcnow() + issue.last_synced_at = utcnow() + + # Update the issue in the session + session.add(issue) + await session.flush() + + logger.info( + "forgejo.issue.closed", + extra={ + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "repository_id": str(repository.id), + "organization_id": str(issue.organization_id), + "actor_agent_id": str(actor_agent_id) if actor_agent_id else None, + "actor_user_id": str(actor_user_id) if actor_user_id else None, + }, + ) + + return { + "success": True, + "issue_id": str(issue.id), + "forgejo_issue_number": issue.forgejo_issue_number, + "state": "closed", + "forgejo_closed_at": issue.forgejo_closed_at.isoformat() if issue.forgejo_closed_at else None, + "last_synced_at": issue.last_synced_at.isoformat(), + } + + +async def close_issue_by_id( + session: AsyncSession, + issue_id: UUID, + actor_agent_id: UUID | None = None, + actor_user_id: UUID | None = None, +) -> dict[str, object]: + """ + Close a Forgejo issue by ID. + + Args: + session: Async session for database operations + issue_id: LocalForgejoIssue ID to close + actor_agent_id: Optional agent ID performing the close + actor_user_id: Optional user ID performing the close + + Returns: + Normalized result dict with success status and details + + Raises: + CloseIssueNotFoundError: If issue not found + CloseIssueAccessError: If access is denied + CloseIssueRemoteError: If Forgejo API call fails + """ + # Find the issue + statement = select(ForgejoIssue).where(ForgejoIssue.id == issue_id) + issue = (await session.exec(statement)).first() + + if issue is None: + raise CloseIssueNotFoundError("Issue not found") + + # Close the issue + return await close_cached_issue( + session=session, + issue=issue, + actor_agent_id=actor_agent_id, + actor_user_id=actor_user_id, + ) diff --git a/backend/migrations/versions/a1b2c3d4e5f7_add_board_repository_links.py b/backend/migrations/versions/a1b2c3d4e5f7_add_board_repository_links.py new file mode 100644 index 0000000..f15b770 --- /dev/null +++ b/backend/migrations/versions/a1b2c3d4e5f7_add_board_repository_links.py @@ -0,0 +1,63 @@ +"""Add board_repository_links table for board-to-repository associations. + +Revision ID: a1b2c3d4e5f7 +Revises: f5a2b3c4d5e6 +Create Date: 2026-05-19 01:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f7" +down_revision = "f5a2b3c4d5e6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + """Create board_repository_links table.""" + bind = op.get_bind() + inspector = sa.inspect(bind) + + if not inspector.has_table("board_repository_links"): + op.create_table( + "board_repository_links", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("repository_id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), + sa.ForeignKeyConstraint(["repository_id"], ["forgejo_repositories.id"]), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + ) + op.create_index( + "ix_board_repository_links_board_id", + "board_repository_links", + ["board_id"], + ) + op.create_index( + "ix_board_repository_links_repository_id", + "board_repository_links", + ["repository_id"], + ) + op.create_index( + "ix_board_repository_links_org_id", + "board_repository_links", + ["organization_id"], + ) + op.create_unique_constraint( + "uq_board_repository_links_board_repository", + "board_repository_links", + ["board_id", "repository_id"], + ) + + +def downgrade() -> None: + """Drop board_repository_links table.""" + op.drop_table("board_repository_links") diff --git a/frontend/src/app/git-projects/connections/page.tsx b/frontend/src/app/git-projects/connections/page.tsx index 96ebbb2..8369bba 100644 --- a/frontend/src/app/git-projects/connections/page.tsx +++ b/frontend/src/app/git-projects/connections/page.tsx @@ -10,6 +10,7 @@ import { ForgejoConnectionsTable } from "@/components/git/ForgejoConnectionsTabl import { getForgejoConnections, deleteForgejoConnection, + validateConnection, type ForgejoConnection, } from "@/lib/api-forgejo"; @@ -54,6 +55,26 @@ export default function ForgejoConnectionsPage() { } }; + const handleValidateConnection = async (connection: ForgejoConnection) => { + try { + const result = await validateConnection(connection.id); + if (result.ok) { + alert( + `Connection validated successfully!\n\n` + + `Response time: ${result.response_time_ms}ms` + ); + } else { + alert( + `Connection validation failed: ${result.error_message || "Unknown error"}` + ); + } + return result; + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to validate connection"); + throw err; + } + }; + return ( )} diff --git a/frontend/src/app/git-projects/issues/page.tsx b/frontend/src/app/git-projects/issues/page.tsx new file mode 100644 index 0000000..832e9d2 --- /dev/null +++ b/frontend/src/app/git-projects/issues/page.tsx @@ -0,0 +1,263 @@ +"use client"; + +export const dynamic = "force-dynamic"; + +import { useMemo, useState, useEffect, useCallback } 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"; + +export default function GitIssuesPage() { + const [issues, setIssues] = useState([]); + const [repos, setRepos] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [stateFilter, setStateFilter] = useState("open"); + const [repoFilter, setRepoFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + const limit = 30; + + const fetchIssues = useCallback(async () => { + setLoading(true); + 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) { + 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[] = useMemo( + () => [ + { + accessorKey: "forgejo_issue_number", + header: "#", + cell: ({ row }) => ( + + #{row.original.forgejo_issue_number} + + ), + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => ( +
{row.original.title}
+ ), + }, + { + accessorKey: "state", + header: "State", + cell: ({ row }) => { + const state = row.original.state; + return ( + + {state} + + ); + }, + }, + { + accessorKey: "author", + header: "Author", + }, + { + accessorKey: "labels", + header: "Labels", + cell: ({ row }) => { + const labels = row.original.labels; + if (!labels || labels.length === 0) return null; + return ( +
+ {labels.slice(0, 3).map((label: Record, i: number) => ( + + {String(label.name || "")} + + ))} + {labels.length > 3 && ( + +{labels.length - 3} + )} +
+ ); + }, + }, + { + 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; + } + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: issues, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + const totalPages = Math.ceil(total / limit); + + return ( + +
+ + + + + { setSearch(e.target.value); setPage(1); }} + className="w-[240px]" + /> +
+ +
+ + + + + + ), + title: "No issues found", + description: "Sync a repository to pull in issues, or adjust your filters.", + }} + /> +
+ + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} ({total} total) + +
+ + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/git-projects/page.tsx b/frontend/src/app/git-projects/page.tsx index 39fbaed..7428a63 100644 --- a/frontend/src/app/git-projects/page.tsx +++ b/frontend/src/app/git-projects/page.tsx @@ -2,7 +2,7 @@ export const dynamic = "force-dynamic"; -import { useMemo } from "react"; +import { useMemo, useState, useEffect, useCallback } from "react"; import { type ColumnDef, @@ -10,45 +10,150 @@ import { useReactTable, } from "@tanstack/react-table"; +import Link from "next/link"; + import { useAuth } from "@/auth/clerk"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { DataTable } from "@/components/tables/DataTable"; - -type GitProject = { - id: string; - name: string; - url: string; - updatedAt: string; -}; - -const EMPTY_STATE_DATA = { - title: "No repositories tracked yet", - description: - "Connect a Forgejo instance to start tracking issues and pull requests from your Git projects.", -}; - -const columns: ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - }, - { - accessorKey: "url", - header: "URL", - }, - { - accessorKey: "updatedAt", - header: "Updated", - }, -]; +import { Button } from "@/components/ui/button"; +import { + getForgejoRepositories, + type ForgejoRepository, + syncRepository, +} from "@/lib/api-forgejo"; export default function GitProjectsPage() { const _useAuth = useAuth(); + const [repositories, setRepositories] = useState([]); + const [loading, setLoading] = useState(true); + const [syncingId, setSyncingId] = useState(null); + const [syncResult, setSyncResult] = useState<{ + repoName: string; + created: number; + updated: number; + open: number; + closed: number; + } | null>(null); - const gitProjects: GitProject[] = useMemo(() => [], []); + const fetchRepos = useCallback(async () => { + try { + const repos = await getForgejoRepositories(); + setRepositories(repos); + } catch (err) { + console.error("Failed to fetch repositories:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchRepos(); + }, [fetchRepos]); + + const handleSync = useCallback( + async (repo: ForgejoRepository) => { + setSyncingId(repo.id); + setSyncResult(null); + try { + const result = await syncRepository(repo.id); + setSyncResult({ + repoName: `${repo.owner}/${repo.repo}`, + created: result.created, + updated: result.updated, + open: result.open, + closed: result.closed, + }); + await fetchRepos(); + } catch (err) { + console.error("Sync failed:", err); + } finally { + setSyncingId(null); + } + }, + [fetchRepos], + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + accessorKey: "display_name", + header: "Repository", + cell: ({ row }) => { + const repo = row.original; + const name = + repo.display_name || `${repo.owner}/${repo.repo}`; + return ( +
+
+ {name} +
+
+ {repo.owner}/{repo.repo} +
+
+ ); + }, + }, + { + accessorKey: "connection", + header: "Connection", + cell: ({ row }) => { + const conn = row.original.connection; + return conn ? conn.name : "—"; + }, + }, + { + accessorKey: "active", + header: "Status", + cell: ({ row }) => ( + + {row.original.active ? "Active" : "Inactive"} + + ), + }, + { + accessorKey: "last_sync_at", + header: "Last Synced", + cell: ({ row }) => { + const val = row.original.last_sync_at; + if (!val) return "—"; + try { + return new Date(val).toLocaleString(); + } catch { + return val; + } + }, + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const repo = row.original; + const isSyncing = syncingId === repo.id; + return ( + + ); + }, + }, + ], + [handleSync, syncingId], + ); const table = useReactTable({ - data: gitProjects, + data: repositories, columns, getCoreRowModel: getCoreRowModel(), }); @@ -61,13 +166,34 @@ export default function GitProjectsPage() { signUpForceRedirectUrl: "/git-projects", }} title="Git Projects" - description={`${gitProjects.length} repository${gitProjects.length === 1 ? "" : "s"} tracked.`} + description={`${repositories.length} repositor${repositories.length === 1 ? "y" : "ies"} tracked.`} stickyHeader > -
+ {syncResult && ( +
+ {syncResult.repoName} synced:{" "} + {syncResult.created} created, {syncResult.updated} updated,{" "} + {syncResult.open} open, {syncResult.closed} closed +
+ )} + +
+ + + + + + +
+ +
), - title: EMPTY_STATE_DATA.title, - description: EMPTY_STATE_DATA.description, - actionHref: "/git-projects/connect", - actionLabel: "Connect repository", + title: "No repositories tracked yet", + description: + "Connect a Forgejo instance and add repositories to start tracking issues.", + actionHref: "/git-projects/connections", + actionLabel: "Set up connection", }} />
); -} +} \ No newline at end of file diff --git a/frontend/src/app/git-projects/repositories/page.tsx b/frontend/src/app/git-projects/repositories/page.tsx index 9a0f9d9..f5baac7 100644 --- a/frontend/src/app/git-projects/repositories/page.tsx +++ b/frontend/src/app/git-projects/repositories/page.tsx @@ -10,6 +10,8 @@ import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTa import { getForgejoRepositories, deleteForgejoRepository, + syncRepository, + validateRepository, type ForgejoRepository, } from "@/lib/api-forgejo"; @@ -54,6 +56,47 @@ export default function ForgejoRepositoriesPage() { } }; + const handleSync = async (repository: ForgejoRepository) => { + try { + const result = await syncRepository(repository.id); + alert( + `Sync completed!\n\n` + + `Created: ${result.created}\n` + + `Updated: ${result.updated}\n` + + `Open: ${result.open}\n` + + `Closed: ${result.closed}\n` + + `Total: ${result.total}` + ); + // Refetch to update last_sync_at + const data = await getForgejoRepositories(); + setRepositories(data); + return result; + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to sync repository"); + throw err; + } + }; + + const handleValidateRepository = async (repository: ForgejoRepository) => { + try { + const result = await validateRepository(repository.id); + if (result.ok) { + alert( + `Repository is valid!\n\n` + + `Repository exists: ${result.repo_exists ? "Yes" : "No"}` + ); + } else { + alert( + `Repository validation failed: ${result.error_message || "Unknown error"}` + ); + } + return result; + } catch (err) { + alert(err instanceof Error ? err.message : "Failed to validate repository"); + throw err; + } + }; + return ( )}
diff --git a/frontend/src/components/git/ForgejoConnectionsTable.tsx b/frontend/src/components/git/ForgejoConnectionsTable.tsx index bee4cf5..427332e 100644 --- a/frontend/src/components/git/ForgejoConnectionsTable.tsx +++ b/frontend/src/components/git/ForgejoConnectionsTable.tsx @@ -11,6 +11,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2 } from "lucide-react"; import { DataTable } from "@/components/tables/DataTable"; import DropdownSelect from "@/components/ui/dropdown-select"; import { Input } from "@/components/ui/input"; @@ -23,6 +24,7 @@ interface ConnectionsTableProps { isLoading: boolean; onEdit?: (connection: ForgejoConnection) => void; onDelete?: (connection: ForgejoConnection) => void; + onValidate?: (connection: ForgejoConnection) => void; } export function ForgejoConnectionsTable({ @@ -30,10 +32,13 @@ export function ForgejoConnectionsTable({ isLoading, onEdit, onDelete, + onValidate, }: ConnectionsTableProps) { + // onEdit available for future use + const _ = onEdit; const table = useReactTable({ data: connections, - columns: columns, + columns: columns(onValidate), getCoreRowModel: getCoreRowModel(), }); @@ -70,7 +75,9 @@ export function ForgejoConnectionsTable({ ); } -const columns: ColumnDef[] = [ +const columns = ( + onValidate?: (connection: ForgejoConnection) => void +): ColumnDef[] => [ { accessorKey: "name", header: ({ column }) => { @@ -125,11 +132,35 @@ const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => , + cell: ({ row }) => , }, ]; -function ActionsCell({ connection }: { connection: ForgejoConnection }) { +function ActionsCell({ + connection, + onValidate, +}: { + connection: ForgejoConnection; + onValidate?: (connection: ForgejoConnection) => void; +}) { + const [isValidateLoading, setIsValidateLoading] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [validateResult, setValidateResult] = useState<{ + ok: boolean; + error_message?: string; + response_time_ms?: number; + } | null>(null); + + const handleValidate = async () => { + if (!onValidate) return; + setIsValidateLoading(true); + try { + await onValidate(connection); + } finally { + setIsValidateLoading(false); + } + }; + const options = [ { value: "edit", label: "Edit" }, { value: "delete", label: "Delete", icon: (props: { className?: string }) => ( @@ -160,12 +191,31 @@ function ActionsCell({ connection }: { connection: ForgejoConnection }) { }; return ( - +
+ {onValidate && ( + + )} + +
); } diff --git a/frontend/src/components/git/ForgejoRepositoriesTable.tsx b/frontend/src/components/git/ForgejoRepositoriesTable.tsx index 5a6ed9b..a0a9e68 100644 --- a/frontend/src/components/git/ForgejoRepositoriesTable.tsx +++ b/frontend/src/components/git/ForgejoRepositoriesTable.tsx @@ -11,6 +11,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Loader2, CheckCircle2 } from "lucide-react"; import { DataTable } from "@/components/tables/DataTable"; import DropdownSelect from "@/components/ui/dropdown-select"; import { cn } from "@/lib/utils"; @@ -22,6 +23,8 @@ interface RepositoriesTableProps { isLoading: boolean; onEdit?: (repository: ForgejoRepository) => void; onDelete?: (repository: ForgejoRepository) => void; + onSync?: (repository: ForgejoRepository) => void; + onValidate?: (repository: ForgejoRepository) => void; } export function ForgejoRepositoriesTable({ @@ -29,10 +32,14 @@ export function ForgejoRepositoriesTable({ isLoading, onEdit, onDelete, + onSync, + onValidate, }: RepositoriesTableProps) { + // onEdit available for future use + const _ = onEdit; const table = useReactTable({ data: repositories, - columns: columns, + columns: columns(onSync, onValidate), getCoreRowModel: getCoreRowModel(), }); @@ -69,7 +76,10 @@ export function ForgejoRepositoriesTable({ ); } -const columns: ColumnDef[] = [ +const columns = ( + onSync?: (repository: ForgejoRepository) => void, + onValidate?: (repository: ForgejoRepository) => void +): ColumnDef[] => [ { accessorKey: "displayName", header: ({ column }) => { @@ -128,7 +138,7 @@ const columns: ColumnDef[] = [ cell: ({ row }) => { const lastSyncAt = row.original.last_sync_at; const lastSyncError = row.original.last_sync_error; - + if (!lastSyncAt) { return Never; } @@ -151,11 +161,56 @@ const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => , + cell: ({ row }) => , }, ]; -function ActionsCell({ repository }: { repository: ForgejoRepository }) { +function ActionsCell({ + repository, + onSync, + onValidate, +}: { + repository: ForgejoRepository; + onSync?: (repository: ForgejoRepository) => void; + onValidate?: (repository: ForgejoRepository) => void; +}) { + const [isSyncLoading, setIsSyncLoading] = useState(false); + const [isValidateLoading, setIsValidateLoading] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [syncResult, setSyncResult] = useState<{ + created: number; + updated: number; + open: number; + closed: number; + total: number; + } | null>(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [validateResult, setValidateResult] = useState<{ + ok: boolean; + repo_exists?: boolean; + error_message?: string; + } | null>(null); + + const handleSync = async () => { + if (!onSync) return; + setIsSyncLoading(true); + try { + await onSync(repository); + } finally { + setIsSyncLoading(false); + } + }; + + const handleValidate = async () => { + if (!onValidate) return; + setIsValidateLoading(true); + try { + await onValidate(repository); + } finally { + setIsValidateLoading(false); + } + }; + const options = [ { value: "edit", label: "Edit" }, { value: "delete", label: "Delete", icon: (props: { className?: string }) => ( @@ -186,12 +241,74 @@ function ActionsCell({ repository }: { repository: ForgejoRepository }) { }; return ( - +
+ {onSync && ( + + )} + {onValidate && ( + + )} + +
); } diff --git a/frontend/src/components/organisms/DashboardSidebar.tsx b/frontend/src/components/organisms/DashboardSidebar.tsx index 256ec4c..8d6180d 100644 --- a/frontend/src/components/organisms/DashboardSidebar.tsx +++ b/frontend/src/components/organisms/DashboardSidebar.tsx @@ -10,6 +10,7 @@ import { CheckCircle2, Folder, FolderGit, + CircleDot, Building2, LayoutGrid, Network, @@ -120,6 +121,13 @@ export function DashboardSidebar() { Git Projects + + + Issues + { + return fetchJson<{ created: number; updated: number; open: number; closed: number; total: number }>( + `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/sync`, + { + method: "POST", + }, + ); +} + +export async function validateConnection( + connectionId: string, +): Promise<{ + ok: boolean; + error_message?: string; + response_time_ms: number; +}> { + return fetchJson<{ + ok: boolean; + error_message?: string; + response_time_ms: number; + }>( + `${API_BASE_URL}/api/v1/forgejo/connections/${connectionId}/validate`, + { + method: "POST", + }, + ); +} + +export async function validateRepository( + repositoryId: string, +): Promise<{ + ok: boolean; + repo_exists: boolean; + error_message?: string; +}> { + return fetchJson<{ + ok: boolean; + repo_exists: boolean; + error_message?: string; + }>( + `${API_BASE_URL}/api/v1/forgejo/repositories/${repositoryId}/validate`, + { + method: "POST", + }, + ); +} + +// Forgejo Issue types +export interface ForgejoIssue { + id: string; + organization_id: string; + repository_id: string; + forgejo_issue_number: number; + title: string; + body_preview: string | null; + state: string; + is_pull_request: boolean; + labels: Record[]; + assignees: Record[]; + author: string; + html_url: string; + forgejo_created_at: string; + forgejo_updated_at: string; + forgejo_closed_at: string | null; + last_synced_at: string; + created_at: string; + updated_at: string; +} + +export interface ForgejoIssueListResponse { + items: ForgejoIssue[]; + total: number; + page: number; + limit: number; +} + +// Forgejo Issue API +export async function getForgejoIssues(params?: { + repository_id?: string; + state?: string; + search?: string; + page?: number; + limit?: number; +}): Promise { + const searchParams = new URLSearchParams(); + if (params?.repository_id) searchParams.set("repository_id", params.repository_id); + if (params?.state) searchParams.set("state", params.state); + if (params?.search) searchParams.set("search", params.search); + if (params?.page) searchParams.set("page", params.page.toString()); + if (params?.limit) searchParams.set("limit", params.limit.toString()); + + const qs = searchParams.toString(); + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/issues${qs ? `?${qs}` : ""}`, + ); +} + +export async function getForgejoIssue(issueId: string): Promise { + return fetchJson( + `${API_BASE_URL}/api/v1/forgejo/issues/${issueId}`, + ); +}