diff --git a/backend/app/api/forgejo_repositories.py b/backend/app/api/forgejo_repositories.py index 9b69404..0af64b1 100644 --- a/backend/app/api/forgejo_repositories.py +++ b/backend/app/api/forgejo_repositories.py @@ -2,18 +2,21 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status -from sqlmodel import select +from sqlmodel import col, select from app.api.deps import require_org_member +from app.core.time import utcnow from app.db import crud from app.db.session import get_session from app.models.boards import Board from app.models.board_repository_links import BoardRepositoryLink from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_import_runs import ForgejoImportRun from app.models.forgejo_repositories import ForgejoRepository from app.schemas.boards import BoardRead from app.schemas.common import OkResponse @@ -23,6 +26,7 @@ from app.schemas.forgejo_repositories import ( ForgejoRepositoryUpdate, MassImportRequest, MassImportResponse, + MassImportRunRead, MassImportRepoResult, ) from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus @@ -47,11 +51,69 @@ def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]: "name": connection.name, "base_url": connection.base_url, "has_token": connection.token is not None, - "token_last_eight": connection.token[-8:] if connection.token and len(connection.token) >= 8 else connection.token, + "token_last_eight": ( + connection.token[-8:] + if connection.token and len(connection.token) >= 8 + else connection.token + ), "active": connection.active, } +async def _repository_board_summary_map( + session: AsyncSession, + *, + organization_id: UUID, + repository_ids: list[UUID], +) -> dict[UUID, list[dict[str, object]]]: + """Return linked board summaries keyed by repository id.""" + if not repository_ids: + return {} + + rows = ( + await session.exec( + select(BoardRepositoryLink.repository_id, Board.id, Board.name) + .join(Board, BoardRepositoryLink.board_id == Board.id) + .where( + BoardRepositoryLink.organization_id == organization_id, + col(BoardRepositoryLink.repository_id).in_(repository_ids), + Board.organization_id == organization_id, + ) + .order_by(Board.name.asc()) + ) + ).all() + + result: dict[UUID, list[dict[str, object]]] = {} + for repository_id, board_id, board_name in rows: + result.setdefault(repository_id, []).append( + { + "id": board_id, + "name": board_name, + } + ) + return result + + +def _mask_import_run(run: ForgejoImportRun) -> MassImportRunRead: + """Return a typed persisted import run summary.""" + return MassImportRunRead( + id=run.id, + organization_id=run.organization_id, + requested_by_user_id=run.requested_by_user_id, + repository_ids=[UUID(str(repository_id)) for repository_id in run.repository_ids], + results=[MassImportRepoResult.model_validate(result) for result in run.results], + total_created=run.total_created, + total_updated=run.total_updated, + total_stale_closed=run.total_stale_closed, + succeeded=run.succeeded, + failed=run.failed, + started_at=run.started_at, + finished_at=run.finished_at, + duration_ms=run.duration_ms, + created_at=run.created_at, + ) + + @router.get("", response_model=list[ForgejoRepositoryRead]) async def list_repositories( session: AsyncSession = SESSION_DEP, @@ -71,9 +133,20 @@ async def list_repositories( c = await crud.get_by_id(session, ForgejoConnection, cid) if c is not None: conn_map[cid] = c + board_map = await _repository_board_summary_map( + session, + organization_id=ctx.organization.id, + repository_ids=[r.id for r in repositories], + ) result = [] for r in repositories: - result.append(_mask_repository(r, conn_map.get(r.connection_id))) + result.append( + _mask_repository( + r, + conn_map.get(r.connection_id), + linked_boards=board_map.get(r.id, []), + ) + ) return result @@ -136,12 +209,11 @@ async def mass_import_repositories( """Run a full sync across all (or selected) active repositories for the org.""" from app.services.forgejo_issue_sync import IssueSyncService - statement = ( - select(ForgejoRepository) - .where( - ForgejoRepository.organization_id == ctx.organization.id, - ForgejoRepository.active == True, # noqa: E712 - ) + started_at = utcnow() + monotonic_start = time.monotonic() + statement = select(ForgejoRepository).where( + ForgejoRepository.organization_id == ctx.organization.id, + ForgejoRepository.active == True, # noqa: E712 ) all_repos = (await session.exec(statement)).all() @@ -190,6 +262,25 @@ async def mass_import_repositories( ) failed += 1 + finished_at = utcnow() + run = await crud.create( + session, + ForgejoImportRun, + organization_id=ctx.organization.id, + requested_by_user_id=ctx.member.user_id, + repository_ids=[str(repo.id) for repo in repos_to_import], + results=[result.model_dump(mode="json") for result in results], + total_created=total_created, + total_updated=total_updated, + total_stale_closed=total_stale_closed, + succeeded=succeeded, + failed=failed, + started_at=started_at, + finished_at=finished_at, + duration_ms=int((time.monotonic() - monotonic_start) * 1000), + created_at=finished_at, + ) + return MassImportResponse( results=results, total_created=total_created, @@ -197,9 +288,28 @@ async def mass_import_repositories( total_stale_closed=total_stale_closed, succeeded=succeeded, failed=failed, + run=_mask_import_run(run), ) +@router.get("/import-runs", response_model=list[MassImportRunRead]) +async def list_mass_import_runs( + limit: int = Query(default=5, ge=1, le=25), + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[MassImportRunRead]: + """Return recent persisted mass import run summaries.""" + runs = ( + await session.exec( + select(ForgejoImportRun) + .where(ForgejoImportRun.organization_id == ctx.organization.id) + .order_by(col(ForgejoImportRun.created_at).desc()) + .limit(limit) + ) + ).all() + return [_mask_import_run(run) for run in runs] + + @router.get("/{repository_id}", response_model=ForgejoRepositoryRead) async def get_repository( repository_id: UUID, @@ -207,16 +317,25 @@ async def get_repository( ctx: OrganizationContext = ORG_MEMBER_DEP, ) -> ForgejoRepositoryRead: """Return one Forgejo repository by id for the caller's organization.""" - statement = ( - select(ForgejoRepository) - .where(ForgejoRepository.id == repository_id, ForgejoRepository.organization_id == ctx.organization.id) + statement = select(ForgejoRepository).where( + ForgejoRepository.id == repository_id, + ForgejoRepository.organization_id == ctx.organization.id, ) repository = (await session.exec(statement)).first() if repository is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) # Load connection for response conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) - return _mask_repository(repository, conn) + board_map = await _repository_board_summary_map( + session, + organization_id=ctx.organization.id, + repository_ids=[repository.id], + ) + return _mask_repository( + repository, + conn, + linked_boards=board_map.get(repository.id, []), + ) @router.patch("/{repository_id}", response_model=ForgejoRepositoryRead) @@ -228,9 +347,9 @@ async def update_repository( ) -> ForgejoRepositoryRead: """Patch a Forgejo repository for the caller's organization.""" # Get repository - statement = ( - select(ForgejoRepository) - .where(ForgejoRepository.id == repository_id, ForgejoRepository.organization_id == ctx.organization.id) + statement = select(ForgejoRepository).where( + ForgejoRepository.id == repository_id, + ForgejoRepository.organization_id == ctx.organization.id, ) repository = (await session.exec(statement)).first() if repository is None: @@ -238,7 +357,10 @@ async def update_repository( # Load connection for updates validation conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) if conn is None: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Referenced connection not found") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Referenced connection not found", + ) updates = payload.model_dump(exclude_unset=True) @@ -284,12 +406,22 @@ async def update_repository( setattr(repository, key, value) from app.core.time import utcnow + repository.updated_at = utcnow() # Reload connection to get latest state await crud.save(session, repository) # Load connection for response conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) - return _mask_repository(repository, conn) + board_map = await _repository_board_summary_map( + session, + organization_id=ctx.organization.id, + repository_ids=[repository.id], + ) + return _mask_repository( + repository, + conn, + linked_boards=board_map.get(repository.id, []), + ) @router.delete("/{repository_id}", response_model=OkResponse) @@ -327,15 +459,15 @@ async def validate_repository( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) if repository.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - + # Load connection connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) if connection is None or connection.organization_id != ctx.organization.id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - + from app.core.time import utcnow import time - + start_time = time.time() repo_exists = None try: @@ -444,7 +576,12 @@ async def sync_repository_issues_recent( raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) -def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnection | None = None) -> dict[str, object]: +def _mask_repository( + repository: ForgejoRepository, + connection: ForgejoConnection | None = None, + *, + linked_boards: list[dict[str, object]] | None = None, +) -> dict[str, object]: """Return repository dict with safe connection metadata.""" return { "id": repository.id, @@ -458,10 +595,13 @@ def _mask_repository(repository: ForgejoRepository, connection: ForgejoConnectio "connection": _create_connection_info(connection) if connection is not None else None, "has_webhook_secret": bool(repository.webhook_secret), "description": repository.description, - "open_issues_count": repository.open_issues_count if repository.open_issues_count is not None else 0, + "open_issues_count": ( + repository.open_issues_count if repository.open_issues_count is not None else 0 + ), "is_archived": bool(repository.is_archived), "topics": repository.topics if repository.topics is not None else [], "labels": repository.labels if repository.labels is not None else [], + "linked_boards": linked_boards or [], "last_sync_at": repository.last_sync_at, "last_sync_error": repository.last_sync_error, "created_at": repository.created_at, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1e0894a..327bc12 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,7 @@ from app.models.board_webhook_payloads import BoardWebhookPayload from app.models.board_webhooks import BoardWebhook from app.models.boards import Board from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_import_runs import ForgejoImportRun from app.models.forgejo_issue_task_links import ForgejoIssueTaskLink from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_repositories import ForgejoRepository @@ -50,6 +51,7 @@ __all__ = [ "Board", "BoardRepositoryLink", "ForgejoConnection", + "ForgejoImportRun", "ForgejoIssueTaskLink", "ForgejoIssue", "ForgejoRepository", diff --git a/backend/app/models/forgejo_import_runs.py b/backend/app/models/forgejo_import_runs.py new file mode 100644 index 0000000..6a2c308 --- /dev/null +++ b/backend/app/models/forgejo_import_runs.py @@ -0,0 +1,41 @@ +"""Persisted Forgejo mass import run summaries.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import Column, JSON +from sqlmodel import Field + +from app.core.time import utcnow +from app.models.base import QueryModel + +RUNTIME_ANNOTATION_TYPES = (datetime,) + + +class ForgejoImportRun(QueryModel, table=True): + """Summary of one full or subset Forgejo issue import run.""" + + __tablename__ = "forgejo_import_runs" # pyright: ignore[reportAssignmentType] + + id: UUID = Field(default_factory=uuid4, primary_key=True) + organization_id: UUID = Field(foreign_key="organizations.id", index=True) + requested_by_user_id: UUID | None = Field(default=None, foreign_key="users.id") + repository_ids: list[str] = Field( + default_factory=list, + sa_column=Column(JSON, nullable=False, server_default="[]"), + ) + results: list[dict[str, object]] = Field( + default_factory=list, + sa_column=Column(JSON, nullable=False, server_default="[]"), + ) + total_created: int = Field(default=0) + total_updated: int = Field(default=0) + total_stale_closed: int = Field(default=0) + succeeded: int = Field(default=0) + failed: int = Field(default=0) + started_at: datetime = Field(default_factory=utcnow) + finished_at: datetime | None = Field(default=None) + duration_ms: int | None = Field(default=None) + created_at: datetime = Field(default_factory=utcnow) diff --git a/backend/app/schemas/forgejo_repositories.py b/backend/app/schemas/forgejo_repositories.py index bfe0419..4df0317 100644 --- a/backend/app/schemas/forgejo_repositories.py +++ b/backend/app/schemas/forgejo_repositories.py @@ -100,6 +100,13 @@ class ForgejoRepositoryConnectionInfo(SQLModel): active: bool +class ForgejoRepositoryBoardSummary(SQLModel): + """Small board summary embedded in repository settings responses.""" + + id: UUID + name: str + + class MassImportRequest(SQLModel): """Optional body for the mass import endpoint.""" @@ -120,6 +127,25 @@ class MassImportRepoResult(SQLModel): error: str | None = None +class MassImportRunRead(SQLModel): + """Persisted import run summary.""" + + id: UUID + organization_id: UUID + requested_by_user_id: UUID | None = None + repository_ids: list[UUID] = Field(default_factory=list) + results: list[MassImportRepoResult] = Field(default_factory=list) + total_created: int = 0 + total_updated: int = 0 + total_stale_closed: int = 0 + succeeded: int = 0 + failed: int = 0 + started_at: datetime + finished_at: datetime | None = None + duration_ms: int | None = None + created_at: datetime + + class MassImportResponse(SQLModel): """Aggregate result from a mass import across all requested repositories.""" @@ -129,6 +155,7 @@ class MassImportResponse(SQLModel): total_stale_closed: int = 0 succeeded: int = 0 failed: int = 0 + run: MassImportRunRead | None = None class ForgejoRepositoryRead(ForgejoRepositoryBase): @@ -144,6 +171,7 @@ class ForgejoRepositoryRead(ForgejoRepositoryBase): is_archived: bool = False topics: list[str] = Field(default_factory=list) labels: list[dict[str, object]] = Field(default_factory=list) + linked_boards: list[ForgejoRepositoryBoardSummary] = Field(default_factory=list) last_sync_at: datetime | None last_sync_error: str | None created_at: datetime diff --git a/backend/migrations/versions/c6d7e8f9a0b1_add_forgejo_import_runs.py b/backend/migrations/versions/c6d7e8f9a0b1_add_forgejo_import_runs.py new file mode 100644 index 0000000..dcba72e --- /dev/null +++ b/backend/migrations/versions/c6d7e8f9a0b1_add_forgejo_import_runs.py @@ -0,0 +1,63 @@ +"""add forgejo import runs + +Revision ID: c6d7e8f9a0b1 +Revises: b1c2d3e4f5a6 +Create Date: 2026-05-25 00:00:00.000000 + +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "c6d7e8f9a0b1" +down_revision = "b1c2d3e4f5a6" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if inspector.has_table("forgejo_import_runs"): + return + + op.create_table( + "forgejo_import_runs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("requested_by_user_id", sa.Uuid(), nullable=True), + sa.Column("repository_ids", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("results", sa.JSON(), nullable=False, server_default="[]"), + sa.Column("total_created", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_updated", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_stale_closed", sa.Integer(), nullable=False, server_default="0"), + sa.Column("succeeded", sa.Integer(), nullable=False, server_default="0"), + sa.Column("failed", sa.Integer(), nullable=False, server_default="0"), + sa.Column("started_at", sa.DateTime(), nullable=False), + sa.Column("finished_at", sa.DateTime(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"]), + sa.ForeignKeyConstraint(["requested_by_user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_forgejo_import_runs_org_id", + "forgejo_import_runs", + ["organization_id"], + ) + op.create_index( + "ix_forgejo_import_runs_requested_by_user_id", + "forgejo_import_runs", + ["requested_by_user_id"], + ) + + +def downgrade() -> None: + op.drop_index("ix_forgejo_import_runs_requested_by_user_id", table_name="forgejo_import_runs") + op.drop_index("ix_forgejo_import_runs_org_id", table_name="forgejo_import_runs") + op.drop_table("forgejo_import_runs") diff --git a/backend/tests/test_forgejo_repositories_api.py b/backend/tests/test_forgejo_repositories_api.py new file mode 100644 index 0000000..1696fee --- /dev/null +++ b/backend/tests/test_forgejo_repositories_api.py @@ -0,0 +1,186 @@ +# ruff: noqa: INP001 +"""Integration tests for Forgejo repository settings API responses.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import uuid4 + +import pytest +from fastapi import APIRouter, FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from app import models as _models +from app.api.deps import require_org_member +from app.api.forgejo_repositories import router as forgejo_repositories_router +from app.db.session import get_session +from app.models.board_repository_links import BoardRepositoryLink +from app.models.boards import Board +from app.models.forgejo_connections import ForgejoConnection +from app.models.forgejo_import_runs import ForgejoImportRun +from app.models.forgejo_repositories import ForgejoRepository +from app.models.organization_members import OrganizationMember +from app.models.organizations import Organization +from app.services.organizations import OrganizationContext + + +async def _make_engine() -> AsyncEngine: + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + return engine + + +def _build_test_app( + session_maker: async_sessionmaker[AsyncSession], + ctx: OrganizationContext, +) -> FastAPI: + app = FastAPI() + api_v1 = APIRouter(prefix="/api/v1") + api_v1.include_router(forgejo_repositories_router) + app.include_router(api_v1) + + async def _override_get_session() -> AsyncSession: + async with session_maker() as session: + yield session + + async def _override_require_org_member() -> OrganizationContext: + return ctx + + app.dependency_overrides[get_session] = _override_get_session + app.dependency_overrides[require_org_member] = _override_require_org_member + return app + + +@pytest.mark.asyncio +async def test_list_repositories_embeds_linked_board_summaries() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + + connection = ForgejoConnection( + id=uuid4(), + organization_id=organization.id, + name="Dream Forgejo", + base_url="https://forgejo.example.local", + ) + repository = ForgejoRepository( + id=uuid4(), + organization_id=organization.id, + connection_id=connection.id, + owner="openclaw", + repo="pipeline", + display_name="Pipeline", + ) + board = Board( + id=uuid4(), + organization_id=organization.id, + name="Pipeline Board", + slug="pipeline-board", + ) + link = BoardRepositoryLink( + id=uuid4(), + organization_id=organization.id, + board_id=board.id, + repository_id=repository.id, + ) + + try: + async with session_maker() as session: + session.add(organization) + session.add(connection) + session.add(repository) + session.add(board) + session.add(link) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get("/api/v1/forgejo/repositories") + + assert response.status_code == 200 + data = response.json() + assert data[0]["linked_boards"] == [{"id": str(board.id), "name": "Pipeline Board"}] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +async def test_import_runs_route_returns_persisted_history() -> None: + engine = await _make_engine() + session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + organization = Organization(id=uuid4(), name="Pipeline") + member = OrganizationMember( + id=uuid4(), + organization_id=organization.id, + user_id=uuid4(), + role="owner", + ) + app = _build_test_app( + session_maker, + OrganizationContext(organization=organization, member=member), + ) + repository_id = uuid4() + run = ForgejoImportRun( + id=uuid4(), + organization_id=organization.id, + requested_by_user_id=member.user_id, + repository_ids=[str(repository_id)], + results=[ + { + "repository_id": str(repository_id), + "name": "openclaw/pipeline", + "created": 2, + "updated": 3, + "stale_closed": 1, + "open": 5, + "closed": 1, + "total": 6, + "error": None, + } + ], + total_created=2, + total_updated=3, + total_stale_closed=1, + succeeded=1, + failed=0, + started_at=datetime(2026, 5, 25, 12, 0, 0), + finished_at=datetime(2026, 5, 25, 12, 0, 3), + duration_ms=3000, + created_at=datetime(2026, 5, 25, 12, 0, 3), + ) + + try: + async with session_maker() as session: + session.add(organization) + session.add(run) + await session.commit() + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://testserver", + ) as client: + response = await client.get("/api/v1/forgejo/repositories/import-runs") + + assert response.status_code == 200 + data = response.json() + assert data[0]["id"] == str(run.id) + assert data[0]["results"][0]["created"] == 2 + assert data[0]["repository_ids"] == [str(repository_id)] + finally: + await engine.dispose() diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx index 780081f..b1d21c8 100644 --- a/frontend/src/app/settings/git-projects/page.tsx +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -5,6 +5,7 @@ export const dynamic = "force-dynamic"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { AlertCircle, CheckCircle2, @@ -19,7 +20,7 @@ import { ListChecks, Loader2, RefreshCw, - Server, + Tags, Webhook, } from "lucide-react"; @@ -40,8 +41,8 @@ import { deleteForgejoConnection, deleteForgejoRepository, getForgejoConnections, - getLinkedBoardsForRepository, getForgejoRepositories, + getMassImportRuns, massImportRepositories, syncRepository, validateConnection, @@ -49,6 +50,7 @@ import { type ForgejoConnection, type ForgejoRepository, type MassImportResponse, + type MassImportRunRead, } from "@/lib/api-forgejo"; type Notice = { @@ -60,26 +62,15 @@ type DeleteTarget = | { type: "connection"; item: ForgejoConnection } | { type: "repository"; item: ForgejoRepository }; -type BoardLink = { - id: string; - name: string; -}; - -type LastMassImport = { - finishedAt: string; - result: MassImportResponse; -}; - type AttentionItem = { id: string; title: string; detail: string; tone: "warning" | "danger" | "muted"; + kind: "connection" | "sync" | "webhook" | "archived" | "inactive"; href?: string; }; -const LAST_MASS_IMPORT_STORAGE_KEY = "pipeline.gitProjects.lastMassImport"; - const repositoryName = (repository: ForgejoRepository) => repository.display_name || `${repository.owner}/${repository.repo}`; @@ -93,20 +84,11 @@ const formatTimestamp = (value: string | null) => { const formatCompactNumber = (value: number) => new Intl.NumberFormat(undefined, { notation: "compact" }).format(value); -const summarizeMassImport = (result: MassImportResponse) => +const summarizeMassImport = (result: MassImportResponse | MassImportRunRead) => `${result.total_created} created, ${result.total_updated} updated, ${result.total_stale_closed} closed`; -const isLastMassImport = (value: unknown): value is LastMassImport => { - if (!value || typeof value !== "object") return false; - const candidate = value as Partial; - return ( - typeof candidate.finishedAt === "string" && - typeof candidate.result?.total_created === "number" && - typeof candidate.result?.total_updated === "number" && - typeof candidate.result?.total_stale_closed === "number" && - Array.isArray(candidate.result?.results) - ); -}; +const labelColor = (color: string) => + color.startsWith("#") ? color : `#${color}`; function NoticeBanner({ notice }: { notice: Notice }) { return ( @@ -185,13 +167,156 @@ function StatCard({ ); } -function AttentionPanel({ items }: { items: AttentionItem[] }) { +function HealthSummaryPanel({ + status, + summary, + primaryActionLabel, + primaryActionIcon, + onPrimaryAction, + isPrimaryActionLoading, + metrics, +}: { + status: "healthy" | "warning" | "danger"; + summary: string; + primaryActionLabel: string; + primaryActionIcon: ReactNode; + onPrimaryAction: () => void; + isPrimaryActionLoading?: boolean; + metrics: { label: string; value: string; caption: string }[]; +}) { + const statusStyles = + status === "healthy" + ? "border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]" + : status === "warning" + ? "border-[color:rgba(245,158,11,0.35)] bg-[color:rgba(245,158,11,0.08)] text-[color:var(--warning)]" + : "border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]"; + + return ( +
+
+
+
+ {status === "healthy" ? ( + + ) : ( + + )} + {status === "healthy" + ? "Healthy" + : status === "warning" + ? "Review" + : "Action needed"} +
+

+ Git Projects Health +

+

{summary}

+
+ +
+
+ {metrics.map((metric) => ( +
+

+ {metric.label} +

+

+ {metric.value} +

+

{metric.caption}

+
+ ))} +
+
+ ); +} + +function AttentionPanel({ + items, + onSyncTroubled, + onOpenWebhookSetup, + isSyncing, +}: { + items: AttentionItem[]; + onSyncTroubled: () => void; + onOpenWebhookSetup: () => void; + isSyncing: boolean; +}) { + const groupedItems = [ + { + key: "danger", + label: "Critical", + items: items.filter((item) => item.tone === "danger"), + }, + { + key: "warning", + label: "Warnings", + items: items.filter((item) => item.tone === "warning"), + }, + { + key: "muted", + label: "Context", + items: items.filter((item) => item.tone === "muted"), + }, + ].filter((group) => group.items.length > 0); + + const hasSyncItems = items.some((item) => item.kind === "sync"); + const hasWebhookItems = items.some((item) => item.kind === "webhook"); + return (
} title="Needs Attention" description="Connection and repository signals that can block fresh issue data." + action={ +
+ {hasSyncItems ? ( + + ) : null} + {hasWebhookItems ? ( + + ) : null} +
+ } /> {items.length === 0 ? (
@@ -205,38 +330,54 @@ function AttentionPanel({ items }: { items: AttentionItem[] }) {
) : ( -
- {items.map((item) => ( -
-
-

{item.title}

-

{item.detail}

+
+ {groupedItems.map((group) => { + const visibleItems = group.items.slice(0, 4); + const hiddenCount = Math.max( + group.items.length - visibleItems.length, + 0, + ); + return ( +
+
+

+ {group.label} +

+ + {group.items.length} + +
+
+ {visibleItems.map((item) => ( +
+
+

{item.title}

+

{item.detail}

+
+ {item.href ? ( + + + + ) : null} +
+ ))} + {hiddenCount > 0 ? ( +
+ {hiddenCount} more hidden in this group. +
+ ) : null} +
-
- - {item.tone} - - {item.href ? ( - - - - ) : null} -
-
- ))} + ); + })}
)}
@@ -244,12 +385,13 @@ function AttentionPanel({ items }: { items: AttentionItem[] }) { } function LastImportPanel({ - lastImport, + importRuns, onOpen, }: { - lastImport: LastMassImport | null; + importRuns: MassImportRunRead[]; onOpen: () => void; }) { + const lastImport = importRuns[0] ?? null; return (

- {summarizeMassImport(lastImport.result)} + {summarizeMassImport(lastImport)}

- Finished {formatTimestamp(lastImport.finishedAt)} across{" "} - {lastImport.result.succeeded} repositor - {lastImport.result.succeeded === 1 ? "y" : "ies"}. - {lastImport.result.failed > 0 ? ( + Finished {formatTimestamp(lastImport.finished_at)} across{" "} + {lastImport.succeeded} repositor + {lastImport.succeeded === 1 ? "y" : "ies"}. + {lastImport.failed > 0 ? ( - {lastImport.result.failed} failed. + {lastImport.failed} failed. ) : null}

@@ -284,7 +426,7 @@ function LastImportPanel({

- {lastImport.result.total_created} + {lastImport.total_created}

Created @@ -292,7 +434,7 @@ function LastImportPanel({

- {lastImport.result.total_updated} + {lastImport.total_updated}

Updated @@ -300,7 +442,7 @@ function LastImportPanel({

- {lastImport.result.total_stale_closed} + {lastImport.total_stale_closed}

Closed @@ -310,15 +452,182 @@ function LastImportPanel({

) : (

- No full import has run in this session. Scheduled sync still keeps - active repositories current. + No full import history yet. Scheduled sync still keeps active + repositories current.

)} + {importRuns.length > 1 ? ( +
+ {importRuns.slice(1, 4).map((run) => ( +
+ + {formatTimestamp(run.finished_at)} + + + {summarizeMassImport(run)} + +
+ ))} +
+ ) : null}
); } +function RepositoryDetailsDialog({ + repository, + webhookBaseUrl, + onOpenChange, +}: { + repository: ForgejoRepository | null; + webhookBaseUrl: string; + onOpenChange: (open: boolean) => void; +}) { + const webhookUrl = repository + ? webhookBaseUrl + ? `${webhookBaseUrl}/api/v1/forgejo/webhooks/${repository.id}` + : `.../api/v1/forgejo/webhooks/${repository.id}` + : ""; + + return ( + + + + + {repository ? repositoryName(repository) : "Repository details"} + + + {repository ? ( +
+
+ } + label="Open" + value={String(repository.open_issues_count)} + caption="Open issues upstream." + /> + } + label="Branch" + value={repository.default_branch || "Unknown"} + caption="Default branch." + /> + } + label="Webhook" + value={repository.has_webhook_secret ? "Ready" : "Missing"} + caption="Stored webhook secret." + /> + } + label="Synced" + value={formatTimestamp(repository.last_sync_at)} + caption="Last repository sync." + /> +
+ + {repository.description ? ( +

+ {repository.description} +

+ ) : null} + + {repository.last_sync_error ? ( +
+ + {repository.last_sync_error} +
+ ) : null} + +
+
+

+ Linked Boards +

+
+ {repository.linked_boards.length ? ( + repository.linked_boards.map((board) => ( + + {board.name} + + )) + ) : ( + + No linked boards. + + )} +
+
+
+

Topics

+
+ {repository.topics.length ? ( + repository.topics.map((topic) => ( + + + {topic} + + )) + ) : ( + No topics. + )} +
+
+
+ +
+

Labels

+
+ {repository.labels.length ? ( + repository.labels.map((label) => ( + + + {label.name} + + )) + ) : ( + No labels synced. + )} +
+
+ +
+
+
+

+ Webhook URL +

+ + {webhookUrl} + +
+ +
+
+
+ ) : null} +
+
+ ); +} + function CopyButton({ value, label }: { value: string; label?: string }) { const [copied, setCopied] = useState(false); @@ -353,6 +662,7 @@ function CopyButton({ value, label }: { value: string; label?: string }) { export default function GitProjectSettingsPage() { const auth = useAuth(); + const router = useRouter(); const [connections, setConnections] = useState([]); const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -361,12 +671,9 @@ export default function GitProjectSettingsPage() { const [massImportOpen, setMassImportOpen] = useState(false); const [massImportResult, setMassImportResult] = useState(null); - const [lastMassImport, setLastMassImport] = useState( - null, - ); - const [linkedBoardsByRepository, setLinkedBoardsByRepository] = useState< - Record - >({}); + const [massImportRuns, setMassImportRuns] = useState([]); + const [selectedRepository, setSelectedRepository] = + useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -380,60 +687,18 @@ export default function GitProjectSettingsPage() { return () => clearTimeout(id); }, [notice]); - useEffect(() => { - try { - const stored = window.localStorage.getItem(LAST_MASS_IMPORT_STORAGE_KEY); - if (!stored) return; - const parsed: unknown = JSON.parse(stored); - if (isLastMassImport(parsed)) { - setLastMassImport(parsed); - } - } catch { - // Local storage is optional for this summary. - } - }, []); - - useEffect(() => { - try { - if (lastMassImport) { - window.localStorage.setItem( - LAST_MASS_IMPORT_STORAGE_KEY, - JSON.stringify(lastMassImport), - ); - } - } catch { - // Local storage is optional for this summary. - } - }, [lastMassImport]); - const loadSettings = useCallback(async () => { try { setIsLoading(true); - const [connectionsData, repositoriesData] = await Promise.all([ - getForgejoConnections(), - getForgejoRepositories(), - ]); + const [connectionsData, repositoriesData, importRunsData] = + await Promise.all([ + getForgejoConnections(), + getForgejoRepositories(), + getMassImportRuns(5), + ]); setConnections(connectionsData); setRepositories(repositoriesData); - const boardResults = await Promise.allSettled( - repositoriesData.map(async (repo) => { - const boards = await getLinkedBoardsForRepository(repo.id); - return [repo.id, boards] as const; - }), - ); - setLinkedBoardsByRepository( - Object.fromEntries( - boardResults - .filter( - ( - result, - ): result is PromiseFulfilledResult< - readonly [string, BoardLink[]] - > => result.status === "fulfilled", - ) - .map((result) => result.value), - ), - ); + setMassImportRuns(importRunsData); setError(null); } catch (err) { setError( @@ -452,10 +717,6 @@ export default function GitProjectSettingsPage() { } }, [auth.isSignedIn, loadSettings]); - const activeConnections = useMemo( - () => connections.filter((c) => c.active).length, - [connections], - ); const activeRepositories = useMemo( () => repositories.filter((r) => r.active).length, [repositories], @@ -481,8 +742,7 @@ export default function GitProjectSettingsPage() { [repositories], ); const activeRepositoriesWithWebhooks = useMemo( - () => - repositories.filter((r) => r.active && r.has_webhook_secret).length, + () => repositories.filter((r) => r.active && r.has_webhook_secret).length, [repositories], ); const archivedRepositories = useMemo( @@ -496,8 +756,10 @@ export default function GitProjectSettingsPage() { items.push({ id: `connection-inactive-${connection.id}`, title: `${connection.name} is inactive`, - detail: "Repositories using this connection will not receive fresh issue data.", + detail: + "Repositories using this connection will not receive fresh issue data.", tone: "muted", + kind: "connection", href: `/git-projects/connections/${connection.id}/edit`, }); } @@ -505,8 +767,10 @@ export default function GitProjectSettingsPage() { items.push({ id: `connection-token-${connection.id}`, title: `${connection.name} is missing a token`, - detail: "Validation, sync, and imports need a configured provider token.", + detail: + "Validation, sync, and imports need a configured provider token.", tone: "danger", + kind: "connection", href: `/git-projects/connections/${connection.id}/edit`, }); } @@ -522,6 +786,17 @@ export default function GitProjectSettingsPage() { title: `${name} has a sync error`, detail: repo.last_sync_error, tone: "danger", + kind: "sync", + href: `/git-projects/repositories/${repo.id}/edit`, + }); + } + if (repo.active && !repo.last_sync_at && !repo.last_sync_error) { + items.push({ + id: `repo-never-sync-${repo.id}`, + title: `${name} has never synced`, + detail: "Run a sync to populate issues and verify provider access.", + tone: "warning", + kind: "sync", href: `/git-projects/repositories/${repo.id}/edit`, }); } @@ -529,8 +804,10 @@ export default function GitProjectSettingsPage() { items.push({ id: `repo-webhook-${repo.id}`, title: `${name} has no webhook secret`, - detail: "Webhook setup can receive events, but signature validation needs a stored secret.", + detail: + "Webhook setup can receive events, but signature validation needs a stored secret.", tone: "warning", + kind: "webhook", href: `/git-projects/repositories/${repo.id}/edit`, }); } @@ -538,8 +815,10 @@ export default function GitProjectSettingsPage() { items.push({ id: `repo-archived-${repo.id}`, title: `${name} is archived upstream`, - detail: "Archived repositories can stay visible, but they may not need active syncing.", + detail: + "Archived repositories can stay visible, but they may not need active syncing.", tone: "muted", + kind: "archived", href: `/git-projects/repositories/${repo.id}/edit`, }); } @@ -547,8 +826,10 @@ export default function GitProjectSettingsPage() { items.push({ id: `repo-inactive-${repo.id}`, title: `${name} is inactive`, - detail: "Inactive repositories are excluded from Sync All and full imports.", + detail: + "Inactive repositories are excluded from Sync All and full imports.", tone: "muted", + kind: "inactive", href: `/git-projects/repositories/${repo.id}/edit`, }); } @@ -558,6 +839,78 @@ export default function GitProjectSettingsPage() { return [...connectionItems, ...repositoryItems]; }, [connections, repositories]); + const troubledRepositories = useMemo( + () => + repositories.filter( + (repo) => repo.active && (repo.last_sync_error || !repo.last_sync_at), + ), + [repositories], + ); + const missingWebhookRepositories = useMemo( + () => + repositories.filter((repo) => repo.active && !repo.has_webhook_secret), + [repositories], + ); + const healthStatus = useMemo<"healthy" | "warning" | "danger">(() => { + if (attentionItems.some((item) => item.tone === "danger")) return "danger"; + if (attentionItems.some((item) => item.tone === "warning")) + return "warning"; + return "healthy"; + }, [attentionItems]); + const hasCriticalConnectionIssue = useMemo( + () => + attentionItems.some( + (item) => item.kind === "connection" && item.tone === "danger", + ), + [attentionItems], + ); + const healthSummary = useMemo(() => { + if (healthStatus === "danger") { + return `${syncErrorCount} sync error${syncErrorCount === 1 ? "" : "s"} and ${attentionItems.filter((item) => item.tone === "danger").length} critical signal${attentionItems.filter((item) => item.tone === "danger").length === 1 ? "" : "s"} need review before Git Projects can stay current.`; + } + if (healthStatus === "warning") { + return `${missingWebhookRepositories.length} active repositor${missingWebhookRepositories.length === 1 ? "y" : "ies"} need webhook secrets or first sync coverage.`; + } + return "Connections, active repositories, sync state, and webhook coverage are ready."; + }, [ + attentionItems, + healthStatus, + missingWebhookRepositories.length, + syncErrorCount, + ]); + const healthMetrics = useMemo( + () => [ + { + label: "Repositories", + value: `${activeRepositories}/${repositories.length}`, + caption: "Active tracked repositories", + }, + { + label: "Open Issues", + value: formatCompactNumber(totalOpenIssues), + caption: "Reported upstream", + }, + { + label: "Webhooks", + value: `${activeRepositoriesWithWebhooks}/${activeRepositories}`, + caption: "Active repos with secrets", + }, + { + label: "Latest Sync", + value: formatTimestamp(latestSync?.toISOString() ?? null), + caption: `${archivedRepositories} archived tracked`, + }, + ], + [ + activeRepositories, + activeRepositoriesWithWebhooks, + archivedRepositories, + latestSync, + repositories.length, + totalOpenIssues, + ], + ); + const webhookBaseUrl = useMemo(() => { try { return getApiBaseUrl(); @@ -610,9 +963,7 @@ export default function GitProjectSettingsPage() { setNotice({ tone: "error", message: - err instanceof Error - ? err.message - : "Failed to validate repository.", + err instanceof Error ? err.message : "Failed to validate repository.", }); throw err; } @@ -644,16 +995,11 @@ export default function GitProjectSettingsPage() { try { const result = await massImportRepositories(); setMassImportResult(result); - setLastMassImport({ - finishedAt: new Date().toISOString(), - result, - }); await loadSettings(); } catch (err) { setNotice({ tone: "error", - message: - err instanceof Error ? err.message : "Mass import failed.", + message: err instanceof Error ? err.message : "Mass import failed.", }); setMassImportOpen(false); } finally { @@ -661,6 +1007,40 @@ export default function GitProjectSettingsPage() { } }; + const handleSyncTroubled = async () => { + const targets = troubledRepositories.length + ? troubledRepositories + : repositories.filter((repo) => repo.active); + if (targets.length === 0) return; + setIsSyncingAll(true); + let succeeded = 0; + let failed = 0; + await Promise.allSettled( + targets.map(async (repo) => { + try { + await syncRepository(repo.id); + succeeded++; + } catch { + failed++; + } + }), + ); + const data = await getForgejoRepositories(); + setRepositories(data); + setIsSyncingAll(false); + setNotice( + failed === 0 + ? { + tone: "success", + message: `${succeeded} troubled repositor${succeeded === 1 ? "y" : "ies"} synced successfully.`, + } + : { + tone: "error", + message: `Troubled sync completed: ${succeeded} succeeded, ${failed} failed.`, + }, + ); + }; + const handleSyncAll = async () => { const active = repositories.filter((r) => r.active); if (active.length === 0) return; @@ -693,6 +1073,24 @@ export default function GitProjectSettingsPage() { ); }; + const handlePrimaryHealthAction = () => { + if (hasCriticalConnectionIssue) { + router.push("/git-projects/connections"); + return; + } + if (healthStatus === "danger" || troubledRepositories.length > 0) { + void handleSyncTroubled(); + return; + } + if (missingWebhookRepositories.length > 0) { + document + .getElementById("git-project-webhooks") + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + void handleSyncAll(); + }; + const confirmDelete = async () => { if (!deleteTarget) return; setIsDeleting(true); @@ -771,7 +1169,9 @@ export default function GitProjectSettingsPage() { onClick={() => { setMassImportOpen(true); }} - disabled={isMassImporting || isLoading || activeRepositories === 0} + disabled={ + isMassImporting || isLoading || activeRepositories === 0 + } title="Full issue import for all repositories" > @@ -801,50 +1201,47 @@ export default function GitProjectSettingsPage() { ) : null} - {/* Stats */} -
- } - label="Connections" - value={`${activeConnections}/${connections.length}`} - caption="Active Forgejo connection records." - /> - } - label="Repositories" - value={`${activeRepositories}/${repositories.length}`} - caption="Active tracked repositories." - /> - } - label="Open Issues" - value={formatCompactNumber(totalOpenIssues)} - caption="Open issues reported by tracked repositories." - /> - } - label="Webhooks" - value={`${activeRepositoriesWithWebhooks}/${activeRepositories}`} - caption="Active repositories with webhook secrets." - /> - } - label="Latest Sync" - value={formatTimestamp(latestSync?.toISOString() ?? null)} - caption="Most recent repository sync timestamp." - /> - } - label="Sync Errors" - value={String(syncErrorCount)} - caption={`Repositories with sync errors; ${archivedRepositories} archived tracked.`} - /> -
+ 0 + ? "Sync troubled" + : missingWebhookRepositories.length > 0 + ? "Review webhooks" + : "Sync all" + } + primaryActionIcon={ + hasCriticalConnectionIssue ? ( + + ) : missingWebhookRepositories.length > 0 && + healthStatus !== "danger" && + troubledRepositories.length === 0 ? ( + + ) : ( + + ) + } + onPrimaryAction={handlePrimaryHealthAction} + isPrimaryActionLoading={isSyncingAll} + metrics={healthMetrics} + />
- + + document + .getElementById("git-project-webhooks") + ?.scrollIntoView({ behavior: "smooth", block: "start" }) + } + isSyncing={isSyncingAll} + /> setMassImportOpen(true)} />
@@ -903,7 +1300,7 @@ export default function GitProjectSettingsPage() { setDeleteTarget({ type: "repository", item: repository }) } @@ -914,7 +1311,10 @@ export default function GitProjectSettingsPage() { {/* Webhook Setup */} -
+
} title="Webhook Setup" @@ -1182,6 +1582,14 @@ export default function GitProjectSettingsPage() { + { + if (!open) setSelectedRepository(null); + }} + /> + { diff --git a/frontend/src/components/git/ForgejoRepositoriesTable.tsx b/frontend/src/components/git/ForgejoRepositoriesTable.tsx index 541cbbe..c0c9cdf 100644 --- a/frontend/src/components/git/ForgejoRepositoriesTable.tsx +++ b/frontend/src/components/git/ForgejoRepositoriesTable.tsx @@ -14,11 +14,11 @@ import { Button } from "@/components/ui/button"; import { Archive, CheckCircle2, + Eye, GitBranch, KeyRound, Loader2, RefreshCw, - Tags, } from "lucide-react"; import { DataTable } from "@/components/tables/DataTable"; import { cn } from "@/lib/utils"; @@ -36,20 +36,12 @@ type RepositorySyncResult = { total: number; }; -type BoardLink = { - id: string; - name: string; -}; - -const labelColor = (color: string) => - color.startsWith("#") ? color : `#${color}`; - interface RepositoriesTableProps { repositories: ForgejoRepository[]; isLoading: boolean; - linkedBoardsByRepository?: Record; onEdit?: (repository: ForgejoRepository) => void; onDelete?: (repository: ForgejoRepository) => void; + onViewDetails?: (repository: ForgejoRepository) => void; onSync?: (repository: ForgejoRepository) => Promise; onValidate?: ( repository: ForgejoRepository, @@ -59,9 +51,9 @@ interface RepositoriesTableProps { export function ForgejoRepositoriesTable({ repositories, isLoading, - linkedBoardsByRepository = {}, onEdit, onDelete, + onViewDetails, onSync, onValidate, }: RepositoriesTableProps) { @@ -69,7 +61,7 @@ export function ForgejoRepositoriesTable({ const _ = onEdit; const table = useReactTable({ data: repositories, - columns: columns(onSync, onValidate, linkedBoardsByRepository), + columns: columns(onSync, onValidate, onViewDetails), getCoreRowModel: getCoreRowModel(), }); @@ -89,7 +81,7 @@ export function ForgejoRepositoriesTable({ getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, onDelete: onDelete ?? undefined, }} - tableClassName="min-w-[1180px] w-full text-left text-sm" + tableClassName="min-w-[900px] w-full text-left text-sm" /> ); } @@ -99,7 +91,7 @@ const columns = ( onValidate?: ( repository: ForgejoRepository, ) => Promise, - linkedBoardsByRepository: Record = {}, + onViewDetails?: (repository: ForgejoRepository) => void, ): ColumnDef[] => [ { accessorKey: "displayName", @@ -196,89 +188,6 @@ const columns = ( ), }, - { - accessorKey: "metadata", - header: "Metadata", - cell: ({ row }) => { - const repo = row.original; - const visibleTopics = repo.topics.slice(0, 2); - const hiddenTopics = Math.max( - repo.topics.length - visibleTopics.length, - 0, - ); - const visibleLabels = repo.labels.slice(0, 2); - const hiddenLabels = Math.max( - repo.labels.length - visibleLabels.length, - 0, - ); - - return ( -
- - default: {repo.default_branch || "unknown"} - -
- {visibleTopics.map((topic) => ( - - - {topic} - - ))} - {hiddenTopics > 0 ? ( - +{hiddenTopics} topics - ) : null} - {visibleTopics.length === 0 ? ( - No topics - ) : null} -
-
- {visibleLabels.map((label) => ( - - - {label.name} - - ))} - {hiddenLabels > 0 ? ( - +{hiddenLabels} labels - ) : null} -
-
- ); - }, - }, - { - accessorKey: "boards", - header: "Boards", - cell: ({ row }) => { - const boards = linkedBoardsByRepository[row.original.id] ?? []; - const visibleBoards = boards.slice(0, 3); - const hiddenBoards = Math.max(boards.length - visibleBoards.length, 0); - - if (boards.length === 0) { - return No linked boards; - } - - return ( -
- {visibleBoards.map((board) => ( - - {board.name} - - ))} - {hiddenBoards > 0 ? ( - +{hiddenBoards} - ) : null} -
- ); - }, - }, { accessorKey: "lastSync", header: "Last Sync", @@ -316,6 +225,7 @@ const columns = ( repository={row.original} onSync={onSync} onValidate={onValidate} + onViewDetails={onViewDetails} /> ), }, @@ -325,12 +235,14 @@ function ActionsCell({ repository, onSync, onValidate, + onViewDetails, }: { repository: ForgejoRepository; onSync?: (repository: ForgejoRepository) => Promise; onValidate?: ( repository: ForgejoRepository, ) => Promise; + onViewDetails?: (repository: ForgejoRepository) => void; }) { const [isSyncLoading, setIsSyncLoading] = useState(false); const [isValidateLoading, setIsValidateLoading] = useState(false); @@ -382,6 +294,7 @@ function ActionsCell({ disabled={isSyncLoading} className="h-8 w-8 p-0" title="Sync issues" + aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`} > {isSyncLoading ? ( @@ -399,6 +312,7 @@ function ActionsCell({ disabled={isValidateLoading} className="h-8 w-8 p-0" title="Validate repository" + aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`} > {isValidateLoading ? ( @@ -409,6 +323,17 @@ function ActionsCell({ )} )} + {onViewDetails && ( + + )} ); } diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 97ddb12..d676b2f 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -34,6 +34,11 @@ export interface ForgejoRepoLabel { description: string; } +export interface ForgejoRepositoryBoardSummary { + id: string; + name: string; +} + export interface ForgejoRepository { id: string; organization_id: string; @@ -49,6 +54,7 @@ export interface ForgejoRepository { is_archived: boolean; topics: string[]; labels: ForgejoRepoLabel[]; + linked_boards: ForgejoRepositoryBoardSummary[]; connection: ForgejoConnection; last_sync_at: string | null; last_sync_error: string | null; @@ -669,6 +675,24 @@ export interface MassImportResponse { total_stale_closed: number; succeeded: number; failed: number; + run: MassImportRunRead | null; +} + +export interface MassImportRunRead { + id: string; + organization_id: string; + requested_by_user_id: string | null; + repository_ids: string[]; + results: MassImportRepoResult[]; + total_created: number; + total_updated: number; + total_stale_closed: number; + succeeded: number; + failed: number; + started_at: string; + finished_at: string | null; + duration_ms: number | null; + created_at: string; } export async function massImportRepositories( @@ -683,6 +707,14 @@ export async function massImportRepositories( }); } +export async function getMassImportRuns( + limit = 5, +): Promise { + return fetchJson( + `/api/v1/forgejo/repositories/import-runs?limit=${limit}`, + ); +} + // Forgejo Metrics API export async function getForgejoMetrics(params?: { organization_id?: string;