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,
|
ForgejoRepositoryCreate,
|
||||||
ForgejoRepositoryRead,
|
ForgejoRepositoryRead,
|
||||||
ForgejoRepositoryUpdate,
|
ForgejoRepositoryUpdate,
|
||||||
|
MassImportRequest,
|
||||||
|
MassImportResponse,
|
||||||
|
MassImportRepoResult,
|
||||||
)
|
)
|
||||||
from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus
|
from app.schemas.forgejo_validation import ForgejoRepositoryValidationResponse, ValidationStatus
|
||||||
from app.services.forgejo_client import get_forgejo_client
|
from app.services.forgejo_client import get_forgejo_client
|
||||||
|
|
@ -119,6 +122,84 @@ async def create_repository(
|
||||||
return _mask_repository(repository, conn)
|
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)
|
@router.get("/{repository_id}", response_model=ForgejoRepositoryRead)
|
||||||
async def get_repository(
|
async def get_repository(
|
||||||
repository_id: UUID,
|
repository_id: UUID,
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,37 @@ class ForgejoRepositoryConnectionInfo(SQLModel):
|
||||||
active: bool
|
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):
|
class ForgejoRepositoryRead(ForgejoRepositoryBase):
|
||||||
"""Repository payload returned from read endpoints."""
|
"""Repository payload returned from read endpoints."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ class IssueSyncService:
|
||||||
if not isinstance(issues, list) or len(issues) == 0:
|
if not isinstance(issues, list) or len(issues) == 0:
|
||||||
break
|
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:
|
for issue_data in issues:
|
||||||
# Skip pull requests
|
# Skip pull requests
|
||||||
if issue_data.get("pull_request") is not None:
|
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()
|
updated_at = self._parse_iso_date(issue_payload.get("updated_at")) or utcnow()
|
||||||
closed_at = self._parse_iso_date(issue_payload.get("closed_at"))
|
closed_at = self._parse_iso_date(issue_payload.get("closed_at"))
|
||||||
|
|
||||||
# Check if issue exists
|
existing = existing_map.get(forgejo_number)
|
||||||
existing = await self._find_issue(repository_id, forgejo_number)
|
|
||||||
|
|
||||||
if existing is None:
|
if existing is None:
|
||||||
issue = ForgejoIssue(
|
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
|
# Update repository sync metadata
|
||||||
repository.last_sync_at = utcnow()
|
repository.last_sync_at = utcnow()
|
||||||
repository.last_sync_error = None
|
repository.last_sync_error = None
|
||||||
|
|
@ -345,6 +357,7 @@ class IssueSyncService:
|
||||||
return {
|
return {
|
||||||
"created": created,
|
"created": created,
|
||||||
"updated": updated_count,
|
"updated": updated_count,
|
||||||
|
"stale_closed": stale_closed,
|
||||||
"open": open_count,
|
"open": open_count,
|
||||||
"closed": closed_count,
|
"closed": closed_count,
|
||||||
"total": created + updated_count,
|
"total": created + updated_count,
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import {
|
||||||
CircleDot,
|
CircleDot,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Link2,
|
Link2,
|
||||||
|
Loader2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
|
@ -26,17 +28,25 @@ import { ForgejoRepositoriesTable } from "@/components/git/ForgejoRepositoriesTa
|
||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { getApiBaseUrl } from "@/lib/api-base";
|
import { getApiBaseUrl } from "@/lib/api-base";
|
||||||
import {
|
import {
|
||||||
deleteForgejoConnection,
|
deleteForgejoConnection,
|
||||||
deleteForgejoRepository,
|
deleteForgejoRepository,
|
||||||
getForgejoConnections,
|
getForgejoConnections,
|
||||||
getForgejoRepositories,
|
getForgejoRepositories,
|
||||||
|
massImportRepositories,
|
||||||
syncRepository,
|
syncRepository,
|
||||||
validateConnection,
|
validateConnection,
|
||||||
validateRepository,
|
validateRepository,
|
||||||
type ForgejoConnection,
|
type ForgejoConnection,
|
||||||
type ForgejoRepository,
|
type ForgejoRepository,
|
||||||
|
type MassImportResponse,
|
||||||
} from "@/lib/api-forgejo";
|
} from "@/lib/api-forgejo";
|
||||||
|
|
||||||
type Notice = {
|
type Notice = {
|
||||||
|
|
@ -146,6 +156,10 @@ export default function GitProjectSettingsPage() {
|
||||||
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
const [repositories, setRepositories] = useState<ForgejoRepository[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSyncingAll, setIsSyncingAll] = useState(false);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [notice, setNotice] = useState<Notice | null>(null);
|
const [notice, setNotice] = useState<Notice | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | 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 handleSyncAll = async () => {
|
||||||
const active = repositories.filter((r) => r.active);
|
const active = repositories.filter((r) => r.active);
|
||||||
if (active.length === 0) return;
|
if (active.length === 0) return;
|
||||||
|
|
@ -393,6 +426,20 @@ export default function GitProjectSettingsPage() {
|
||||||
/>
|
/>
|
||||||
{isSyncingAll ? "Syncing…" : "Sync All"}
|
{isSyncingAll ? "Syncing…" : "Sync All"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -661,6 +708,138 @@ export default function GitProjectSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</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
|
<ConfirmActionDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
onOpenChange={(open) => {
|
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
|
// Forgejo Metrics API
|
||||||
export async function getForgejoMetrics(params?: {
|
export async function getForgejoMetrics(params?: {
|
||||||
organization_id?: string;
|
organization_id?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue