dded a "Mass Import" button in the header actions that opens a dialog with three states
This commit is contained in:
parent
fc24ec933b
commit
5a1caa8264
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue