mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 03:26:44 +03:00
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:
17
CHANGELOG.md
17
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
|
- 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 (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
|
## [3.2.6] - 2025-08-09
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,23 +2225,40 @@ export async function archiveGiteaRepo(
|
|||||||
? currentDesc
|
? currentDesc
|
||||||
: currentDesc + archiveNotice;
|
: currentDesc + archiveNotice;
|
||||||
|
|
||||||
const renameResponse = await httpPatch(
|
try {
|
||||||
`${client.url}/api/v1/repos/${owner}/${repo}`,
|
await httpPatch(
|
||||||
{
|
`${client.url}/api/v1/repos/${owner}/${repo}`,
|
||||||
name: archivedName,
|
{
|
||||||
description: newDescription,
|
name: archivedName,
|
||||||
},
|
description: newDescription,
|
||||||
{
|
},
|
||||||
Authorization: `token ${client.token}`,
|
{
|
||||||
'Content-Type': 'application/json',
|
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})`);
|
console.log(`[Archive] Successfully marked mirror repository ${owner}/${repo} as archived (renamed to ${archivedName})`);
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -402,4 +422,4 @@ export async function triggerRepositoryCleanup(userId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
return runRepositoryCleanup(config);
|
return runRepositoryCleanup(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
10
src/types/repository-status.test.ts
Normal file
10
src/types/repository-status.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user