diff --git a/backend/app/api/forgejo_repositories.py b/backend/app/api/forgejo_repositories.py index dc0c04b..9b69404 100644 --- a/backend/app/api/forgejo_repositories.py +++ b/backend/app/api/forgejo_repositories.py @@ -21,6 +21,9 @@ from app.schemas.forgejo_repositories import ( ForgejoRepositoryCreate, ForgejoRepositoryRead, ForgejoRepositoryUpdate, + MassImportRequest, + MassImportResponse, + MassImportRepoResult, ) from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus from app.services.forgejo_client import get_forgejo_client @@ -119,6 +122,84 @@ async def create_repository( return _mask_repository(repository, conn) +@router.post( + "/import", + response_model=MassImportResponse, + summary="Mass Import Issues", + description="Full issue import for all active repositories (or a subset). Updates existing records, closes stale open issues.", +) +async def mass_import_repositories( + payload: MassImportRequest | None = None, + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> MassImportResponse: + """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 + ) + ) + all_repos = (await session.exec(statement)).all() + + if payload and payload.repository_ids: + id_set = set(payload.repository_ids) + repos_to_import = [r for r in all_repos if r.id in id_set] + else: + repos_to_import = list(all_repos) + + sync_service = IssueSyncService(session=session, organization_id=ctx.organization.id) + + results: list[MassImportRepoResult] = [] + total_created = 0 + total_updated = 0 + total_stale_closed = 0 + succeeded = 0 + failed = 0 + + for repo in repos_to_import: + try: + stats = await sync_service.sync_repository_issues(repository_id=repo.id) + results.append( + MassImportRepoResult( + repository_id=repo.id, + name=f"{repo.owner}/{repo.repo}", + created=stats.get("created", 0), + updated=stats.get("updated", 0), + stale_closed=stats.get("stale_closed", 0), + open=stats.get("open", 0), + closed=stats.get("closed", 0), + total=stats.get("total", 0), + error=None, + ) + ) + total_created += stats.get("created", 0) + total_updated += stats.get("updated", 0) + total_stale_closed += stats.get("stale_closed", 0) + succeeded += 1 + except Exception as e: + results.append( + MassImportRepoResult( + repository_id=repo.id, + name=f"{repo.owner}/{repo.repo}", + error=str(e), + ) + ) + failed += 1 + + return MassImportResponse( + results=results, + total_created=total_created, + total_updated=total_updated, + total_stale_closed=total_stale_closed, + succeeded=succeeded, + failed=failed, + ) + + @router.get("/{repository_id}", response_model=ForgejoRepositoryRead) async def get_repository( repository_id: UUID, diff --git a/backend/app/schemas/forgejo_repositories.py b/backend/app/schemas/forgejo_repositories.py index 5720717..bfe0419 100644 --- a/backend/app/schemas/forgejo_repositories.py +++ b/backend/app/schemas/forgejo_repositories.py @@ -100,6 +100,37 @@ class ForgejoRepositoryConnectionInfo(SQLModel): active: bool +class MassImportRequest(SQLModel): + """Optional body for the mass import endpoint.""" + + repository_ids: list[UUID] | None = None + + +class MassImportRepoResult(SQLModel): + """Per-repository result from a mass import run.""" + + repository_id: UUID + name: str + created: int = 0 + updated: int = 0 + stale_closed: int = 0 + open: int = 0 + closed: int = 0 + total: int = 0 + error: str | None = None + + +class MassImportResponse(SQLModel): + """Aggregate result from a mass import across all requested repositories.""" + + results: list[MassImportRepoResult] + total_created: int = 0 + total_updated: int = 0 + total_stale_closed: int = 0 + succeeded: int = 0 + failed: int = 0 + + class ForgejoRepositoryRead(ForgejoRepositoryBase): """Repository payload returned from read endpoints.""" diff --git a/backend/app/services/forgejo_issue_sync.py b/backend/app/services/forgejo_issue_sync.py index c3c086f..1b769be 100644 --- a/backend/app/services/forgejo_issue_sync.py +++ b/backend/app/services/forgejo_issue_sync.py @@ -94,6 +94,14 @@ class IssueSyncService: if not isinstance(issues, list) or len(issues) == 0: break + # Batch-lookup existing issues for this page (one query vs N serial) + page_numbers = [ + int(i.get("number", 0)) + for i in issues + if isinstance(i, dict) and i.get("pull_request") is None and i.get("number") + ] + existing_map = await self._find_issues_batch(repository_id, page_numbers) + for issue_data in issues: # Skip pull requests if issue_data.get("pull_request") is not None: @@ -230,8 +238,7 @@ class IssueSyncService: updated_at = self._parse_iso_date(issue_payload.get("updated_at")) or utcnow() closed_at = self._parse_iso_date(issue_payload.get("closed_at")) - # Check if issue exists - existing = await self._find_issue(repository_id, forgejo_number) + existing = existing_map.get(forgejo_number) if existing is None: issue = ForgejoIssue( @@ -337,6 +344,11 @@ class IssueSyncService: }, ) + # Close DB-open issues that Forgejo has since closed + stale_closed = await self._close_stale_opens( + repository_id, connection, repository, days=90 + ) + # Update repository sync metadata repository.last_sync_at = utcnow() repository.last_sync_error = None @@ -345,6 +357,7 @@ class IssueSyncService: return { "created": created, "updated": updated_count, + "stale_closed": stale_closed, "open": open_count, "closed": closed_count, "total": created + updated_count, diff --git a/frontend/src/app/settings/git-projects/page.tsx b/frontend/src/app/settings/git-projects/page.tsx index 946f56b..74425fa 100644 --- a/frontend/src/app/settings/git-projects/page.tsx +++ b/frontend/src/app/settings/git-projects/page.tsx @@ -11,10 +11,12 @@ import { CircleDot, Clock, Copy, + Download, ExternalLink, GitBranch, KeyRound, Link2, + Loader2, RefreshCw, Server, Webhook, @@ -26,17 +28,25 @@ import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTa import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; import { Button } from "@/components/ui/button"; import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { getApiBaseUrl } from "@/lib/api-base"; import { deleteForgejoConnection, deleteForgejoRepository, getForgejoConnections, getForgejoRepositories, + massImportRepositories, syncRepository, validateConnection, validateRepository, type ForgejoConnection, type ForgejoRepository, + type MassImportResponse, } from "@/lib/api-forgejo"; type Notice = { @@ -146,6 +156,10 @@ export default function GitProjectSettingsPage() { const [repositories, setRepositories] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSyncingAll, setIsSyncingAll] = useState(false); + const [isMassImporting, setIsMassImporting] = useState(false); + const [massImportOpen, setMassImportOpen] = useState(false); + const [massImportResult, setMassImportResult] = + useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); @@ -290,6 +304,25 @@ export default function GitProjectSettingsPage() { } }; + const handleMassImport = async () => { + setIsMassImporting(true); + setMassImportResult(null); + try { + const result = await massImportRepositories(); + setMassImportResult(result); + await loadSettings(); + } catch (err) { + setNotice({ + tone: "error", + message: + err instanceof Error ? err.message : "Mass import failed.", + }); + setMassImportOpen(false); + } finally { + setIsMassImporting(false); + } + }; + const handleSyncAll = async () => { const active = repositories.filter((r) => r.active); if (active.length === 0) return; @@ -393,6 +426,20 @@ export default function GitProjectSettingsPage() { /> {isSyncingAll ? "Syncing…" : "Sync All"} + + + + + )} + + {isMassImporting && ( +
+ +

+ Importing issues from {activeRepositories} repositor + {activeRepositories === 1 ? "y" : "ies"}… +

+
+ )} + + {!isMassImporting && massImportResult && ( +
+

+ {massImportResult.total_created} created,{" "} + {massImportResult.total_updated} updated,{" "} + {massImportResult.total_stale_closed} closed across{" "} + {massImportResult.succeeded} repositor + {massImportResult.succeeded === 1 ? "y" : "ies"}. + {massImportResult.failed > 0 && ( + + {massImportResult.failed} failed. + + )} +

+
+ + + + + + + + + + + + {massImportResult.results.map((r) => ( + + + + + + + + ))} + +
+ Repository + + Created + + Updated + + Closed + + Status +
+ {r.name} + + {r.created} + + {r.updated} + + {r.stale_closed} + + {r.error ? ( + + Error + + ) : ( + + OK + + )} +
+
+
+ +
+
+ )} + + + { diff --git a/frontend/src/lib/api-forgejo.ts b/frontend/src/lib/api-forgejo.ts index 524ade8..97ddb12 100644 --- a/frontend/src/lib/api-forgejo.ts +++ b/frontend/src/lib/api-forgejo.ts @@ -649,6 +649,40 @@ export async function getForgejoHeatmap(params?: { ); } +// Mass Import +export interface MassImportRepoResult { + repository_id: string; + name: string; + created: number; + updated: number; + stale_closed: number; + open: number; + closed: number; + total: number; + error: string | null; +} + +export interface MassImportResponse { + results: MassImportRepoResult[]; + total_created: number; + total_updated: number; + total_stale_closed: number; + succeeded: number; + failed: number; +} + +export async function massImportRepositories( + repositoryIds?: string[], +): Promise { + return fetchJson("/api/v1/forgejo/repositories/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + repositoryIds ? { repository_ids: repositoryIds } : {}, + ), + }); +} + // Forgejo Metrics API export async function getForgejoMetrics(params?: { organization_id?: string;