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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue