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

This commit is contained in:
Arunavo Ray
2025-09-14 07:53:36 +05:30
parent f3aae2ec94
commit 6ce70bb5bf
7 changed files with 107 additions and 31 deletions

View File

@@ -58,6 +58,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Updated README with new features - Updated README with new features
- Enhanced CLAUDE.md with repository status definitions - 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-<name>`), 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 (besteffort) 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 ## [3.2.6] - 2025-08-09
### Fixed ### Fixed

View File

@@ -1,7 +1,7 @@
{ {
"name": "gitea-mirror", "name": "gitea-mirror",
"type": "module", "type": "module",
"version": "3.7.0", "version": "3.7.1",
"engines": { "engines": {
"bun": ">=1.2.9" "bun": ">=1.2.9"
}, },

View File

@@ -152,6 +152,7 @@ export const repositorySchema = z.object({
"deleted", "deleted",
"syncing", "syncing",
"synced", "synced",
"archived",
]) ])
.default("imported"), .default("imported"),
lastMirrored: z.coerce.date().optional().nullable(), lastMirrored: z.coerce.date().optional().nullable(),
@@ -181,6 +182,7 @@ export const mirrorJobSchema = z.object({
"deleted", "deleted",
"syncing", "syncing",
"synced", "synced",
"archived",
]) ])
.default("imported"), .default("imported"),
message: z.string(), message: z.string(),

View File

@@ -2176,6 +2176,14 @@ export async function archiveGiteaRepo(
repo: string repo: string
): Promise<void> { ): Promise<void> {
try { 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 // First, check if this is a mirror repository
const repoResponse = await httpGet( const repoResponse = await httpGet(
`${client.url}/api/v1/repos/${owner}/${repo}`, `${client.url}/api/v1/repos/${owner}/${repo}`,
@@ -2207,7 +2215,8 @@ export async function archiveGiteaRepo(
return; 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 currentDesc = repoResponse.data.description || '';
const archiveNotice = `\n\n⚠ ARCHIVED: Original GitHub repository no longer exists. Preserved as backup on ${new Date().toISOString()}`; const archiveNotice = `\n\n⚠ ARCHIVED: Original GitHub repository no longer exists. Preserved as backup on ${new Date().toISOString()}`;
@@ -2216,7 +2225,8 @@ export async function archiveGiteaRepo(
? currentDesc ? currentDesc
: currentDesc + archiveNotice; : currentDesc + archiveNotice;
const renameResponse = await httpPatch( try {
await httpPatch(
`${client.url}/api/v1/repos/${owner}/${repo}`, `${client.url}/api/v1/repos/${owner}/${repo}`,
{ {
name: archivedName, name: archivedName,
@@ -2227,13 +2237,29 @@ export async function archiveGiteaRepo(
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
); );
} catch (e: any) {
if (renameResponse.status >= 400) { // If rename fails (e.g., 422 AlphaDashDot or name conflict), attempt a timestamped fallback
// If rename fails, log but don't throw - data is still preserved const ts = new Date().toISOString().replace(/[-:T.]/g, "").slice(0, 14);
console.error(`[Archive] Failed to rename mirror repository ${owner}/${repo}: ${renameResponse.status}`); 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`); console.log(`[Archive] Repository ${owner}/${repo} remains accessible but not marked as archived`);
return; return;
} }
}
console.log(`[Archive] Successfully marked mirror repository ${owner}/${repo} as archived (renamed to ${archivedName})`); console.log(`[Archive] Successfully marked mirror repository ${owner}/${repo} as archived (renamed to ${archivedName})`);

View File

@@ -7,7 +7,7 @@
import { db, configs, repositories } from '@/lib/db'; import { db, configs, repositories } from '@/lib/db';
import { eq, and, or, sql, not, inArray } from 'drizzle-orm'; import { eq, and, or, sql, not, inArray } from 'drizzle-orm';
import { createGitHubClient, getGithubRepositories, getGithubStarredRepositories } from '@/lib/github'; 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 { getDecryptedGitHubToken, getDecryptedGiteaToken } from '@/lib/utils/config-encryption';
import { publishEvent } from '@/lib/events'; import { publishEvent } from '@/lib/events';
@@ -109,26 +109,46 @@ async function handleOrphanedRepository(
const giteaToken = getDecryptedGiteaToken(config); const giteaToken = getDecryptedGiteaToken(config);
const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken); const giteaClient = createGiteaClient(config.giteaConfig.url, giteaToken);
// Determine the Gitea owner and repo name // Determine the Gitea owner and repo name more robustly
const mirroredLocation = repo.mirroredLocation || ''; const mirroredLocation = (repo.mirroredLocation || '').trim();
let giteaOwner = repo.owner; let giteaOwner: string;
let giteaRepoName = repo.name; let giteaRepoName: string;
if (mirroredLocation) { if (mirroredLocation && mirroredLocation.includes('/')) {
const parts = mirroredLocation.split('/'); const [ownerPart, namePart] = mirroredLocation.split('/');
if (parts.length >= 2) { giteaOwner = ownerPart;
giteaOwner = parts[parts.length - 2]; giteaRepoName = namePart;
giteaRepoName = parts[parts.length - 1]; } 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') { if (action === 'archive') {
console.log(`[Repository Cleanup] Archiving orphaned repository ${repoFullName} in Gitea`); 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); await archiveGiteaRepo(giteaClient, giteaOwner, giteaRepoName);
// Update database status // Update database status
await db.update(repositories).set({ await db.update(repositories).set({
status: 'archived', status: 'archived',
isArchived: true,
errorMessage: 'Repository archived - no longer in GitHub', errorMessage: 'Repository archived - no longer in GitHub',
updatedAt: new Date(), updatedAt: new Date(),
}).where(eq(repositories.id, repo.id)); }).where(eq(repositories.id, repo.id));

View File

@@ -12,6 +12,7 @@ export const repoStatusEnum = z.enum([
"deleted", "deleted",
"syncing", "syncing",
"synced", "synced",
"archived",
]); ]);
export type RepoStatus = z.infer<typeof repoStatusEnum>; export type RepoStatus = z.infer<typeof repoStatusEnum>;

View File

@@ -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);
});
});