From 6ce70bb5bff75bcd9bfca75161caa425a8e740ff Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Sun, 14 Sep 2025 07:53:36 +0530 Subject: [PATCH] chore(version): bump to 3.7.1\n\ncleanup: attempt fix for orphaned repo archiving (refs #84)\n- Sanitize mirror rename to satisfy AlphaDashDot; timestamped fallback\n- Resolve Gitea owner robustly via mirroredLocation/strategy; verify presence\n- Add 'archived' status to Zod enums; set isArchived on archive\n- Update CHANGELOG entry without closing keyword --- CHANGELOG.md | 17 ++++++++ package.json | 2 +- src/lib/db/schema.ts | 2 + src/lib/gitea.ts | 60 +++++++++++++++++++-------- src/lib/repository-cleanup-service.ts | 46 ++++++++++++++------ src/types/Repository.ts | 1 + src/types/repository-status.test.ts | 10 +++++ 7 files changed, 107 insertions(+), 31 deletions(-) create mode 100644 src/types/repository-status.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b888874..22cfb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated README with new features - Enhanced CLAUDE.md with repository status definitions +## [3.7.1] - 2025-09-14 + +### Fixed +- Cleanup archiving for mirror repositories now works reliably (refs #84; awaiting user confirmation). + - Gitea rejects names violating the AlphaDashDot rule; archiving a mirror now uses a sanitized rename strategy (`archived-`), with a timestamped fallback on conflicts or validation errors. + - Owner resolution during cleanup no longer uses the GitHub owner by mistake. It prefers `mirroredLocation`, falls back to computed Gitea owner via configuration, and verifies location with a presence check to avoid `GetUserByName` 404s. +- Repositories UI crash resolved when cleanup marked repos as archived. + - Added `"archived"` to repository/job status enums, fixing Zod validation errors on the Repositories page. + +### Changed +- Archiving logic for mirror repos is non-destructive by design: data is preserved, repo is renamed with an archive marker, and mirror interval is reduced (best‑effort) to minimize sync attempts. +- Cleanup service updates DB to `status: "archived"` and `isArchived: true` on successful archive path. + +### Notes +- This release addresses the scenario where a GitHub source disappears (deleted/banned), ensuring Gitea backups are preserved even when using `CLEANUP_DELETE_IF_NOT_IN_GITHUB=true` with `CLEANUP_ORPHANED_REPO_ACTION=archive`. +- No database migration required. + ## [3.2.6] - 2025-08-09 ### Fixed diff --git a/package.json b/package.json index 375f4f1..fb74433 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gitea-mirror", "type": "module", - "version": "3.7.0", + "version": "3.7.1", "engines": { "bun": ">=1.2.9" }, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 9f87831..8e0b085 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -152,6 +152,7 @@ export const repositorySchema = z.object({ "deleted", "syncing", "synced", + "archived", ]) .default("imported"), lastMirrored: z.coerce.date().optional().nullable(), @@ -181,6 +182,7 @@ export const mirrorJobSchema = z.object({ "deleted", "syncing", "synced", + "archived", ]) .default("imported"), message: z.string(), diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index 925f12e..e9c7137 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -2176,6 +2176,14 @@ export async function archiveGiteaRepo( repo: string ): Promise { try { + // Helper: sanitize to Gitea's AlphaDashDot rule + const sanitizeRepoNameAlphaDashDot = (name: string): string => { + // Replace anything that's not [A-Za-z0-9.-] with '-' + const base = name.replace(/[^A-Za-z0-9.-]+/g, "-").replace(/-+/g, "-"); + // Trim leading/trailing separators and dots for safety + return base.replace(/^[.-]+/, "").replace(/[.-]+$/, ""); + }; + // First, check if this is a mirror repository const repoResponse = await httpGet( `${client.url}/api/v1/repos/${owner}/${repo}`, @@ -2207,7 +2215,8 @@ export async function archiveGiteaRepo( return; } - const archivedName = `[ARCHIVED] ${currentName}`; + // Use a safe prefix and sanitize the name to satisfy AlphaDashDot rule + let archivedName = `archived-${sanitizeRepoNameAlphaDashDot(currentName)}`; const currentDesc = repoResponse.data.description || ''; const archiveNotice = `\n\n⚠️ ARCHIVED: Original GitHub repository no longer exists. Preserved as backup on ${new Date().toISOString()}`; @@ -2216,23 +2225,40 @@ export async function archiveGiteaRepo( ? currentDesc : currentDesc + archiveNotice; - const renameResponse = await httpPatch( - `${client.url}/api/v1/repos/${owner}/${repo}`, - { - name: archivedName, - description: newDescription, - }, - { - Authorization: `token ${client.token}`, - 'Content-Type': 'application/json', + try { + await httpPatch( + `${client.url}/api/v1/repos/${owner}/${repo}`, + { + name: archivedName, + description: newDescription, + }, + { + Authorization: `token ${client.token}`, + 'Content-Type': 'application/json', + } + ); + } catch (e: any) { + // If rename fails (e.g., 422 AlphaDashDot or name conflict), attempt a timestamped fallback + const ts = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14); + archivedName = `archived-${ts}-${sanitizeRepoNameAlphaDashDot(currentName)}`; + try { + await httpPatch( + `${client.url}/api/v1/repos/${owner}/${repo}`, + { + name: archivedName, + description: newDescription, + }, + { + Authorization: `token ${client.token}`, + 'Content-Type': 'application/json', + } + ); + } catch (e2) { + // If this also fails, log but don't throw - data remains preserved + console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}:`, e2); + console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`); + return; } - ); - - if (renameResponse.status >= 400) { - // If rename fails, log but don't throw - data is still preserved - console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}: ${renameResponse.status}`); - console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`); - return; } console.log(`[Archive] Successfully marked mirror repository ${owner}/${repo} as archived (renamed to ${archivedName})`); diff --git a/src/lib/repository-cleanup-service.ts b/src/lib/repository-cleanup-service.ts index 10d2bb7..da78f17 100644 --- a/src/lib/repository-cleanup-service.ts +++ b/src/lib/repository-cleanup-service.ts @@ -7,7 +7,7 @@ import { db, configs, repositories } from '@/lib/db'; import { eq, and, or, sql, not, inArray } from 'drizzle-orm'; import { createGitHubClient, getGithubRepositories, getGithubStarredRepositories } from '@/lib/github'; -import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo } from '@/lib/gitea'; +import { createGiteaClient, deleteGiteaRepo, archiveGiteaRepo, getGiteaRepoOwnerAsync, checkRepoLocation } from '@/lib/gitea'; import { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption'; import { publishEvent } from '@/lib/events'; @@ -109,26 +109,46 @@ async function handleOrphanedRepository( const giteaToken = getDecryptedGiteaToken(config); const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken); - // Determine the Gitea owner and repo name - const mirroredLocation = repo.mirroredLocation || ''; - let giteaOwner = repo.owner; - let giteaRepoName = repo.name; - - if (mirroredLocation) { - const parts = mirroredLocation.split('/'); - if (parts.length >= 2) { - giteaOwner = parts[parts.length - 2]; - giteaRepoName = parts[parts.length - 1]; - } + // Determine the Gitea owner and repo name more robustly + const mirroredLocation = (repo.mirroredLocation || '').trim(); + let giteaOwner: string; + let giteaRepoName: string; + + if (mirroredLocation && mirroredLocation.includes('/')) { + const [ownerPart, namePart] = mirroredLocation.split('/'); + giteaOwner = ownerPart; + giteaRepoName = namePart; + } else { + // Fall back to expected owner based on config and repo flags (starred/org overrides) + giteaOwner = await getGiteaRepoOwnerAsync({ config, repository: repo }); + giteaRepoName = repo.name; } + + // Normalize owner casing to avoid GetUserByName issues on some Gitea setups + giteaOwner = giteaOwner.trim(); if (action === 'archive') { console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`); + // Best-effort check to validate actual location; falls back gracefully + try { + const { present, actualOwner } = await checkRepoLocation({ + config, + repository: repo, + expectedOwner: giteaOwner, + }); + if (present) { + giteaOwner = actualOwner; + } + } catch { + // Non-fatal; continue with best guess + } + await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName); // Update database status await db.update(repositories).set({ status: 'archived', + isArchived: true, errorMessage: 'Repository archived - no longer in GitHub', updatedAt: new Date(), }).where(eq(repositories.id, repo.id)); @@ -402,4 +422,4 @@ export async function triggerRepositoryCleanup(userId: string): Promise<{ } return runRepositoryCleanup(config); -} \ No newline at end of file +} diff --git a/src/types/Repository.ts b/src/types/Repository.ts index da8bce8..7b98625 100644 --- a/src/types/Repository.ts +++ b/src/types/Repository.ts @@ -12,6 +12,7 @@ export const repoStatusEnum = z.enum([ "deleted", "syncing", "synced", + "archived", ]); export type RepoStatus = z.infer; diff --git a/src/types/repository-status.test.ts b/src/types/repository-status.test.ts new file mode 100644 index 0000000..acd5e0b --- /dev/null +++ b/src/types/repository-status.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "bun:test"; +import { repoStatusEnum } from "@/types/Repository"; + +describe("repoStatusEnum", () => { + it("includes archived status", () => { + const res = repoStatusEnum.safeParse("archived"); + expect(res.success).toBe(true); + }); +}); +