fix sync target resolution for mirrored repos

This commit is contained in:
Arunavo Ray
2026-03-25 19:29:46 +05:30
parent 8346748f5a
commit 2da0277a68
4 changed files with 176 additions and 27 deletions

View File

@@ -555,6 +555,63 @@ describe("Enhanced Gitea Operations", () => {
expect(releaseCall.octokit).toBeDefined();
});
test("prefers recorded mirroredLocation when owner resolution changes", async () => {
mockGetGiteaRepoOwnerAsync.mockImplementation(() => Promise.resolve("ceph"));
const config: Partial<Config> = {
userId: "user123",
githubConfig: {
username: "testuser",
token: "github-token",
privateRepositories: false,
mirrorStarred: true,
},
giteaConfig: {
url: "https://gitea.example.com",
token: "encrypted-token",
defaultOwner: "testuser",
mirrorReleases: true,
},
};
const repository: Repository = {
id: "repo789",
name: "test-repo",
fullName: "ceph/test-repo",
owner: "ceph",
cloneUrl: "https://github.com/ceph/test-repo.git",
isPrivate: false,
isStarred: true,
status: repoStatusEnum.parse("mirrored"),
visibility: "public",
userId: "user123",
mirroredLocation: "starred/test-repo",
createdAt: new Date(),
updatedAt: new Date(),
};
const result = await syncGiteaRepoEnhanced(
{ config, repository },
{
getGiteaRepoOwnerAsync: mockGetGiteaRepoOwnerAsync,
mirrorGitHubReleasesToGitea: mockMirrorGitHubReleasesToGitea,
mirrorGitRepoIssuesToGitea: mockMirrorGitRepoIssuesToGitea,
mirrorGitRepoPullRequestsToGitea: mockMirrorGitRepoPullRequestsToGitea,
mirrorGitRepoLabelsToGitea: mockMirrorGitRepoLabelsToGitea,
mirrorGitRepoMilestonesToGitea: mockMirrorGitRepoMilestonesToGitea,
}
);
expect(result).toEqual({ success: true });
const mirrorSyncCalls = mockHttpPost.mock.calls.filter((call) =>
String(call[0]).includes("/mirror-sync")
);
expect(mirrorSyncCalls).toHaveLength(1);
expect(String(mirrorSyncCalls[0][0])).toContain("/api/v1/repos/starred/test-repo/mirror-sync");
expect(String(mirrorSyncCalls[0][0])).not.toContain("/api/v1/repos/ceph/test-repo/mirror-sync");
});
test("blocks sync when pre-sync snapshot fails and blocking is enabled", async () => {
mockShouldCreatePreSyncBackup = true;
mockShouldBlockSyncOnBackupFailure = true;

View File

@@ -52,6 +52,41 @@ interface GiteaRepoInfo {
private: boolean;
}
interface SyncTargetCandidate {
owner: string;
repoName: string;
}
function parseMirroredLocation(location?: string | null): SyncTargetCandidate | null {
if (!location) return null;
const trimmed = location.trim();
if (!trimmed) return null;
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return null;
const owner = trimmed.slice(0, slashIndex).trim();
const repoName = trimmed.slice(slashIndex + 1).trim();
if (!owner || !repoName) return null;
return { owner, repoName };
}
function dedupeSyncTargets(targets: SyncTargetCandidate[]): SyncTargetCandidate[] {
const seen = new Set<string>();
const deduped: SyncTargetCandidate[] = [];
for (const target of targets) {
const key = `${target.owner}/${target.repoName}`.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
deduped.push(target);
}
return deduped;
}
/**
* Check if a repository exists in Gitea and return its details
*/
@@ -285,19 +320,78 @@ export async function syncGiteaRepoEnhanced({
})
.where(eq(repositories.id, repository.id!));
// Get the expected owner
// Resolve sync target in a backward-compatible order:
// 1) recorded mirroredLocation (actual historical mirror location)
// 2) owner derived from current strategy/config
const dependencies = deps ?? (await import("./gitea"));
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const expectedOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
const recordedTarget = parseMirroredLocation(repository.mirroredLocation);
const candidateTargets = dedupeSyncTargets([
...(recordedTarget ? [recordedTarget] : []),
{ owner: expectedOwner, repoName: repository.name },
]);
// Check if repo exists and get its info
const repoInfo = await getGiteaRepoInfo({
config,
owner: repoOwner,
repoName: repository.name,
});
let repoOwner = expectedOwner;
let repoName = repository.name;
let repoInfo: GiteaRepoInfo | null = null;
let firstNonMirrorTarget: SyncTargetCandidate | null = null;
for (const target of candidateTargets) {
const candidateInfo = await getGiteaRepoInfo({
config,
owner: target.owner,
repoName: target.repoName,
});
if (!candidateInfo) {
continue;
}
if (!candidateInfo.mirror) {
if (!firstNonMirrorTarget) {
firstNonMirrorTarget = target;
}
continue;
}
repoOwner = target.owner;
repoName = target.repoName;
repoInfo = candidateInfo;
break;
}
if (!repoInfo) {
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
if (firstNonMirrorTarget) {
console.warn(
`[Sync] Repository ${repository.name} exists at ${firstNonMirrorTarget.owner}/${firstNonMirrorTarget.repoName} but is not configured as a mirror`
);
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: "Repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
})
.where(eq(repositories.id, repository.id!));
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Cannot sync ${repository.name}: Not a mirror repository`,
details: `Repository ${repository.name} exists in Gitea but is not configured as a mirror. You may need to delete and recreate it as a mirror, or manually configure it as a mirror in Gitea.`,
status: "failed",
});
throw new Error(`Repository ${repository.name} is not a mirror. Cannot sync.`);
}
throw new Error(
`Repository ${repository.name} not found in Gitea. Tried locations: ${candidateTargets
.map((t) => `${t.owner}/${t.repoName}`)
.join(", ")}`
);
}
// Check if it's a mirror repository
@@ -342,7 +436,7 @@ export async function syncGiteaRepoEnhanced({
giteaUrl: config.giteaConfig.url,
giteaToken: decryptedConfig.giteaConfig.token,
giteaOwner: repoOwner,
giteaRepo: repository.name,
giteaRepo: repoName,
octokit: fpOctokit,
githubOwner: repository.owner,
githubRepo: repository.name,
@@ -407,13 +501,13 @@ export async function syncGiteaRepoEnhanced({
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
const cloneUrl =
repoInfo.clone_url ||
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repoName}.git`;
try {
const backupResult = await createPreSyncBundleBackup({
config,
owner: repoOwner,
repoName: repository.name,
repoName,
cloneUrl,
force: true, // Strategy already decided to backup; skip legacy gate
});
@@ -464,22 +558,22 @@ export async function syncGiteaRepoEnhanced({
// Update mirror interval if needed
if (config.giteaConfig?.mirrorInterval) {
try {
console.log(`[Sync] Updating mirror interval for ${repository.name} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}`;
console.log(`[Sync] Updating mirror interval for ${repoOwner}/${repoName} to ${config.giteaConfig.mirrorInterval}`);
const updateUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}`;
await httpPatch(updateUrl, {
mirror_interval: config.giteaConfig.mirrorInterval,
}, {
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
});
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
console.log(`[Sync] Successfully updated mirror interval for ${repoOwner}/${repoName}`);
} catch (updateError) {
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, updateError);
console.warn(`[Sync] Failed to update mirror interval for ${repoOwner}/${repoName}:`, updateError);
// Continue with sync even if interval update fails
}
}
// Perform the sync
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repository.name}/mirror-sync`;
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${repoOwner}/${repoName}/mirror-sync`;
try {
const response = await httpPost(apiUrl, undefined, {
@@ -536,7 +630,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.releases = true;
metadataUpdated = true;
@@ -568,7 +662,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.issues = true;
metadataState.components.labels = true;
@@ -601,7 +695,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.pullRequests = true;
metadataUpdated = true;
@@ -631,7 +725,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.labels = true;
metadataUpdated = true;
@@ -670,7 +764,7 @@ export async function syncGiteaRepoEnhanced({
octokit,
repository,
giteaOwner: repoOwner,
giteaRepoName: repository.name,
giteaRepoName: repoName,
});
metadataState.components.milestones = true;
metadataUpdated = true;
@@ -708,7 +802,7 @@ export async function syncGiteaRepoEnhanced({
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${repoOwner}/${repository.name}`,
mirroredLocation: `${repoOwner}/${repoName}`,
metadata: metadataUpdated
? serializeRepositoryMetadataState(metadataState)
: repository.metadata ?? null,
@@ -720,7 +814,7 @@ export async function syncGiteaRepoEnhanced({
repositoryId: repository.id,
repositoryName: repository.name,
message: `Sync requested for repository: ${repository.name}`,
details: `Mirror sync was requested for ${repository.name}.`,
details: `Mirror sync was requested for ${repoOwner}/${repoName}.`,
status: "synced",
});

View File

@@ -13,7 +13,6 @@ try {
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}

View File

@@ -28,7 +28,6 @@ try {
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}
try {
@@ -68,4 +67,4 @@ try {
<body>
<App page='dashboard' client:load />
</body>
</html>
</html>