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 from __future__ import annotations
import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status 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.api.deps import require_org_member
from app.core.time import utcnow
from app.db import crud from app.db import crud
from app.db.session import get_session from app.db.session import get_session
from app.models.boards import Board from app.models.boards import Board
from app.models.board_repository_links import BoardRepositoryLink from app.models.board_repository_links import BoardRepositoryLink
from app.models.forgejo_connections import ForgejoConnection 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.forgejo_repositories import ForgejoRepository
from app.schemas.boards import BoardRead from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse from app.schemas.common import OkResponse
@ -23,6 +26,7 @@ from app.schemas.forgejo_repositories import (
ForgejoRepositoryUpdate, ForgejoRepositoryUpdate,
MassImportRequest, MassImportRequest,
MassImportResponse, MassImportResponse,
MassImportRunRead,
MassImportRepoResult, MassImportRepoResult,
) )
from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus
@ -47,11 +51,69 @@ def _create_connection_info(connection: ForgejoConnection) -> dict[str, object]:
"name": connection.name, "name": connection.name,
"base_url": connection.base_url, "base_url": connection.base_url,
"has_token": connection.token is not None, "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, "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]) @router.get("", response_model=list[ForgejoRepositoryRead])
async def list_repositories( async def list_repositories(
session: AsyncSession = SESSION_DEP, session: AsyncSession = SESSION_DEP,
@ -71,9 +133,20 @@ async def list_repositories(
c = await crud.get_by_id(session, ForgejoConnection, cid) c = await crud.get_by_id(session, ForgejoConnection, cid)
if c is not None: if c is not None:
conn_map[cid] = c 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 = [] result = []
for r in repositories: 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 return result
@ -136,12 +209,11 @@ async def mass_import_repositories(
"""Run a full sync across all (or selected) active repositories for the org.""" """Run a full sync across all (or selected) active repositories for the org."""
from app.services.forgejo_issue_sync import IssueSyncService from app.services.forgejo_issue_sync import IssueSyncService
statement = ( started_at = utcnow()
select(ForgejoRepository) monotonic_start = time.monotonic()
.where( statement = select(ForgejoRepository).where(
ForgejoRepository.organization_id == ctx.organization.id, ForgejoRepository.organization_id == ctx.organization.id,
ForgejoRepository.active == True, # noqa: E712 ForgejoRepository.active == True, # noqa: E712
)
) )
all_repos = (await session.exec(statement)).all() all_repos = (await session.exec(statement)).all()
@ -190,6 +262,25 @@ async def mass_import_repositories(
) )
failed += 1 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( return MassImportResponse(
results=results, results=results,
total_created=total_created, total_created=total_created,
@ -197,9 +288,28 @@ async def mass_import_repositories(
total_stale_closed=total_stale_closed, total_stale_closed=total_stale_closed,
succeeded=succeeded, succeeded=succeeded,
failed=failed, 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) @router.get("/{repository_id}", response_model=ForgejoRepositoryRead)
async def get_repository( async def get_repository(
repository_id: UUID, repository_id: UUID,
@ -207,16 +317,25 @@ async def get_repository(
ctx: OrganizationContext = ORG_MEMBER_DEP, ctx: OrganizationContext = ORG_MEMBER_DEP,
) -> ForgejoRepositoryRead: ) -> ForgejoRepositoryRead:
"""Return one Forgejo repository by id for the caller's organization.""" """Return one Forgejo repository by id for the caller's organization."""
statement = ( statement = select(ForgejoRepository).where(
select(ForgejoRepository) ForgejoRepository.id == repository_id,
.where(ForgejoRepository.id == repository_id, ForgejoRepository.organization_id == ctx.organization.id) ForgejoRepository.organization_id == ctx.organization.id,
) )
repository = (await session.exec(statement)).first() repository = (await session.exec(statement)).first()
if repository is None: if repository is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# Load connection for response # Load connection for response
conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) 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) @router.patch("/{repository_id}", response_model=ForgejoRepositoryRead)
@ -228,9 +347,9 @@ async def update_repository(
) -> ForgejoRepositoryRead: ) -> ForgejoRepositoryRead:
"""Patch a Forgejo repository for the caller's organization.""" """Patch a Forgejo repository for the caller's organization."""
# Get repository # Get repository
statement = ( statement = select(ForgejoRepository).where(
select(ForgejoRepository) ForgejoRepository.id == repository_id,
.where(ForgejoRepository.id == repository_id, ForgejoRepository.organization_id == ctx.organization.id) ForgejoRepository.organization_id == ctx.organization.id,
) )
repository = (await session.exec(statement)).first() repository = (await session.exec(statement)).first()
if repository is None: if repository is None:
@ -238,7 +357,10 @@ async def update_repository(
# Load connection for updates validation # Load connection for updates validation
conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id)
if conn is None: 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) updates = payload.model_dump(exclude_unset=True)
@ -284,12 +406,22 @@ async def update_repository(
setattr(repository, key, value) setattr(repository, key, value)
from app.core.time import utcnow from app.core.time import utcnow
repository.updated_at = utcnow() repository.updated_at = utcnow()
# Reload connection to get latest state # Reload connection to get latest state
await crud.save(session, repository) await crud.save(session, repository)
# Load connection for response # Load connection for response
conn = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) 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) @router.delete("/{repository_id}", response_model=OkResponse)
@ -327,15 +459,15 @@ async def validate_repository(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if repository.organization_id != ctx.organization.id: if repository.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
# Load connection # Load connection
connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id) connection = await crud.get_by_id(session, ForgejoConnection, repository.connection_id)
if connection is None or connection.organization_id != ctx.organization.id: if connection is None or connection.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
from app.core.time import utcnow from app.core.time import utcnow
import time import time
start_time = time.time() start_time = time.time()
repo_exists = None repo_exists = None
try: try:
@ -444,7 +576,12 @@ async def sync_repository_issues_recent(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) 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 repository dict with safe connection metadata."""
return { return {
"id": repository.id, "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, "connection": _create_connection_info(connection) if connection is not None else None,
"has_webhook_secret": bool(repository.webhook_secret), "has_webhook_secret": bool(repository.webhook_secret),
"description": repository.description, "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), "is_archived": bool(repository.is_archived),
"topics": repository.topics if repository.topics is not None else [], "topics": repository.topics if repository.topics is not None else [],
"labels": repository.labels if repository.labels 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_at": repository.last_sync_at,
"last_sync_error": repository.last_sync_error, "last_sync_error": repository.last_sync_error,
"created_at": repository.created_at, "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.board_webhooks import BoardWebhook
from app.models.boards import Board from app.models.boards import Board
from app.models.forgejo_connections import ForgejoConnection 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_issue_task_links import ForgejoIssueTaskLink
from app.models.forgejo_issues import ForgejoIssue from app.models.forgejo_issues import ForgejoIssue
from app.models.forgejo_repositories import ForgejoRepository from app.models.forgejo_repositories import ForgejoRepository
@ -50,6 +51,7 @@ __all__ = [
"Board", "Board",
"BoardRepositoryLink", "BoardRepositoryLink",
"ForgejoConnection", "ForgejoConnection",
"ForgejoImportRun",
"ForgejoIssueTaskLink", "ForgejoIssueTaskLink",
"ForgejoIssue", "ForgejoIssue",
"ForgejoRepository", "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 active: bool
class ForgejoRepositoryBoardSummary(SQLModel):
"""Small board summary embedded in repository settings responses."""
id: UUID
name: str
class MassImportRequest(SQLModel): class MassImportRequest(SQLModel):
"""Optional body for the mass import endpoint.""" """Optional body for the mass import endpoint."""
@ -120,6 +127,25 @@ class MassImportRepoResult(SQLModel):
error: str | None = None 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): class MassImportResponse(SQLModel):
"""Aggregate result from a mass import across all requested repositories.""" """Aggregate result from a mass import across all requested repositories."""
@ -129,6 +155,7 @@ class MassImportResponse(SQLModel):
total_stale_closed: int = 0 total_stale_closed: int = 0
succeeded: int = 0 succeeded: int = 0
failed: int = 0 failed: int = 0
run: MassImportRunRead | None = None
class ForgejoRepositoryRead(ForgejoRepositoryBase): class ForgejoRepositoryRead(ForgejoRepositoryBase):
@ -144,6 +171,7 @@ class ForgejoRepositoryRead(ForgejoRepositoryBase):
is_archived: bool = False is_archived: bool = False
topics: list[str] = Field(default_factory=list) topics: list[str] = Field(default_factory=list)
labels: list[dict[str, object]] = 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_at: datetime | None
last_sync_error: str | None last_sync_error: str | None
created_at: datetime 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 { import {
Archive, Archive,
CheckCircle2, CheckCircle2,
Eye,
GitBranch, GitBranch,
KeyRound, KeyRound,
Loader2, Loader2,
RefreshCw, RefreshCw,
Tags,
} from "lucide-react"; } from "lucide-react";
import { DataTable } from "@/components/tables/DataTable"; import { DataTable } from "@/components/tables/DataTable";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -36,20 +36,12 @@ type RepositorySyncResult = {
total: number; total: number;
}; };
type BoardLink = {
id: string;
name: string;
};
const labelColor = (color: string) =>
color.startsWith("#") ? color : `#${color}`;
interface RepositoriesTableProps { interface RepositoriesTableProps {
repositories: ForgejoRepository[]; repositories: ForgejoRepository[];
isLoading: boolean; isLoading: boolean;
linkedBoardsByRepository?: Record<string, BoardLink[]>;
onEdit?: (repository: ForgejoRepository) => void; onEdit?: (repository: ForgejoRepository) => void;
onDelete?: (repository: ForgejoRepository) => void; onDelete?: (repository: ForgejoRepository) => void;
onViewDetails?: (repository: ForgejoRepository) => void;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>; onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: ( onValidate?: (
repository: ForgejoRepository, repository: ForgejoRepository,
@ -59,9 +51,9 @@ interface RepositoriesTableProps {
export function ForgejoRepositoriesTable({ export function ForgejoRepositoriesTable({
repositories, repositories,
isLoading, isLoading,
linkedBoardsByRepository = {},
onEdit, onEdit,
onDelete, onDelete,
onViewDetails,
onSync, onSync,
onValidate, onValidate,
}: RepositoriesTableProps) { }: RepositoriesTableProps) {
@ -69,7 +61,7 @@ export function ForgejoRepositoriesTable({
const _ = onEdit; const _ = onEdit;
const table = useReactTable({ const table = useReactTable({
data: repositories, data: repositories,
columns: columns(onSync, onValidate, linkedBoardsByRepository), columns: columns(onSync, onValidate, onViewDetails),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
@ -89,7 +81,7 @@ export function ForgejoRepositoriesTable({
getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`, getEditHref: (row) => `/git-projects/repositories/${row.id}/edit`,
onDelete: onDelete ?? undefined, 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?: ( onValidate?: (
repository: ForgejoRepository, repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>, ) => Promise<ForgejoRepositoryValidationResponse>,
linkedBoardsByRepository: Record<string, BoardLink[]> = {}, onViewDetails?: (repository: ForgejoRepository) => void,
): ColumnDef<ForgejoRepository>[] => [ ): ColumnDef<ForgejoRepository>[] => [
{ {
accessorKey: "displayName", accessorKey: "displayName",
@ -196,89 +188,6 @@ const columns = (
</div> </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", accessorKey: "lastSync",
header: "Last Sync", header: "Last Sync",
@ -316,6 +225,7 @@ const columns = (
repository={row.original} repository={row.original}
onSync={onSync} onSync={onSync}
onValidate={onValidate} onValidate={onValidate}
onViewDetails={onViewDetails}
/> />
), ),
}, },
@ -325,12 +235,14 @@ function ActionsCell({
repository, repository,
onSync, onSync,
onValidate, onValidate,
onViewDetails,
}: { }: {
repository: ForgejoRepository; repository: ForgejoRepository;
onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>; onSync?: (repository: ForgejoRepository) => Promise<RepositorySyncResult>;
onValidate?: ( onValidate?: (
repository: ForgejoRepository, repository: ForgejoRepository,
) => Promise<ForgejoRepositoryValidationResponse>; ) => Promise<ForgejoRepositoryValidationResponse>;
onViewDetails?: (repository: ForgejoRepository) => void;
}) { }) {
const [isSyncLoading, setIsSyncLoading] = useState(false); const [isSyncLoading, setIsSyncLoading] = useState(false);
const [isValidateLoading, setIsValidateLoading] = useState(false); const [isValidateLoading, setIsValidateLoading] = useState(false);
@ -382,6 +294,7 @@ function ActionsCell({
disabled={isSyncLoading} disabled={isSyncLoading}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
title="Sync issues" title="Sync issues"
aria-label={`Sync issues for ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
> >
{isSyncLoading ? ( {isSyncLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -399,6 +312,7 @@ function ActionsCell({
disabled={isValidateLoading} disabled={isValidateLoading}
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
title="Validate repository" title="Validate repository"
aria-label={`Validate ${repository.display_name || `${repository.owner}/${repository.repo}`}`}
> >
{isValidateLoading ? ( {isValidateLoading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -409,6 +323,17 @@ function ActionsCell({
)} )}
</Button> </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> </div>
); );
} }

View File

@ -34,6 +34,11 @@ export interface ForgejoRepoLabel {
description: string; description: string;
} }
export interface ForgejoRepositoryBoardSummary {
id: string;
name: string;
}
export interface ForgejoRepository { export interface ForgejoRepository {
id: string; id: string;
organization_id: string; organization_id: string;
@ -49,6 +54,7 @@ export interface ForgejoRepository {
is_archived: boolean; is_archived: boolean;
topics: string[]; topics: string[];
labels: ForgejoRepoLabel[]; labels: ForgejoRepoLabel[];
linked_boards: ForgejoRepositoryBoardSummary[];
connection: ForgejoConnection; connection: ForgejoConnection;
last_sync_at: string | null; last_sync_at: string | null;
last_sync_error: string | null; last_sync_error: string | null;
@ -669,6 +675,24 @@ export interface MassImportResponse {
total_stale_closed: number; total_stale_closed: number;
succeeded: number; succeeded: number;
failed: 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( 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 // Forgejo Metrics API
export async function getForgejoMetrics(params?: { export async function getForgejoMetrics(params?: {
organization_id?: string; organization_id?: string;