full upgrade pass for the Git Project settings page.
This commit is contained in:
parent
fc1fa41a28
commit
1c1bade3ca
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue