full upgrade pass for the Git Project settings page.

This commit is contained in:
null 2026-05-24 22:12:14 -05:00
parent fc1fa41a28
commit 1c1bade3ca
9 changed files with 1135 additions and 310 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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<string, BoardLink[]>;
onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void;
onViewDetails?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
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<ForgejoRepositoryValidationResponse>,
linkedBoardsByRepository: Record<string, BoardLink[]> = {},
onViewDetails?: (repository: ForgejoRepository) => void,
): ColumnDef<ForgejoRepository>[] => [
{
accessorKey: "displayName",
@ -196,89 +188,6 @@ const columns = (
</div>
),
},
{
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 (
<div className="min-w-[220px] space-y-2">
<span className="block truncate font-mono text-xs text-muted">
default: {repo.default_branch || "unknown"}
</span>
<div className="flex flex-wrap gap-1.5">
{visibleTopics.map((topic) => (
<Badge key={topic} variant="accent" className="gap-1 normal-case">
<Tags className="h-3 w-3" />
{topic}
</Badge>
))}
{hiddenTopics > 0 ? (
<Badge variant="outline">+{hiddenTopics} topics</Badge>
) : null}
{visibleTopics.length === 0 ? (
<span className="text-xs text-muted">No topics</span>
) : null}
</div>
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label) => (
<span
key={`${label.id ?? label.name}-${label.name}`}
className="inline-flex items-center gap-1 rounded-full border border-[color:var(--border)] px-2 py-0.5 text-xs text-strong"
title={label.description || label.name}
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: labelColor(label.color) }}
/>
{label.name}
</span>
))}
{hiddenLabels > 0 ? (
<span className="text-xs text-muted">+{hiddenLabels} labels</span>
) : null}
</div>
</div>
);
},
},
{
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 <span className="text-sm text-muted">No linked boards</span>;
}
return (
<div className="flex max-w-[240px] flex-wrap gap-1.5">
{visibleBoards.map((board) => (
<Badge key={board.id} variant="outline" className="normal-case">
{board.name}
</Badge>
))}
{hiddenBoards > 0 ? (
<Badge variant="outline">+{hiddenBoards}</Badge>
) : null}
</div>
);
},
},
{
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<RepositorySyncResult>;
onValidate?: (
repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>;
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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -409,6 +323,17 @@ function ActionsCell({
)}
</Button>
)}
{onViewDetails && (
<Button
variant="ghost"
onClick={() => onViewDetails(repository)}
className="h-8 w-8 p-0"
title="Repository details"
aria-label={`View details for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
>
<Eye className="h-4 w-4" />
</Button>
)}
</div>
);
}

View File

@ -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<MassImportRunRead[]> {
return fetchJson<MassImportRunRead[]>(
`/api/v1/forgejo/repositories/import-runs?limit=${limit}`,
);
}
// Forgejo Metrics API
export async function getForgejoMetrics(params?: {
organization_id?: string;