diff --git a/README.md b/README.md index eb2f807..70ca927 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Enable in Settings → Mirror Options → Mirror metadata - **Ignore Status** - Mark repositories to skip from mirroring - **Automatic Cleanup** - Configure retention period for activity logs - **Scheduled Sync** - Set custom intervals for automatic mirroring +- **Reconcile with Destination** - Compare Gitea/Forgejo with the database from Configuration > Automation: adopt mirrors the database lost track of, or reset rows whose mirror is gone so the next sync recreates them. Nothing is deleted or archived by this step. ### Automatic Syncing & Synchronization diff --git a/docs/API.md b/docs/API.md index 7f49a67..c863913 100644 --- a/docs/API.md +++ b/docs/API.md @@ -117,6 +117,49 @@ Starts mirroring in the background and returns straight away. Poll the list endp `role` is the account's role in that organization (`member`, `admin`, `owner` or `billing_manager`). `409` means it is already tracked; `force: true` refreshes it. Then `POST /api/job/mirror-org` with `{ "organizationIds": ["..."] }` mirrors its repositories. +### Reconcile the destination + +`POST /api/cleanup/reconcile` + +Compares what the destination (Gitea or Forgejo) holds with the repositories this account tracks, and reports three groups: mirrors on the destination that the database does not know about, rows marked mirrored whose repository is gone, and a healthy count. Only mirrors whose source URL points at the configured source count as this app's. Native repositories and mirrors of other hosts are listed under `notManaged` and never touched. Useful after a lost or restored database (issue #284). + +```json +{ "dryRun": true, "adoptUntracked": false, "resetMissing": false } +``` + +The body is optional and every field defaults to the value above, so an empty `POST` is a dry run. With `dryRun: false`: + +- `adoptUntracked: true` creates a row for each untracked mirror from its source URL, so scheduled sync and the cleanup service include it from then on. The row keeps the mirror where it is, even when the strategy would put it elsewhere. +- `resetMissing: true` sets each missing row back to `imported` so the next mirror run recreates the mirror. Rows are never deleted. + +Nothing is deleted or archived on either side by this call; the cleanup service keeps its own rules. + +```json +{ + "success": true, + "dryRun": false, + "report": { + "untracked": [{ "location": "github-mirrors/hello-world", "originalUrl": "https://github.com/octocat/hello-world.git", "sourcePath": "octocat/hello-world", "isPrivate": false }], + "missing": [{ "id": "9b2f...", "fullName": "octocat/gone", "location": "github-mirrors/gone" }], + "notManaged": [{ "location": "me/notes", "reason": "not a mirror" }], + "unverified": [], + "healthyCount": 41, + "scannedOwners": ["e2e_admin", "github-mirrors"], + "skippedOwners": ["starred"], + "totalOnDestination": 43 + }, + "applied": { "adopted": 1, "reset": 1, "skipped": 0 } +} +``` + +`applied` is `null` on a dry run. `scannedOwners` are the destination users and organizations that were listed; `skippedOwners` are ones the configuration or the database point at but the destination does not have. A row lands in `unverified` when the presence check itself failed, and is left alone. + +| Status | Meaning | +| --- | --- | +| `200` | Report computed, and applied when asked. | +| `400` | A field has the wrong type, or the destination is not configured yet. | +| `404` | No configuration for this account. | + ## A complete example Add a repository and mirror it in one go: diff --git a/src/components/config/AutomationSettings.tsx b/src/components/config/AutomationSettings.tsx index a137db6..4170888 100644 --- a/src/components/config/AutomationSettings.tsx +++ b/src/components/config/AutomationSettings.tsx @@ -38,6 +38,7 @@ import { CardDivider, CardSection, } from "./settings-ui"; +import { ReconcileDestinationButton } from "./ReconcileDialog"; interface AutomationSettingsProps { scheduleConfig: ScheduleConfig; @@ -394,10 +395,13 @@ export function AutomationSettings({ } headerAction={savingSpinner(isAutoSavingCleanup)} footer={ - + <> + + + } > {cleanupConfig.deleteIfNotInGitHub && ( diff --git a/src/components/config/ReconcileDialog.tsx b/src/components/config/ReconcileDialog.tsx new file mode 100644 index 0000000..d145f39 --- /dev/null +++ b/src/components/config/ReconcileDialog.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useState } from "react"; +import { GitCompare, LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { withBase } from "@/lib/base-path"; + +interface ReconcileReport { + untracked: Array<{ location: string; originalUrl: string; sourcePath: string; isPrivate: boolean }>; + missing: Array<{ id: string; fullName: string; location: string }>; + notManaged: Array<{ location: string; reason: string }>; + unverified: Array<{ fullName: string; location: string; error: string }>; + healthyCount: number; + scannedOwners: string[]; + skippedOwners: string[]; + totalOnDestination: number; +} + +interface ReconcileResponse { + success: boolean; + dryRun: boolean; + report: ReconcileReport; + applied: { adopted: number; reset: number; skipped: number } | null; + error?: string; +} + +const RECONCILE_API_PATH = withBase("/api/cleanup/reconcile"); +const MAX_LISTED = 8; + +async function postReconcile(body: { + dryRun: boolean; + adoptUntracked?: boolean; + resetMissing?: boolean; +}): Promise { + const response = await fetch(RECONCILE_API_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await response.json().catch(() => ({}))) as Partial; + if (!response.ok || !data.success || !data.report) { + throw new Error(data.error || `Reconcile failed (${response.status})`); + } + return data as ReconcileResponse; +} + +function RepoList({ + title, + items, + hint, + tone = "muted", +}: { + title: string; + items: string[]; + hint: string; + tone?: "muted" | "amber" | "rose"; +}) { + const shown = items.slice(0, MAX_LISTED); + const rest = items.length - shown.length; + const countClass = + tone === "amber" + ? "text-amber-500" + : tone === "rose" + ? "text-rose-500" + : "text-muted-foreground"; + return ( +
+
+ {title} + {items.length} +
+

{hint}

+ {shown.length > 0 && ( +
    + {shown.map((item) => ( +
  • + {item} +
  • + ))} + {rest > 0 && ( +
  • and {rest} more
  • + )} +
+ )} +
+ ); +} + +interface ReconcileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * Compares the destination with the repository database. Runs a dry run on + * open, then applies the two opt-in fixes the user ticks. + */ +export function ReconcileDialog({ open, onOpenChange }: ReconcileDialogProps) { + const [loading, setLoading] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [report, setReport] = useState(null); + const [applied, setApplied] = useState<{ adopted: number; reset: number; skipped: number } | null>(null); + const [adopt, setAdopt] = useState(false); + const [reset, setReset] = useState(false); + + const runDryRun = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await postReconcile({ dryRun: true }); + setReport(result.report); + } catch (err) { + setError(err instanceof Error ? err.message : "Reconcile failed"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!open) return; + setReport(null); + setApplied(null); + setAdopt(false); + setReset(false); + void runDryRun(); + }, [open, runDryRun]); + + const apply = async () => { + if (!adopt && !reset) return; + setApplying(true); + setError(null); + try { + const result = await postReconcile({ + dryRun: false, + adoptUntracked: adopt, + resetMissing: reset, + }); + const summary = result.applied ?? { adopted: 0, reset: 0, skipped: 0 }; + setApplied(summary); + toast.success( + `Adopted ${summary.adopted} untracked mirror${summary.adopted === 1 ? "" : "s"}, reset ${summary.reset} missing row${summary.reset === 1 ? "" : "s"}` + ); + setAdopt(false); + setReset(false); + await runDryRun(); + } catch (err) { + const message = err instanceof Error ? err.message : "Reconcile failed"; + setError(message); + toast.error(message); + } finally { + setApplying(false); + } + }; + + const untrackedCount = report?.untracked.length ?? 0; + const missingCount = report?.missing.length ?? 0; + const busy = loading || applying; + + return ( + !busy && onOpenChange(next)}> + + + Reconcile with destination + + Compares what the destination holds with the repositories this app tracks. Only mirrors of the configured source are considered. Nothing is deleted or archived here. + + + + {loading && !report && ( +
+ + Listing the destination… +
+ )} + + {error && ( +

+ {error} +

+ )} + + {report && ( +
+

+ {report.totalOnDestination} repositor{report.totalOnDestination === 1 ? "y" : "ies"} under{" "} + {report.scannedOwners.length} owner{report.scannedOwners.length === 1 ? "" : "s"} on the destination,{" "} + {report.healthyCount} tracked and present. + {report.skippedOwners.length > 0 && ( + <> Not found there: {report.skippedOwners.join(", ")}. + )} +

+ + r.location)} + hint="Mirrors of your source that this app does not know about. Adopting them adds a row so scheduled sync and cleanup include them." + tone={untrackedCount > 0 ? "amber" : "muted"} + /> + `${r.fullName} (was ${r.location})`)} + hint="Rows marked mirrored whose repository is no longer there. Resetting them makes the next mirror run recreate the mirror." + tone={missingCount > 0 ? "rose" : "muted"} + /> + {report.notManaged.length > 0 && ( + `${r.location} (${r.reason})`)} + hint="Native repositories and mirrors of other hosts. These are listed for information and never touched." + /> + )} + {report.unverified.length > 0 && ( + `${r.fullName}: ${r.error}`)} + hint="The destination did not answer the presence check for these rows. They were left alone." + tone="amber" + /> + )} + + {applied && ( +

+ Applied: adopted {applied.adopted}, reset {applied.reset} + {applied.skipped > 0 ? `, skipped ${applied.skipped}` : ""}. The lists above are refreshed. +

+ )} + +
+ + +
+
+ )} + + + + + + +
+
+ ); +} + +/** The button that opens the dialog, for the Repository Cleanup card. */ +export function ReconcileDestinationButton() { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); +} diff --git a/src/lib/destination-reconcile-service.ts b/src/lib/destination-reconcile-service.ts new file mode 100644 index 0000000..40b62ef --- /dev/null +++ b/src/lib/destination-reconcile-service.ts @@ -0,0 +1,455 @@ +/** + * Reconcile the destination (Gitea or Forgejo) with the repositories table. + * + * Lists what the destination holds under every owner this account mirrors + * into, sorts it against the database (see destination-reconcile.ts), and + * on request adopts untracked mirrors or resets rows whose mirror is gone. + * It never deletes or archives anything on either side; the cleanup service + * keeps its own rules for that. + */ + +import { and, eq, inArray } from "drizzle-orm"; +import { db, organizations, repositories } from "@/lib/db"; +import type { Repository } from "@/lib/db/schema"; +import type { Config } from "@/types/config"; +import type { GitRepo } from "@/types/Repository"; +import { HttpError, httpGet } from "@/lib/http-client"; +import { getDecryptedGiteaToken } from "@/lib/utils/config-encryption"; +import { createSourceProviderFromConfig, resolveSourceConnection } from "@/lib/source-providers"; +import { githubApiBaseUrl } from "@/lib/source-providers/github-source"; +import { getGiteaRepoOwnerAsync } from "@/lib/gitea"; +import { normalizeGitRepoToInsert } from "@/lib/repo-utils"; +import { createMirrorJob } from "@/lib/helpers"; +import { processInParallel } from "@/lib/utils/concurrency"; +import { + classifyDestinationRepos, + collectDestinationOwners, + expectedLocation, + knownSourceHosts, + parseRepoUrl, + type DestinationRepo, +} from "@/lib/destination-reconcile"; + +const PAGE_SIZE = 50; +const MAX_PAGES = 400; +const LOOKUP_CONCURRENCY = 4; + +export interface ReconcileOptions { + dryRun: boolean; + adoptUntracked: boolean; + resetMissing: boolean; +} + +export interface ReconcileReport { + /** Mirrors of the configured source that have no database row. */ + untracked: Array<{ + location: string; + originalUrl: string; + sourcePath: string; + isPrivate: boolean; + }>; + /** Rows that say mirrored, whose repository the destination no longer has. */ + missing: Array<{ id: string; fullName: string; location: string }>; + /** Repositories on the destination this app does not own. */ + notManaged: Array<{ location: string; reason: string }>; + /** Rows whose presence could not be confirmed because the check itself failed. */ + unverified: Array<{ fullName: string; location: string; error: string }>; + healthyCount: number; + scannedOwners: string[]; + skippedOwners: string[]; + totalOnDestination: number; +} + +export interface ReconcileResult { + dryRun: boolean; + report: ReconcileReport; + /** Null on a dry run. */ + applied: { adopted: number; reset: number; skipped: number } | null; +} + +/** The subset of Gitea's repository JSON the reconcile reads. */ +interface GiteaRepoJson { + name?: string; + full_name?: string; + owner?: { login?: string; username?: string }; + mirror?: boolean; + original_url?: string; + private?: boolean; + archived?: boolean; + description?: string | null; + default_branch?: string; + size?: number; + html_url?: string; + clone_url?: string; + language?: string | null; + has_issues?: boolean; + updated_at?: string; + mirror_updated?: string; +} + +function toDestinationRepo(json: GiteaRepoJson): DestinationRepo | null { + const owner = json.owner?.login ?? json.owner?.username ?? json.full_name?.split("/")[0]; + const name = json.name ?? json.full_name?.split("/")[1]; + if (!owner || !name) return null; + return { + owner, + name, + fullName: `${owner}/${name}`, + mirror: json.mirror === true, + originalUrl: json.original_url ?? "", + isPrivate: json.private === true, + isArchived: json.archived === true, + description: json.description ?? null, + defaultBranch: json.default_branch || "main", + size: typeof json.size === "number" ? json.size : 0, + htmlUrl: json.html_url ?? "", + cloneUrl: json.clone_url ?? "", + language: json.language ?? null, + hasIssues: json.has_issues === true, + updatedAt: json.updated_at ?? null, + mirrorUpdated: json.mirror_updated ?? null, + }; +} + +/** + * Page through every repository an owner has on the destination. Tries the + * organization listing first, then the user listing. Null when the owner + * does not exist there. + */ +async function listDestinationOwnerRepos( + baseUrl: string, + headers: Record, + owner: string +): Promise { + for (const kind of ["orgs", "users"] as const) { + const repos: DestinationRepo[] = []; + let page = 1; + let found = true; + while (page <= MAX_PAGES) { + let data: GiteaRepoJson[]; + try { + const response = await httpGet( + `${baseUrl}/api/v1/${kind}/${encodeURIComponent(owner)}/repos?page=${page}&limit=${PAGE_SIZE}`, + headers + ); + data = Array.isArray(response.data) ? response.data : []; + } catch (error) { + if (error instanceof HttpError && error.status === 404 && page === 1) { + found = false; + break; + } + throw error; + } + for (const json of data) { + const repo = toDestinationRepo(json); + if (repo) repos.push(repo); + } + if (data.length < PAGE_SIZE) break; + page += 1; + } + if (found) return repos; + } + return null; +} + +function parseDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Web URL of the source repository, keeping the scheme the mirror was created with. */ +function sourceWebUrl(originalUrl: string, path: string, fallbackHost: string): string { + try { + const url = new URL(originalUrl); + return `${url.protocol}//${url.host}/${path}`; + } catch { + return `https://${fallbackHost}/${path}`; + } +} + +export async function reconcileDestination( + config: Config, + options: ReconcileOptions +): Promise { + const userId = config.userId; + const giteaConfig = config.giteaConfig; + const githubConfig = config.githubConfig; + if (!userId || !config.id) { + throw new Error("A saved configuration is required."); + } + if (!giteaConfig?.url || !giteaConfig?.token || !giteaConfig?.defaultOwner) { + throw new Error("The destination URL, token and username must be configured first."); + } + + const baseUrl = giteaConfig.url.replace(/\/+$/, ""); + const headers = { Authorization: `token ${getDecryptedGiteaToken(config)}` }; + const connection = resolveSourceConnection(config); + const starredMode = githubConfig?.starredReposMode || "dedicated-org"; + const starredOrg = githubConfig?.starredReposOrg || "starred"; + const strategy = + githubConfig?.mirrorStrategy || (giteaConfig.preserveOrgStructure ? "preserve" : "flat-user"); + + const rows = await db.select().from(repositories).where(eq(repositories.userId, userId)); + const orgRows = await db + .select({ name: organizations.name, destinationOrg: organizations.destinationOrg }) + .from(organizations) + .where(eq(organizations.userId, userId)); + + // Every owner the strategy or the database could have put a mirror under. + const owners = collectDestinationOwners([ + giteaConfig.defaultOwner, + giteaConfig.organization, + starredMode === "preserve-owner" ? null : starredOrg, + ...orgRows.flatMap((org) => [org.name, org.destinationOrg]), + ...rows.flatMap((row) => [ + row.destinationOrg, + (row.mirroredLocation ?? "").split("/")[0], + row.organization, + strategy === "preserve" ? row.owner : null, + ]), + ]); + + const scannedOwners: string[] = []; + const skippedOwners: string[] = []; + const destinationRepos: DestinationRepo[] = []; + for (const owner of owners) { + const repos = await listDestinationOwnerRepos(baseUrl, headers, owner); + if (repos === null) { + skippedOwners.push(owner); + continue; + } + scannedOwners.push(owner); + destinationRepos.push(...repos); + } + + const knownHosts = knownSourceHosts({ + sourceUrl: connection.url, + apiUrl: connection.provider === "github" ? githubApiBaseUrl() : null, + cloneUrls: rows.map((row) => row.cloneUrl), + }); + + const classified = classifyDestinationRepos({ destinationRepos, rows, knownHosts }); + const listedLocations = new Set(destinationRepos.map((repo) => repo.fullName.toLowerCase())); + + // Rows that claim a mirror the listing did not show. Confirm each one + // directly before calling it missing, so a listing hiccup never resets a + // healthy repository. + const missing: ReconcileReport["missing"] = []; + const unverified: ReconcileReport["unverified"] = []; + let confirmedPresent = 0; + await processInParallel( + classified.unmatchedMirroredRows, + async (row) => { + let resolvedOwner = giteaConfig.defaultOwner; + try { + resolvedOwner = await getGiteaRepoOwnerAsync({ + config, + repository: row as unknown as Repository, + }); + } catch { + // Fall back to the account; the direct check below decides. + } + const target = expectedLocation(row, resolvedOwner); + if (listedLocations.has(target.location.toLowerCase())) { + confirmedPresent += 1; + return; + } + try { + await httpGet( + `${baseUrl}/api/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}`, + headers + ); + confirmedPresent += 1; + } catch (error) { + if (error instanceof HttpError && error.status === 404) { + missing.push({ id: row.id, fullName: row.fullName, location: target.location }); + } else { + unverified.push({ + fullName: row.fullName, + location: target.location, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }, + LOOKUP_CONCURRENCY + ); + + const report: ReconcileReport = { + untracked: classified.untracked.map((repo) => ({ + location: repo.fullName, + originalUrl: repo.originalUrl, + sourcePath: parseRepoUrl(repo.originalUrl)?.path ?? repo.originalUrl, + isPrivate: repo.isPrivate, + })), + missing, + notManaged: classified.notManaged, + unverified, + healthyCount: classified.matchedRowIds.size + confirmedPresent, + scannedOwners, + skippedOwners, + totalOnDestination: destinationRepos.length, + }; + + if (options.dryRun || (!options.adoptUntracked && !options.resetMissing)) { + return { dryRun: true, report, applied: null }; + } + + let adopted = 0; + let skipped = 0; + let reset = 0; + + if (options.adoptUntracked && classified.untracked.length > 0) { + const sourceProvider = createSourceProviderFromConfig(config, { userId }); + await processInParallel( + classified.untracked, + async (repo) => { + try { + const outcome = await adoptUntrackedMirror({ + config, + repo, + lookup: (owner, name) => sourceProvider.getRepository(owner, name), + connection, + starredOrg: starredMode === "preserve-owner" ? null : starredOrg, + }); + if (outcome === "adopted") adopted += 1; + else skipped += 1; + } catch (error) { + skipped += 1; + console.warn( + `[Reconcile] Could not adopt ${repo.fullName}: ${error instanceof Error ? error.message : String(error)}` + ); + } + }, + LOOKUP_CONCURRENCY + ); + } + + if (options.resetMissing && missing.length > 0) { + const ids = missing.map((entry) => entry.id); + const updated = await db + .update(repositories) + .set({ + status: "imported", + mirroredLocation: "", + errorMessage: "Not found on the destination during reconcile. The next mirror run recreates it.", + updatedAt: new Date(), + }) + .where(and(eq(repositories.userId, userId), inArray(repositories.id, ids))) + .returning({ id: repositories.id }); + reset = updated.length; + } + + if (adopted > 0 || reset > 0) { + await createMirrorJob({ + userId, + status: "synced", + jobType: "sync", + message: "Reconciled the database with the destination", + details: `Adopted ${adopted} untracked mirror${adopted === 1 ? "" : "s"}, reset ${reset} row${reset === 1 ? "" : "s"} whose mirror was gone.`, + skipNotification: true, + }); + } + + return { dryRun: false, report, applied: { adopted, reset, skipped } }; +} + +/** + * Create the database row for a mirror the destination has and the + * database does not. Prefers the source's own metadata, falls back to what + * the destination reports when the source repository cannot be read. + */ +async function adoptUntrackedMirror({ + config, + repo, + lookup, + connection, + starredOrg, +}: { + config: Config; + repo: DestinationRepo; + lookup: (owner: string, name: string) => Promise; + connection: { provider: GitRepo["sourceProvider"]; url: string }; + starredOrg: string | null; +}): Promise<"adopted" | "skipped"> { + const origin = parseRepoUrl(repo.originalUrl); + if (!origin) return "skipped"; + + let fromSource: GitRepo | null = null; + try { + fromSource = await lookup(origin.owner, origin.name); + } catch { + fromSource = null; + } + + const now = new Date(); + const lastMirrored = parseDate(repo.mirrorUpdated) ?? parseDate(repo.updatedAt) ?? now; + const fallback: GitRepo = { + name: origin.name, + fullName: origin.path, + url: sourceWebUrl(repo.originalUrl, origin.path, origin.host), + cloneUrl: repo.originalUrl, + owner: origin.owner, + sourceProvider: connection.provider, + sourceUrl: connection.url, + isPrivate: repo.isPrivate, + isForked: false, + hasIssues: repo.hasIssues, + isStarred: false, + isArchived: repo.isArchived, + size: repo.size, + hasLFS: false, + hasSubmodules: false, + language: repo.language, + description: repo.description, + defaultBranch: repo.defaultBranch, + visibility: repo.isPrivate ? "private" : "public", + status: "mirrored", + importedAt: now, + createdAt: now, + updatedAt: now, + }; + + const livesInStarredOrg = + starredOrg !== null && repo.owner.trim().toLowerCase() === starredOrg.trim().toLowerCase(); + + const gitRepo: GitRepo = { + ...(fromSource ?? fallback), + isStarred: livesInStarredOrg || fromSource?.isStarred === true, + status: "mirrored", + mirroredLocation: repo.fullName, + lastMirrored, + errorMessage: undefined, + importedAt: now, + }; + + const insert = normalizeGitRepoToInsert(gitRepo, { userId: config.userId!, configId: config.id! }); + // The normalizer always starts rows as imported; this one is already mirrored. + insert.status = "mirrored"; + insert.mirroredLocation = repo.fullName; + insert.lastMirrored = lastMirrored; + insert.errorMessage = null; + + // If the strategy would put this repository somewhere else, pin it where + // it already is so the next sync does not create a second copy. + try { + const strategyOwner = await getGiteaRepoOwnerAsync({ + config, + repository: insert as unknown as Repository, + }); + if (strategyOwner.trim().toLowerCase() !== repo.owner.trim().toLowerCase()) { + insert.destinationOrg = repo.owner; + } + } catch { + insert.destinationOrg = repo.owner; + } + + const inserted = await db + .insert(repositories) + .values(insert) + .onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] }) + .returning({ id: repositories.id }); + + return inserted.length > 0 ? "adopted" : "skipped"; +} diff --git a/src/lib/destination-reconcile.test.ts b/src/lib/destination-reconcile.test.ts new file mode 100644 index 0000000..a2a59c3 --- /dev/null +++ b/src/lib/destination-reconcile.test.ts @@ -0,0 +1,261 @@ +/** + * Unit tests for the pure reconcile decisions (issue #284): which + * destination repositories are ours, which of those the database knows + * about, and which rows claim a mirror the destination no longer has. + */ + +import { describe, test, expect } from "bun:test"; +import { + classifyDestinationRepos, + collectDestinationOwners, + expectedLocation, + knownSourceHosts, + parseRepoUrl, + type DestinationRepo, + type TrackedRepositoryRow, +} from "./destination-reconcile"; + +function destinationRepo(overrides: Partial & { fullName: string }): DestinationRepo { + const [owner, name] = overrides.fullName.split("/"); + return { + owner, + name, + mirror: true, + originalUrl: `https://github.com/${overrides.fullName}.git`, + isPrivate: false, + isArchived: false, + description: null, + defaultBranch: "main", + size: 0, + htmlUrl: `https://gitea.example.com/${overrides.fullName}`, + cloneUrl: `https://gitea.example.com/${overrides.fullName}.git`, + language: null, + hasIssues: false, + updatedAt: null, + mirrorUpdated: null, + ...overrides, + }; +} + +function row(overrides: Partial & { fullName: string }): TrackedRepositoryRow { + const name = overrides.fullName.split("/").pop() ?? overrides.fullName; + return { + id: overrides.fullName, + name, + cloneUrl: `https://github.com/${overrides.fullName}.git`, + mirroredLocation: null, + status: "mirrored", + ...overrides, + }; +} + +describe("parseRepoUrl", () => { + test("reads host and path from https clone URLs, dropping .git", () => { + expect(parseRepoUrl("https://github.com/octocat/Hello-World.git")).toEqual({ + host: "github.com", + path: "octocat/Hello-World", + owner: "octocat", + name: "Hello-World", + }); + }); + + test("keeps GitLab subgroups in the owner", () => { + expect(parseRepoUrl("https://gitlab.com/group/sub/project.git")).toEqual({ + host: "gitlab.com", + path: "group/sub/project", + owner: "group/sub", + name: "project", + }); + }); + + test("handles self hosted Gitea URLs with a port and no suffix", () => { + expect(parseRepoUrl("http://gitea.lan:3000/team/tool")).toMatchObject({ + host: "gitea.lan:3000", + path: "team/tool", + }); + }); + + test("strips credentials, query and fragment", () => { + expect(parseRepoUrl("https://user:token@github.com/o/r.git?x=1#top")).toMatchObject({ + host: "github.com", + path: "o/r", + }); + }); + + test("accepts the scp style ssh form", () => { + expect(parseRepoUrl("git@github.com:octocat/Hello-World.git")).toMatchObject({ + host: "github.com", + path: "octocat/Hello-World", + }); + }); + + test("rejects empty and non repository URLs", () => { + expect(parseRepoUrl("")).toBeNull(); + expect(parseRepoUrl(" ")).toBeNull(); + expect(parseRepoUrl("https://github.com/")).toBeNull(); + expect(parseRepoUrl("https://github.com/only-owner")).toBeNull(); + expect(parseRepoUrl("not a url")).toBeNull(); + }); +}); + +describe("knownSourceHosts", () => { + test("includes the source, the API host with and without api., and clone hosts", () => { + const hosts = knownSourceHosts({ + sourceUrl: "https://github.com", + apiUrl: "https://api.github.com", + cloneUrls: ["https://git.internal:8080/team/repo.git", null, ""], + }); + expect([...hosts].sort()).toEqual(["api.github.com", "git.internal:8080", "github.com"]); + }); + + test("a GitHub Enterprise API host counts as a clone host", () => { + const hosts = knownSourceHosts({ + sourceUrl: "https://github.com", + apiUrl: "https://ghe.example.com/api/v3", + cloneUrls: [], + }); + expect(hosts.has("ghe.example.com")).toBeTrue(); + }); +}); + +describe("classifyDestinationRepos", () => { + const knownHosts = new Set(["github.com"]); + + test("a mirror of the source with no row is untracked", () => { + const result = classifyDestinationRepos({ + destinationRepos: [destinationRepo({ fullName: "mirrors/hello" })], + rows: [], + knownHosts, + }); + expect(result.untracked.map((r) => r.fullName)).toEqual(["mirrors/hello"]); + expect(result.notManaged).toEqual([]); + }); + + test("a native repository is reported as not managed and never untracked", () => { + const result = classifyDestinationRepos({ + destinationRepos: [destinationRepo({ fullName: "me/notes", mirror: false, originalUrl: "" })], + rows: [], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.notManaged).toEqual([{ location: "me/notes", reason: "not a mirror" }]); + }); + + test("a mirror of another host is reported with its host", () => { + const result = classifyDestinationRepos({ + destinationRepos: [ + destinationRepo({ fullName: "mirrors/other", originalUrl: "https://codeberg.org/x/other.git" }), + ], + rows: [], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.notManaged).toEqual([{ location: "mirrors/other", reason: "mirror of codeberg.org" }]); + }); + + test("a mirror without a usable original URL is not managed", () => { + const result = classifyDestinationRepos({ + destinationRepos: [destinationRepo({ fullName: "mirrors/blank", originalUrl: "" })], + rows: [], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.notManaged[0].reason).toBe("mirror without a source URL"); + }); + + test("matches a row by its recorded mirrored location, regardless of case", () => { + const result = classifyDestinationRepos({ + destinationRepos: [destinationRepo({ fullName: "Mirrors/Hello" })], + rows: [row({ fullName: "octocat/hello", mirroredLocation: "mirrors/hello" })], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.trackedLocations.has("mirrors/hello")).toBeTrue(); + expect(result.matchedRowIds.has("octocat/hello")).toBeTrue(); + expect(result.unmatchedMirroredRows).toEqual([]); + }); + + test("matches a row by clone URL when the recorded location is stale", () => { + const result = classifyDestinationRepos({ + destinationRepos: [ + destinationRepo({ fullName: "moved/hello", originalUrl: "https://github.com/octocat/hello.git" }), + ], + rows: [row({ fullName: "octocat/hello", mirroredLocation: "old-org/hello" })], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.matchedRowIds.has("octocat/hello")).toBeTrue(); + }); + + test("matches a row by the source path in its full name", () => { + const result = classifyDestinationRepos({ + destinationRepos: [ + destinationRepo({ fullName: "mirrors/hello", originalUrl: "https://github.com/OctoCat/Hello" }), + ], + rows: [row({ fullName: "octocat/hello", cloneUrl: "" })], + knownHosts, + }); + expect(result.untracked).toEqual([]); + expect(result.matchedRowIds.has("octocat/hello")).toBeTrue(); + }); + + test("rows that say mirrored but matched nothing are candidates for the missing check", () => { + const result = classifyDestinationRepos({ + destinationRepos: [], + rows: [ + row({ fullName: "octocat/gone", status: "mirrored", mirroredLocation: "mirrors/gone" }), + row({ fullName: "octocat/synced", status: "synced" }), + row({ fullName: "octocat/new", status: "imported" }), + row({ fullName: "octocat/broken", status: "failed", mirroredLocation: "mirrors/broken" }), + ], + knownHosts, + }); + expect(result.unmatchedMirroredRows.map((r) => r.fullName)).toEqual([ + "octocat/gone", + "octocat/synced", + ]); + }); + + test("mixed listing sorts every repository into exactly one group", () => { + const result = classifyDestinationRepos({ + destinationRepos: [ + destinationRepo({ fullName: "mirrors/tracked" }), + destinationRepo({ fullName: "mirrors/orphan" }), + destinationRepo({ fullName: "me/native", mirror: false, originalUrl: "" }), + destinationRepo({ fullName: "mirrors/foreign", originalUrl: "https://gitlab.com/a/b.git" }), + ], + rows: [row({ fullName: "octocat/tracked", mirroredLocation: "mirrors/tracked" })], + knownHosts, + }); + expect(result.untracked.map((r) => r.fullName)).toEqual(["mirrors/orphan"]); + expect(result.notManaged.map((r) => r.location).sort()).toEqual(["me/native", "mirrors/foreign"]); + expect([...result.trackedLocations]).toEqual(["mirrors/tracked"]); + }); +}); + +describe("expectedLocation", () => { + test("prefers the recorded mirrored location", () => { + expect(expectedLocation({ name: "hello", mirroredLocation: "mirrors/hello" }, "fallback")).toEqual({ + owner: "mirrors", + name: "hello", + location: "mirrors/hello", + }); + }); + + test("falls back to the resolved owner and the row name", () => { + expect(expectedLocation({ name: "hello", mirroredLocation: "" }, "octo-mirrors")).toEqual({ + owner: "octo-mirrors", + name: "hello", + location: "octo-mirrors/hello", + }); + expect(expectedLocation({ name: "hello", mirroredLocation: "/" }, "owner").location).toBe("owner/hello"); + }); +}); + +describe("collectDestinationOwners", () => { + test("deduplicates without regard to case and drops blanks and paths", () => { + expect( + collectDestinationOwners(["Mirrors", "mirrors", " starred ", "", null, undefined, "org/repo", "user"]) + ).toEqual(["Mirrors", "starred", "user"]); + }); +}); diff --git a/src/lib/destination-reconcile.ts b/src/lib/destination-reconcile.ts new file mode 100644 index 0000000..20aa0cf --- /dev/null +++ b/src/lib/destination-reconcile.ts @@ -0,0 +1,273 @@ +/** + * Pure decision logic for reconciling the destination (Gitea or Forgejo) + * with the repositories table. + * + * The database is the only thing the cleanup service and the UI look at, so + * a mirror that exists on the destination without a row is invisible to + * every maintenance feature (issue #284). The helpers here take what the + * destination reports and what the database holds and sort it into three + * groups: mirrors the database does not know about, rows whose mirror is + * gone, and everything that matches. They never touch the network or the + * database, so they are unit tested directly; the async orchestration lives + * in destination-reconcile-service.ts. + */ + +import { sourceHostOf } from "@/lib/source-providers/kinds"; + +/** What the destination reports about one repository. */ +export interface DestinationRepo { + /** Owner login on the destination. */ + owner: string; + name: string; + /** `owner/name` on the destination. */ + fullName: string; + mirror: boolean; + /** The clone address the mirror was created from, credentials stripped. */ + originalUrl: string; + isPrivate: boolean; + isArchived: boolean; + description: string | null; + defaultBranch: string; + /** Size in kilobytes, as Gitea reports it. */ + size: number; + htmlUrl: string; + cloneUrl: string; + language: string | null; + hasIssues: boolean; + updatedAt: string | null; + mirrorUpdated: string | null; +} + +/** The columns of a repositories row the classification needs. */ +export interface TrackedRepositoryRow { + id: string; + name: string; + fullName: string; + cloneUrl: string; + mirroredLocation: string | null; + status: string; +} + +export interface NotManagedRepo { + location: string; + reason: string; +} + +export interface ClassifiedDestination { + /** Mirrors of the configured source with no database row. */ + untracked: DestinationRepo[]; + /** Repositories on the destination this app does not own. Never touched. */ + notManaged: NotManagedRepo[]; + /** Lower-cased `owner/name` of every destination repository that matched a row. */ + trackedLocations: Set; + /** Rows the destination listing accounted for. */ + matchedRowIds: Set; + /** Rows that claim to be mirrored but no destination repository matched. */ + unmatchedMirroredRows: TrackedRepositoryRow[]; +} + +/** Statuses that mean "the mirror is supposed to exist on the destination". */ +export const MIRRORED_STATUSES: ReadonlySet = new Set(["mirrored", "synced"]); + +export interface ParsedRepoUrl { + /** Lower-cased host. */ + host: string; + /** `owner/name` (or `group/sub/name`), without a `.git` suffix. */ + path: string; + owner: string; + name: string; +} + +/** + * Split a clone or web URL into host and repository path. Accepts https, + * http, ssh:// and the `git@host:owner/name.git` form, and drops any + * credentials, query and fragment. Returns null when the URL does not name + * a repository. + */ +export function parseRepoUrl(raw: string | null | undefined): ParsedRepoUrl | null { + const value = (raw ?? "").trim(); + if (!value) return null; + + let host = ""; + let pathname = ""; + + const scp = value.match(/^(?:[^@\s/]+@)([^:/\s]+):(.+)$/); + if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + host = scp[1].toLowerCase(); + pathname = scp[2]; + } else { + try { + const url = new URL(value); + host = url.host.toLowerCase(); + pathname = url.pathname; + } catch { + return null; + } + } + + const segments = pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length < 2) return null; + + const last = segments[segments.length - 1].replace(/\.git$/i, ""); + if (!last) return null; + const owner = segments.slice(0, -1).join("/"); + return { host, path: `${owner}/${last}`, owner, name: last }; +} + +/** + * Hosts a mirror's original URL may point at and still count as ours: the + * configured source, the API host it is reached through (GitHub Enterprise + * serves clones and the API from one host, github.com from api.github.com), + * and the host of every clone URL already stored, which covers setups where + * the clone host differs from the nominal source. + */ +export function knownSourceHosts({ + sourceUrl, + apiUrl, + cloneUrls, +}: { + sourceUrl: string; + apiUrl?: string | null; + cloneUrls: Iterable; +}): Set { + const hosts = new Set(); + const add = (host: string | null | undefined) => { + const value = (host ?? "").trim().toLowerCase(); + if (value) hosts.add(value); + }; + + add(sourceHostOf(sourceUrl)); + + if (apiUrl) { + const apiHost = sourceHostOf(apiUrl); + add(apiHost); + if (apiHost.startsWith("api.")) add(apiHost.slice(4)); + } + + for (const cloneUrl of cloneUrls) { + add(parseRepoUrl(cloneUrl)?.host); + } + + return hosts; +} + +function cloneKey(url: string | null | undefined): string | null { + const parsed = parseRepoUrl(url); + return parsed ? `${parsed.host}/${parsed.path.toLowerCase()}` : null; +} + +/** + * Sort the destination listing against the database rows. + * + * A destination repository is ours when it is a mirror and its original URL + * points at a known source host. It is tracked when a row records it as the + * mirrored location, stores the same clone URL, or carries the same source + * path as its full name. Anything else that is ours is untracked. Native + * repositories and mirrors of other hosts are reported, never touched. + */ +export function classifyDestinationRepos({ + destinationRepos, + rows, + knownHosts, +}: { + destinationRepos: DestinationRepo[]; + rows: TrackedRepositoryRow[]; + knownHosts: Set; +}): ClassifiedDestination { + const byLocation = new Map(); + const byCloneKey = new Map(); + const byFullName = new Map(); + + for (const row of rows) { + const location = (row.mirroredLocation ?? "").trim().toLowerCase(); + if (location && !byLocation.has(location)) byLocation.set(location, row); + const key = cloneKey(row.cloneUrl); + if (key && !byCloneKey.has(key)) byCloneKey.set(key, row); + const fullName = row.fullName.trim().toLowerCase(); + if (fullName && !byFullName.has(fullName)) byFullName.set(fullName, row); + } + + const untracked: DestinationRepo[] = []; + const notManaged: NotManagedRepo[] = []; + const trackedLocations = new Set(); + const matchedRowIds = new Set(); + + for (const repo of destinationRepos) { + const location = repo.fullName.toLowerCase(); + + if (!repo.mirror) { + notManaged.push({ location: repo.fullName, reason: "not a mirror" }); + continue; + } + + const origin = parseRepoUrl(repo.originalUrl); + if (!origin) { + notManaged.push({ location: repo.fullName, reason: "mirror without a source URL" }); + continue; + } + + if (!knownHosts.has(origin.host)) { + notManaged.push({ location: repo.fullName, reason: `mirror of ${origin.host}` }); + continue; + } + + const row = + byLocation.get(location) ?? + byCloneKey.get(`${origin.host}/${origin.path.toLowerCase()}`) ?? + byFullName.get(origin.path.toLowerCase()); + + if (row) { + trackedLocations.add(location); + matchedRowIds.add(row.id); + } else { + untracked.push(repo); + } + } + + const unmatchedMirroredRows = rows.filter( + (row) => MIRRORED_STATUSES.has(row.status) && !matchedRowIds.has(row.id) + ); + + return { untracked, notManaged, trackedLocations, matchedRowIds, unmatchedMirroredRows }; +} + +/** + * The `owner/name` a row's mirror should live at, from the recorded location + * when there is one, otherwise from the owner the strategy resolves. + */ +export function expectedLocation( + row: Pick, + resolvedOwner: string +): { owner: string; name: string; location: string } { + const recorded = (row.mirroredLocation ?? "").trim(); + if (recorded.includes("/")) { + const slash = recorded.indexOf("/"); + const owner = recorded.slice(0, slash).trim(); + const name = recorded.slice(slash + 1).trim(); + if (owner && name) return { owner, name, location: `${owner}/${name}` }; + } + const owner = resolvedOwner.trim(); + return { owner, name: row.name, location: `${owner}/${row.name}` }; +} + +/** + * Destination owners worth listing: the configured accounts and every owner + * the database already points at. Deduplicated without regard to case, + * keeping the first spelling seen. + */ +export function collectDestinationOwners(candidates: Iterable): string[] { + const seen = new Set(); + const owners: string[] = []; + for (const candidate of candidates) { + const value = (candidate ?? "").trim(); + if (!value || value.includes("/")) continue; + const key = value.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + owners.push(value); + } + return owners; +} diff --git a/src/pages/api/cleanup/reconcile.ts b/src/pages/api/cleanup/reconcile.ts new file mode 100644 index 0000000..80f56c0 --- /dev/null +++ b/src/pages/api/cleanup/reconcile.ts @@ -0,0 +1,82 @@ +import type { APIRoute } from "astro"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { db, configs } from "@/lib/db"; +import type { Config } from "@/types/config"; +import { requireAuthenticatedUserId } from "@/lib/auth-guards"; +import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; +import { reconcileDestination } from "@/lib/destination-reconcile-service"; + +const bodySchema = z.object({ + dryRun: z.boolean().optional().default(true), + adoptUntracked: z.boolean().optional().default(false), + resetMissing: z.boolean().optional().default(false), +}); + +/** + * Compare the destination with the repository database (issue #284). + * + * Always computes the report. With `dryRun: false`, `adoptUntracked` creates + * rows for mirrors the database does not know about and `resetMissing` + * sends rows whose mirror is gone back to `imported`. Nothing is deleted or + * archived here; the cleanup service keeps its own rules. + */ +export const POST: APIRoute = async ({ request, locals }) => { + try { + const authResult = await requireAuthenticatedUserId({ request, locals }); + if ("response" in authResult) return authResult.response; + const userId = authResult.userId; + + const raw = await request.text(); + let parsedBody: unknown = {}; + if (raw.trim()) { + try { + parsedBody = JSON.parse(raw); + } catch { + return jsonResponse({ + data: { success: false, error: "Body must be JSON" }, + status: 400, + }); + } + } + const parsed = bodySchema.safeParse(parsedBody); + if (!parsed.success) { + return jsonResponse({ + data: { success: false, error: "dryRun, adoptUntracked and resetMissing must be booleans" }, + status: 400, + }); + } + + const [config] = await db + .select() + .from(configs) + .where(and(eq(configs.userId, userId), eq(configs.isActive, true))) + .limit(1); + + if (!config) { + return jsonResponse({ + data: { success: false, error: "No active configuration found" }, + status: 404, + }); + } + + if (!config.giteaConfig?.url || !config.giteaConfig?.token || !config.giteaConfig?.defaultOwner) { + return jsonResponse({ + data: { + success: false, + error: "Configure the destination URL, username and token before reconciling", + }, + status: 400, + }); + } + + const result = await reconcileDestination(config as unknown as Config, parsed.data); + + return jsonResponse({ + data: { success: true, ...result }, + status: 200, + }); + } catch (error) { + return createSecureErrorResponse(error, "destination reconcile", 500); + } +}; diff --git a/tests/e2e/07-reconcile.spec.ts b/tests/e2e/07-reconcile.spec.ts new file mode 100644 index 0000000..21cfed1 --- /dev/null +++ b/tests/e2e/07-reconcile.spec.ts @@ -0,0 +1,124 @@ +/** + * 07 – Reconcile with the destination (issue #284). + * + * Forgets a mirrored repository in the app database, checks that reconcile + * reports the Gitea mirror as untracked and adopts it back, then deletes a + * mirror on Gitea directly, checks that reconcile reports the row as + * missing and resets it for the next mirror run. + */ + +import { test, expect } from "@playwright/test"; +import { APP_URL, GITEA_URL, GiteaAPI, getAppSessionCookies } from "./helpers"; + +const RECONCILE_URL = `${APP_URL}/api/cleanup/reconcile`; + +async function listTracked(request: any, cookies: string): Promise { + const resp = await request.get(`${APP_URL}/api/github/repositories`, { + headers: { Cookie: cookies }, + failOnStatusCode: false, + }); + expect(resp.status(), await resp.text()).toBe(200); + const body = await resp.json(); + return body.repositories ?? []; +} + +async function reconcile(request: any, cookies: string, body: Record) { + const resp = await request.post(RECONCILE_URL, { + headers: { Cookie: cookies, "Content-Type": "application/json" }, + data: body, + failOnStatusCode: false, + }); + expect(resp.status(), await resp.text()).toBe(200); + return resp.json(); +} + +test.describe("E2E: reconcile with the destination", () => { + let cookies = ""; + let giteaApi: GiteaAPI; + let forgotten: any = null; + let removed: any = null; + + test.beforeAll(async () => { + giteaApi = new GiteaAPI(GITEA_URL); + }); + + test.afterAll(async () => { + await giteaApi.dispose(); + }); + + test("Step 1: a dry run answers with the report shape", async ({ request }) => { + cookies = await getAppSessionCookies(request); + const result = await reconcile(request, cookies, {}); + expect(result.dryRun).toBe(true); + expect(result.applied).toBeNull(); + expect(Array.isArray(result.report.untracked)).toBeTruthy(); + expect(Array.isArray(result.report.missing)).toBeTruthy(); + expect(Array.isArray(result.report.notManaged)).toBeTruthy(); + expect(result.report.scannedOwners.length).toBeGreaterThan(0); + console.log( + `[Reconcile] ${result.report.totalOnDestination} on destination, ${result.report.healthyCount} healthy, ` + + `${result.report.untracked.length} untracked, ${result.report.missing.length} missing, ${result.report.notManaged.length} not managed`, + ); + }); + + test("Step 2: a mirror the database forgot is reported as untracked", async ({ request }) => { + const mirrored = (await listTracked(request, cookies)).filter( + (r: any) => ["mirrored", "synced"].includes(r.status) && r.mirroredLocation, + ); + expect(mirrored.length, "spec 02 must have mirrored repositories").toBeGreaterThanOrEqual(2); + forgotten = mirrored[0]; + removed = mirrored[1]; + + const del = await request.delete(`${APP_URL}/api/repositories`, { + headers: { Cookie: cookies, "Content-Type": "application/json" }, + data: { ids: [forgotten.id] }, + failOnStatusCode: false, + }); + expect(del.status(), await del.text()).toBe(200); + console.log(`[Reconcile] Forgot ${forgotten.fullName} (was at ${forgotten.mirroredLocation})`); + + const result = await reconcile(request, cookies, { dryRun: true }); + const locations = result.report.untracked.map((r: any) => r.location.toLowerCase()); + expect(locations).toContain(forgotten.mirroredLocation.toLowerCase()); + }); + + test("Step 3: adopting brings the row back as mirrored", async ({ request }) => { + const result = await reconcile(request, cookies, { dryRun: false, adoptUntracked: true }); + expect(result.dryRun).toBe(false); + expect(result.applied.adopted).toBeGreaterThanOrEqual(1); + + const rows = await listTracked(request, cookies); + const back = rows.find( + (r: any) => r.fullName.toLowerCase() === forgotten.fullName.toLowerCase(), + ); + expect(back, `expected ${forgotten.fullName} to be tracked again`).toBeTruthy(); + expect(back.status).toBe("mirrored"); + expect(back.mirroredLocation.toLowerCase()).toBe(forgotten.mirroredLocation.toLowerCase()); + + const again = await reconcile(request, cookies, { dryRun: true }); + const locations = again.report.untracked.map((r: any) => r.location.toLowerCase()); + expect(locations).not.toContain(forgotten.mirroredLocation.toLowerCase()); + }); + + test("Step 4: a mirror deleted on the destination is reported as missing and can be reset", async ({ + request, + }) => { + const [owner, name] = removed.mirroredLocation.split("/"); + expect(await giteaApi.deleteRepo(owner, name)).toBeTruthy(); + expect(await giteaApi.getRepo(owner, name)).toBeNull(); + console.log(`[Reconcile] Deleted ${removed.mirroredLocation} on Gitea`); + + const dry = await reconcile(request, cookies, { dryRun: true }); + const missingIds = dry.report.missing.map((r: any) => r.id); + expect(missingIds).toContain(removed.id); + + const applied = await reconcile(request, cookies, { dryRun: false, resetMissing: true }); + expect(applied.applied.reset).toBeGreaterThanOrEqual(1); + + const rows = await listTracked(request, cookies); + const row = rows.find((r: any) => r.id === removed.id); + expect(row).toBeTruthy(); + expect(row.status).toBe("imported"); + expect(row.mirroredLocation ?? "").toBe(""); + }); +}); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 3fc2bb3..e7594a7 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -366,6 +366,17 @@ export class GiteaAPI { return resp.ok() || resp.status() === 200; } + /** Delete a repo on Gitea directly (the app is not told). */ + async deleteRepo(owner: string, name: string): Promise { + const ctx = await this.getCtx(); + const token = await this.createToken(); + const resp = await ctx.delete(`/api/v1/repos/${owner}/${name}`, { + headers: { Authorization: `token ${token}` }, + failOnStatusCode: false, + }); + return resp.ok() || resp.status() === 404; + } + getTokenValue(): string { return this.token; }