dded a "Mass Import" button in the header actions that opens a dialog with three states

This commit is contained in:
null 2026-05-24 20:57:58 -05:00
parent fc24ec933b
commit 5a1caa8264
5 changed files with 340 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@ -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<ForgejoRepository[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSyncingAll, setIsSyncingAll] = useState(false);
const [isMassImporting, setIsMassImporting] = useState(false);
const [massImportOpen, setMassImportOpen] = useState(false);
const [massImportResult, setMassImportResult] =
useState<MassImportResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(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"}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setMassImportResult(null);
setMassImportOpen(true);
}}
disabled={isMassImporting || isLoading || activeRepositories === 0}
title="Full issue import for all repositories"
>
<Download className="h-4 w-4" />
Mass Import
</Button>
<Button
type="button"
variant="outline"
@ -661,6 +708,138 @@ export default function GitProjectSettingsPage() {
</div>
</DashboardPageLayout>
{/* Mass Import Dialog */}
<Dialog
open={massImportOpen}
onOpenChange={(open) => {
if (!isMassImporting) setMassImportOpen(open);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Full Issue Import</DialogTitle>
</DialogHeader>
{!isMassImporting && !massImportResult && (
<div className="space-y-4">
<p className="text-sm text-muted">
Imports all issues from every tracked repository. Existing
issues are updated and stale open issues are closed if
they&apos;ve been resolved upstream. This may take a few
minutes.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setMassImportOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={handleMassImport}>
<Download className="h-4 w-4" />
Start Import
</Button>
</div>
</div>
)}
{isMassImporting && (
<div className="flex flex-col items-center gap-3 py-8">
<Loader2 className="h-8 w-8 animate-spin text-[color:var(--accent)]" />
<p className="text-sm text-muted">
Importing issues from {activeRepositories} repositor
{activeRepositories === 1 ? "y" : "ies"}
</p>
</div>
)}
{!isMassImporting && massImportResult && (
<div className="space-y-4">
<p className="text-sm text-muted">
{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 && (
<span className="ml-1 text-[color:var(--danger)]">
{massImportResult.failed} failed.
</span>
)}
</p>
<div className="overflow-x-auto rounded-xl border border-[color:var(--border)]">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-[color:var(--border)] bg-[color:var(--surface-muted)]">
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Repository
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
Created
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
Updated
</th>
<th className="px-3 py-2 text-right text-xs font-semibold uppercase tracking-wide text-muted">
Closed
</th>
<th className="px-3 py-2 text-left text-xs font-semibold uppercase tracking-wide text-muted">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-[color:var(--border)]">
{massImportResult.results.map((r) => (
<tr
key={r.repository_id}
className="bg-[color:var(--surface)] hover:bg-[color:var(--surface-muted)]"
>
<td className="px-3 py-2 font-medium text-strong">
{r.name}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.created}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.updated}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{r.stale_closed}
</td>
<td className="px-3 py-2">
{r.error ? (
<span
className="text-xs text-[color:var(--danger)]"
title={r.error}
>
Error
</span>
) : (
<span className="text-xs text-[color:var(--success)]">
OK
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
onClick={() => setMassImportOpen(false)}
>
Done
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<ConfirmActionDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {

View File

@ -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<MassImportResponse> {
return fetchJson<MassImportResponse>("/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;