mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-07 21:48:39 +03:00
* feat: smart force-push protection with backup strategies (#187) Replace blunt `backupBeforeSync` boolean with `backupStrategy` enum offering four modes: disabled, always, on-force-push (default), and block-on-force-push. This dramatically reduces backup storage for large mirror collections by only creating snapshots when force-pushes are actually detected. Detection works by comparing branch SHAs between Gitea and GitHub APIs before each sync — no git cloning required. Fail-open design ensures detection errors never block sync. Key changes: - Add force-push detection module (branch SHA comparison via APIs) - Add backup strategy resolver with backward-compat migration - Add pending-approval repo status with approve/dismiss UI + API - Add block-on-force-push mode requiring manual approval - Fix checkAncestry to only treat 404 as confirmed force-push (transient errors skip branch instead of false-positive blocking) - Fix approve-sync to bypass detection gate (skipForcePushDetection) - Fix backup execution to not be hard-gated by deprecated flag - Persist backupStrategy through config-mapper round-trip * fix: resolve four bugs in smart force-push protection P0: Approve flow re-blocks itself — approve-sync now calls syncGiteaRepoEnhanced with skipForcePushDetection: true so the detection+block gate is bypassed on approved syncs. P1: backupStrategy not persisted — added to both directions of the config-mapper. Don't inject a default in the mapper; let resolveBackupStrategy handle fallback so legacy backupBeforeSync still works for E2E tests and existing configs. P1: Backup hard-gated by deprecated backupBeforeSync — added force flag to createPreSyncBundleBackup; strategy-driven callers and approve-sync pass force: true to bypass the legacy guard. P1: checkAncestry false positives — now only returns false for 404/422 (confirmed force-push). Transient errors (rate limits, 500s) are rethrown so detectForcePush skips that branch (fail-open). * test(e2e): migrate backup tests from backupBeforeSync to backupStrategy Update E2E tests to use the new backupStrategy enum ("always", "disabled") instead of the deprecated backupBeforeSync boolean. * docs: add backup strategy UI screenshot * refactor(ui): move Destructive Update Protection to GitHub config tab Relocates the backup strategy section from GiteaConfigForm to GitHubConfigForm since it protects against GitHub-side force-pushes. Adds ShieldAlert icon to match other section header patterns. * docs: add force-push protection documentation and Beta badge Add docs/FORCE_PUSH_PROTECTION.md covering detection mechanism, backup strategies, API usage, and troubleshooting. Link it from README features list and support section. Mark the feature as Beta in the UI with an outline badge. * fix(ui): match Beta badge style to Git LFS badge
918 lines
31 KiB
TypeScript
918 lines
31 KiB
TypeScript
/**
|
|
* Enhanced Gitea operations with better error handling for starred repositories
|
|
* This module provides fixes for:
|
|
* 1. "Repository is not a mirror" errors
|
|
* 2. Duplicate organization constraint errors
|
|
* 3. Race conditions in parallel processing
|
|
*/
|
|
|
|
import type { Config } from "@/types/config";
|
|
import type { Repository } from "./db/schema";
|
|
import { Octokit } from "@octokit/rest";
|
|
import { createMirrorJob } from "./helpers";
|
|
import { decryptConfigTokens } from "./utils/config-encryption";
|
|
import { httpPost, httpGet, httpPatch, HttpError } from "./http-client";
|
|
import { db, repositories } from "./db";
|
|
import { eq } from "drizzle-orm";
|
|
import { repoStatusEnum } from "@/types/Repository";
|
|
import {
|
|
createPreSyncBundleBackup,
|
|
shouldCreatePreSyncBackup,
|
|
shouldBlockSyncOnBackupFailure,
|
|
resolveBackupStrategy,
|
|
shouldBackupForStrategy,
|
|
shouldBlockSyncForStrategy,
|
|
strategyNeedsDetection,
|
|
} from "./repo-backup";
|
|
import { detectForcePush } from "./utils/force-push-detection";
|
|
import {
|
|
parseRepositoryMetadataState,
|
|
serializeRepositoryMetadataState,
|
|
} from "./metadata-state";
|
|
|
|
type SyncDependencies = {
|
|
getGiteaRepoOwnerAsync: typeof import("./gitea")["getGiteaRepoOwnerAsync"];
|
|
mirrorGitHubReleasesToGitea: typeof import("./gitea")["mirrorGitHubReleasesToGitea"];
|
|
mirrorGitRepoIssuesToGitea: typeof import("./gitea")["mirrorGitRepoIssuesToGitea"];
|
|
mirrorGitRepoPullRequestsToGitea: typeof import("./gitea")["mirrorGitRepoPullRequestsToGitea"];
|
|
mirrorGitRepoLabelsToGitea: typeof import("./gitea")["mirrorGitRepoLabelsToGitea"];
|
|
mirrorGitRepoMilestonesToGitea: typeof import("./gitea")["mirrorGitRepoMilestonesToGitea"];
|
|
};
|
|
|
|
/**
|
|
* Enhanced repository information including mirror status
|
|
*/
|
|
interface GiteaRepoInfo {
|
|
id: number;
|
|
name: string;
|
|
owner: { login: string } | string;
|
|
mirror: boolean;
|
|
mirror_interval?: string;
|
|
clone_url?: string;
|
|
private: boolean;
|
|
}
|
|
|
|
/**
|
|
* Check if a repository exists in Gitea and return its details
|
|
*/
|
|
export async function getGiteaRepoInfo({
|
|
config,
|
|
owner,
|
|
repoName,
|
|
}: {
|
|
config: Partial<Config>;
|
|
owner: string;
|
|
repoName: string;
|
|
}): Promise<GiteaRepoInfo | null> {
|
|
try {
|
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
|
throw new Error("Gitea config is required.");
|
|
}
|
|
|
|
const decryptedConfig = decryptConfigTokens(config as Config);
|
|
|
|
const response = await httpGet<GiteaRepoInfo>(
|
|
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
|
{
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
}
|
|
);
|
|
|
|
return response.data;
|
|
} catch (error) {
|
|
if (error instanceof HttpError && error.status === 404) {
|
|
return null; // Repository doesn't exist
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enhanced organization creation with better error handling and retry logic
|
|
*/
|
|
export async function getOrCreateGiteaOrgEnhanced({
|
|
orgName,
|
|
orgId,
|
|
config,
|
|
maxRetries = 3,
|
|
retryDelay = 100,
|
|
}: {
|
|
orgId?: string;
|
|
orgName: string;
|
|
config: Partial<Config>;
|
|
maxRetries?: number;
|
|
retryDelay?: number;
|
|
}): Promise<number> {
|
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token || !config.userId) {
|
|
throw new Error("Gitea config is required.");
|
|
}
|
|
|
|
const decryptedConfig = decryptConfigTokens(config as Config);
|
|
|
|
// First, validate the user's authentication by getting their information
|
|
console.log(`[Org Creation] Validating user authentication before organization operations`);
|
|
try {
|
|
const userResponse = await httpGet(
|
|
`${config.giteaConfig.url}/api/v1/user`,
|
|
{
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
}
|
|
);
|
|
console.log(`[Org Creation] Authenticated as user: ${userResponse.data.username || userResponse.data.login} (ID: ${userResponse.data.id})`);
|
|
} catch (authError) {
|
|
if (authError instanceof HttpError && authError.status === 401) {
|
|
console.error(`[Org Creation] Authentication failed: Invalid or expired token`);
|
|
throw new Error(`Authentication failed: Please check your Gitea token has the required permissions. The token may be invalid or expired.`);
|
|
}
|
|
console.error(`[Org Creation] Failed to validate authentication:`, authError);
|
|
throw new Error(`Failed to validate Gitea authentication: ${authError instanceof Error ? authError.message : String(authError)}`);
|
|
}
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
console.log(`[Org Creation] Attempting to get or create organization: ${orgName} (attempt ${attempt + 1}/${maxRetries})`);
|
|
|
|
// Check if org exists
|
|
try {
|
|
const orgResponse = await httpGet<{ id: number }>(
|
|
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
|
|
{
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
}
|
|
);
|
|
|
|
console.log(`[Org Creation] Organization ${orgName} already exists with ID: ${orgResponse.data.id}`);
|
|
return orgResponse.data.id;
|
|
} catch (error) {
|
|
if (!(error instanceof HttpError) || error.status !== 404) {
|
|
throw error; // Unexpected error
|
|
}
|
|
// Organization doesn't exist, continue to create it
|
|
}
|
|
|
|
// Try to create the organization
|
|
console.log(`[Org Creation] Organization ${orgName} not found. Creating new organization.`);
|
|
|
|
const visibility = config.giteaConfig.visibility || "public";
|
|
const createOrgPayload = {
|
|
username: orgName,
|
|
full_name: orgName === "starred" ? "Starred Repositories" : orgName,
|
|
description: orgName === "starred"
|
|
? "Repositories starred on GitHub"
|
|
: `Mirrored from GitHub organization: ${orgName}`,
|
|
website: "",
|
|
location: "",
|
|
visibility: visibility,
|
|
};
|
|
|
|
try {
|
|
const createResponse = await httpPost<{ id: number }>(
|
|
`${config.giteaConfig.url}/api/v1/orgs`,
|
|
createOrgPayload,
|
|
{
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
}
|
|
);
|
|
|
|
console.log(`[Org Creation] Successfully created organization ${orgName} with ID: ${createResponse.data.id}`);
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
organizationId: orgId,
|
|
organizationName: orgName,
|
|
message: `Successfully created Gitea organization: ${orgName}`,
|
|
status: "synced",
|
|
details: `Organization ${orgName} was created in Gitea with ID ${createResponse.data.id}.`,
|
|
});
|
|
|
|
return createResponse.data.id;
|
|
} catch (createError) {
|
|
// Check if it's a duplicate error
|
|
if (createError instanceof HttpError) {
|
|
const errorResponse = createError.response?.toLowerCase() || "";
|
|
const isDuplicateError =
|
|
errorResponse.includes("duplicate") ||
|
|
errorResponse.includes("already exists") ||
|
|
errorResponse.includes("uqe_user_lower_name") ||
|
|
errorResponse.includes("constraint");
|
|
|
|
if (isDuplicateError && attempt < maxRetries - 1) {
|
|
console.log(`[Org Creation] Organization creation failed due to duplicate. Will retry check.`);
|
|
|
|
// Wait before retry with exponential backoff
|
|
const delay = process.env.NODE_ENV === 'test' ? 0 : retryDelay * Math.pow(2, attempt);
|
|
console.log(`[Org Creation] Waiting ${delay}ms before retry...`);
|
|
if (delay > 0) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
continue; // Retry the loop
|
|
}
|
|
|
|
// Check for permission errors
|
|
if (createError.status === 403) {
|
|
console.error(`[Org Creation] Permission denied: User may not have rights to create organizations`);
|
|
throw new Error(`Permission denied: Your Gitea user account does not have permission to create organizations. Please ensure your account has the necessary privileges or contact your Gitea administrator.`);
|
|
}
|
|
|
|
// Check for authentication errors
|
|
if (createError.status === 401) {
|
|
console.error(`[Org Creation] Authentication failed when creating organization`);
|
|
throw new Error(`Authentication failed: The Gitea token does not have sufficient permissions to create organizations. Please ensure your token has 'write:organization' scope.`);
|
|
}
|
|
}
|
|
throw createError;
|
|
}
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
|
|
if (attempt === maxRetries - 1) {
|
|
// Final attempt failed
|
|
console.error(`[Org Creation] Failed to get or create organization ${orgName} after ${maxRetries} attempts: ${errorMessage}`);
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
organizationId: orgId,
|
|
organizationName: orgName,
|
|
message: `Failed to create or fetch Gitea organization: ${orgName}`,
|
|
status: "failed",
|
|
details: `Error after ${maxRetries} attempts: ${errorMessage}`,
|
|
});
|
|
|
|
throw new Error(`Failed to create organization ${orgName}: ${errorMessage}`);
|
|
}
|
|
|
|
// Log retry attempt
|
|
console.warn(`[Org Creation] Attempt ${attempt + 1} failed for organization ${orgName}: ${errorMessage}. Retrying...`);
|
|
|
|
// Wait before retry
|
|
const delay = retryDelay * Math.pow(2, attempt);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
// Should never reach here
|
|
throw new Error(`Failed to create organization ${orgName} after ${maxRetries} attempts`);
|
|
}
|
|
|
|
/**
|
|
* Enhanced sync operation that handles non-mirror repositories
|
|
*/
|
|
export async function syncGiteaRepoEnhanced({
|
|
config,
|
|
repository,
|
|
skipForcePushDetection,
|
|
}: {
|
|
config: Partial<Config>;
|
|
repository: Repository;
|
|
/** When true, skip force-push detection and blocking (used by approve-sync). */
|
|
skipForcePushDetection?: boolean;
|
|
}, deps?: SyncDependencies): Promise<any> {
|
|
try {
|
|
if (!config.userId || !config.giteaConfig?.url || !config.giteaConfig?.token) {
|
|
throw new Error("Gitea config is required.");
|
|
}
|
|
|
|
const decryptedConfig = decryptConfigTokens(config as Config);
|
|
|
|
console.log(`[Sync] Starting sync for repository ${repository.name}`);
|
|
|
|
// Mark repo as "syncing" in DB
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("syncing"),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
// Get the expected owner
|
|
const dependencies = deps ?? (await import("./gitea"));
|
|
const repoOwner = await dependencies.getGiteaRepoOwnerAsync({ config, repository });
|
|
|
|
// Check if repo exists and get its info
|
|
const repoInfo = await getGiteaRepoInfo({
|
|
config,
|
|
owner: repoOwner,
|
|
repoName: repository.name,
|
|
});
|
|
|
|
if (!repoInfo) {
|
|
throw new Error(`Repository ${repository.name} not found in Gitea at ${repoOwner}/${repository.name}`);
|
|
}
|
|
|
|
// Check if it's a mirror repository
|
|
if (!repoInfo.mirror) {
|
|
console.warn(`[Sync] Repository ${repository.name} exists but is not configured as a mirror`);
|
|
|
|
// Update database to reflect this status
|
|
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.`);
|
|
}
|
|
|
|
// ---- Smart backup strategy with force-push detection ----
|
|
const backupStrategy = resolveBackupStrategy(config);
|
|
let forcePushDetected = false;
|
|
|
|
if (backupStrategy !== "disabled") {
|
|
// Run force-push detection if the strategy requires it
|
|
// (skip when called from approve-sync to avoid re-blocking)
|
|
if (strategyNeedsDetection(backupStrategy) && !skipForcePushDetection) {
|
|
try {
|
|
const decryptedGithubToken = decryptedConfig.githubConfig?.token;
|
|
if (decryptedGithubToken) {
|
|
const fpOctokit = new Octokit({ auth: decryptedGithubToken });
|
|
const detectionResult = await detectForcePush({
|
|
giteaUrl: config.giteaConfig.url,
|
|
giteaToken: decryptedConfig.giteaConfig.token,
|
|
giteaOwner: repoOwner,
|
|
giteaRepo: repository.name,
|
|
octokit: fpOctokit,
|
|
githubOwner: repository.owner,
|
|
githubRepo: repository.name,
|
|
});
|
|
|
|
forcePushDetected = detectionResult.detected;
|
|
|
|
if (detectionResult.skipped) {
|
|
console.log(
|
|
`[Sync] Force-push detection skipped for ${repository.name}: ${detectionResult.skipReason}`,
|
|
);
|
|
} else if (forcePushDetected) {
|
|
const branchNames = detectionResult.affectedBranches
|
|
.map((b) => `${b.name} (${b.reason})`)
|
|
.join(", ");
|
|
console.warn(
|
|
`[Sync] Force-push detected on ${repository.name}: ${branchNames}`,
|
|
);
|
|
}
|
|
} else {
|
|
console.log(
|
|
`[Sync] Skipping force-push detection for ${repository.name}: no GitHub token`,
|
|
);
|
|
}
|
|
} catch (detectionError) {
|
|
// Fail-open: detection errors should never block sync
|
|
console.warn(
|
|
`[Sync] Force-push detection failed for ${repository.name}, proceeding with sync: ${
|
|
detectionError instanceof Error ? detectionError.message : String(detectionError)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check if sync should be blocked (block-on-force-push mode)
|
|
if (shouldBlockSyncForStrategy(backupStrategy, forcePushDetected)) {
|
|
const branchInfo = `Force-push detected; sync blocked for manual approval.`;
|
|
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: "pending-approval",
|
|
updatedAt: new Date(),
|
|
errorMessage: branchInfo,
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Sync blocked for ${repository.name}: force-push detected`,
|
|
details: branchInfo,
|
|
status: "pending-approval",
|
|
});
|
|
|
|
console.warn(`[Sync] Sync blocked for ${repository.name}: pending manual approval`);
|
|
return { blocked: true, reason: branchInfo };
|
|
}
|
|
|
|
// Create backup if strategy says so
|
|
if (shouldBackupForStrategy(backupStrategy, forcePushDetected)) {
|
|
const cloneUrl =
|
|
repoInfo.clone_url ||
|
|
`${config.giteaConfig.url.replace(/\/$/, "")}/${repoOwner}/${repository.name}.git`;
|
|
|
|
try {
|
|
const backupResult = await createPreSyncBundleBackup({
|
|
config,
|
|
owner: repoOwner,
|
|
repoName: repository.name,
|
|
cloneUrl,
|
|
force: true, // Strategy already decided to backup; skip legacy gate
|
|
});
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Snapshot created for ${repository.name}`,
|
|
details: `Pre-sync snapshot created at ${backupResult.bundlePath}.`,
|
|
status: "syncing",
|
|
});
|
|
} catch (backupError) {
|
|
const errorMessage =
|
|
backupError instanceof Error ? backupError.message : String(backupError);
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Snapshot failed for ${repository.name}`,
|
|
details: `Pre-sync snapshot failed: ${errorMessage}`,
|
|
status: "failed",
|
|
});
|
|
|
|
if (shouldBlockSyncOnBackupFailure(config)) {
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("failed"),
|
|
updatedAt: new Date(),
|
|
errorMessage: `Snapshot failed; sync blocked to protect history. ${errorMessage}`,
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
throw new Error(
|
|
`Snapshot failed; sync blocked to protect history. ${errorMessage}`,
|
|
);
|
|
}
|
|
|
|
console.warn(
|
|
`[Sync] Snapshot failed for ${repository.name}, continuing because blockSyncOnBackupFailure=false: ${errorMessage}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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}`;
|
|
await httpPatch(updateUrl, {
|
|
mirror_interval: config.giteaConfig.mirrorInterval,
|
|
}, {
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
});
|
|
console.log(`[Sync] Successfully updated mirror interval for ${repository.name}`);
|
|
} catch (updateError) {
|
|
console.warn(`[Sync] Failed to update mirror interval for ${repository.name}:`, 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`;
|
|
|
|
try {
|
|
const response = await httpPost(apiUrl, undefined, {
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
});
|
|
|
|
const metadataState = parseRepositoryMetadataState(repository.metadata);
|
|
let metadataUpdated = false;
|
|
const skipMetadataForStarred =
|
|
repository.isStarred && config.githubConfig?.starredCodeOnly;
|
|
let metadataOctokit: Octokit | null = null;
|
|
|
|
const ensureOctokit = (): Octokit | null => {
|
|
if (metadataOctokit) {
|
|
return metadataOctokit;
|
|
}
|
|
if (!decryptedConfig.githubConfig?.token) {
|
|
return null;
|
|
}
|
|
metadataOctokit = new Octokit({
|
|
auth: decryptedConfig.githubConfig.token,
|
|
});
|
|
return metadataOctokit;
|
|
};
|
|
|
|
const shouldMirrorReleases =
|
|
!!config.giteaConfig?.mirrorReleases && !skipMetadataForStarred;
|
|
const shouldMirrorIssuesThisRun =
|
|
!!config.giteaConfig?.mirrorIssues &&
|
|
!skipMetadataForStarred;
|
|
const shouldMirrorPullRequests =
|
|
!!config.giteaConfig?.mirrorPullRequests &&
|
|
!skipMetadataForStarred;
|
|
const shouldMirrorLabels =
|
|
!!config.giteaConfig?.mirrorLabels &&
|
|
!skipMetadataForStarred &&
|
|
!shouldMirrorIssuesThisRun &&
|
|
!metadataState.components.labels;
|
|
const shouldMirrorMilestones =
|
|
!!config.giteaConfig?.mirrorMilestones &&
|
|
!skipMetadataForStarred &&
|
|
!metadataState.components.milestones;
|
|
|
|
if (shouldMirrorReleases) {
|
|
const octokit = ensureOctokit();
|
|
if (!octokit) {
|
|
console.warn(
|
|
`[Sync] Skipping release mirroring for ${repository.name}: Missing GitHub token`
|
|
);
|
|
} else {
|
|
try {
|
|
await dependencies.mirrorGitHubReleasesToGitea({
|
|
config,
|
|
octokit,
|
|
repository,
|
|
giteaOwner: repoOwner,
|
|
giteaRepoName: repository.name,
|
|
});
|
|
metadataState.components.releases = true;
|
|
metadataUpdated = true;
|
|
console.log(
|
|
`[Sync] Mirrored releases for ${repository.name} after sync`
|
|
);
|
|
} catch (releaseError) {
|
|
console.error(
|
|
`[Sync] Failed to mirror releases for ${repository.name}: ${
|
|
releaseError instanceof Error
|
|
? releaseError.message
|
|
: String(releaseError)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldMirrorIssuesThisRun) {
|
|
const octokit = ensureOctokit();
|
|
if (!octokit) {
|
|
console.warn(
|
|
`[Sync] Skipping issue mirroring for ${repository.name}: Missing GitHub token`
|
|
);
|
|
} else {
|
|
try {
|
|
await dependencies.mirrorGitRepoIssuesToGitea({
|
|
config,
|
|
octokit,
|
|
repository,
|
|
giteaOwner: repoOwner,
|
|
giteaRepoName: repository.name,
|
|
});
|
|
metadataState.components.issues = true;
|
|
metadataState.components.labels = true;
|
|
metadataUpdated = true;
|
|
console.log(
|
|
`[Sync] Mirrored issues for ${repository.name} after sync`
|
|
);
|
|
} catch (issueError) {
|
|
console.error(
|
|
`[Sync] Failed to mirror issues for ${repository.name}: ${
|
|
issueError instanceof Error
|
|
? issueError.message
|
|
: String(issueError)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldMirrorPullRequests) {
|
|
const octokit = ensureOctokit();
|
|
if (!octokit) {
|
|
console.warn(
|
|
`[Sync] Skipping pull request mirroring for ${repository.name}: Missing GitHub token`
|
|
);
|
|
} else {
|
|
try {
|
|
await dependencies.mirrorGitRepoPullRequestsToGitea({
|
|
config,
|
|
octokit,
|
|
repository,
|
|
giteaOwner: repoOwner,
|
|
giteaRepoName: repository.name,
|
|
});
|
|
metadataState.components.pullRequests = true;
|
|
metadataUpdated = true;
|
|
console.log(
|
|
`[Sync] Mirrored pull requests for ${repository.name} after sync`
|
|
);
|
|
} catch (prError) {
|
|
console.error(
|
|
`[Sync] Failed to mirror pull requests for ${repository.name}: ${
|
|
prError instanceof Error ? prError.message : String(prError)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldMirrorLabels) {
|
|
const octokit = ensureOctokit();
|
|
if (!octokit) {
|
|
console.warn(
|
|
`[Sync] Skipping label mirroring for ${repository.name}: Missing GitHub token`
|
|
);
|
|
} else {
|
|
try {
|
|
await dependencies.mirrorGitRepoLabelsToGitea({
|
|
config,
|
|
octokit,
|
|
repository,
|
|
giteaOwner: repoOwner,
|
|
giteaRepoName: repository.name,
|
|
});
|
|
metadataState.components.labels = true;
|
|
metadataUpdated = true;
|
|
console.log(
|
|
`[Sync] Mirrored labels for ${repository.name} after sync`
|
|
);
|
|
} catch (labelError) {
|
|
console.error(
|
|
`[Sync] Failed to mirror labels for ${repository.name}: ${
|
|
labelError instanceof Error
|
|
? labelError.message
|
|
: String(labelError)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
} else if (
|
|
config.giteaConfig?.mirrorLabels &&
|
|
metadataState.components.labels
|
|
) {
|
|
console.log(
|
|
`[Sync] Labels already mirrored for ${repository.name}; skipping`
|
|
);
|
|
}
|
|
|
|
if (shouldMirrorMilestones) {
|
|
const octokit = ensureOctokit();
|
|
if (!octokit) {
|
|
console.warn(
|
|
`[Sync] Skipping milestone mirroring for ${repository.name}: Missing GitHub token`
|
|
);
|
|
} else {
|
|
try {
|
|
await dependencies.mirrorGitRepoMilestonesToGitea({
|
|
config,
|
|
octokit,
|
|
repository,
|
|
giteaOwner: repoOwner,
|
|
giteaRepoName: repository.name,
|
|
});
|
|
metadataState.components.milestones = true;
|
|
metadataUpdated = true;
|
|
console.log(
|
|
`[Sync] Mirrored milestones for ${repository.name} after sync`
|
|
);
|
|
} catch (milestoneError) {
|
|
console.error(
|
|
`[Sync] Failed to mirror milestones for ${repository.name}: ${
|
|
milestoneError instanceof Error
|
|
? milestoneError.message
|
|
: String(milestoneError)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
} else if (
|
|
config.giteaConfig?.mirrorMilestones &&
|
|
metadataState.components.milestones
|
|
) {
|
|
console.log(
|
|
`[Sync] Milestones already mirrored for ${repository.name}; skipping`
|
|
);
|
|
}
|
|
|
|
if (metadataUpdated) {
|
|
metadataState.lastSyncedAt = new Date().toISOString();
|
|
}
|
|
|
|
// Mark repo as "synced" in DB
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("synced"),
|
|
updatedAt: new Date(),
|
|
lastMirrored: new Date(),
|
|
errorMessage: null,
|
|
mirroredLocation: `${repoOwner}/${repository.name}`,
|
|
metadata: metadataUpdated
|
|
? serializeRepositoryMetadataState(metadataState)
|
|
: repository.metadata ?? null,
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Sync requested for repository: ${repository.name}`,
|
|
details: `Mirror sync was requested for ${repository.name}. Gitea/Forgejo performs the actual pull asynchronously; check remote logs for pull errors.`,
|
|
status: "synced",
|
|
});
|
|
|
|
console.log(`[Sync] Mirror sync requested for repository ${repository.name}`);
|
|
return response.data;
|
|
} catch (syncError) {
|
|
if (syncError instanceof HttpError && syncError.status === 400) {
|
|
// Handle specific mirror-sync errors
|
|
const errorMessage = syncError.response?.toLowerCase() || "";
|
|
if (errorMessage.includes("not a mirror")) {
|
|
// Update status to indicate this specific error
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("failed"),
|
|
updatedAt: new Date(),
|
|
errorMessage: "Repository is not configured as a mirror in Gitea",
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Sync failed: ${repository.name} is not a mirror`,
|
|
details: "The repository exists in Gitea but is not configured as a mirror. Manual intervention required.",
|
|
status: "failed",
|
|
});
|
|
}
|
|
}
|
|
throw syncError;
|
|
}
|
|
} catch (error) {
|
|
console.error(`[Sync] Error while syncing repository ${repository.name}:`, error);
|
|
|
|
// Update repo with error status
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("failed"),
|
|
updatedAt: new Date(),
|
|
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
if (config.userId && repository.id && repository.name) {
|
|
await createMirrorJob({
|
|
userId: config.userId,
|
|
repositoryId: repository.id,
|
|
repositoryName: repository.name,
|
|
message: `Failed to sync repository: ${repository.name}`,
|
|
details: error instanceof Error ? error.message : "Unknown error",
|
|
status: "failed",
|
|
});
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a repository in Gitea (useful for cleaning up non-mirror repos)
|
|
*/
|
|
export async function deleteGiteaRepo({
|
|
config,
|
|
owner,
|
|
repoName,
|
|
}: {
|
|
config: Partial<Config>;
|
|
owner: string;
|
|
repoName: string;
|
|
}): Promise<void> {
|
|
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
|
|
throw new Error("Gitea config is required.");
|
|
}
|
|
|
|
const decryptedConfig = decryptConfigTokens(config as Config);
|
|
|
|
const response = await fetch(
|
|
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: {
|
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
throw new Error(`Failed to delete repository: ${response.statusText}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a regular repository to a mirror (if supported by Gitea version)
|
|
* Note: This might not be supported in all Gitea versions
|
|
*/
|
|
export async function convertToMirror({
|
|
config,
|
|
owner,
|
|
repoName,
|
|
cloneUrl,
|
|
}: {
|
|
config: Partial<Config>;
|
|
owner: string;
|
|
repoName: string;
|
|
cloneUrl: string;
|
|
}): Promise<boolean> {
|
|
// This is a placeholder - actual implementation depends on Gitea API support
|
|
// Most Gitea versions don't support converting existing repos to mirrors
|
|
console.warn(`[Convert] Converting existing repositories to mirrors is not supported in most Gitea versions`);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Sequential organization creation to avoid race conditions
|
|
*/
|
|
export async function createOrganizationsSequentially({
|
|
config,
|
|
orgNames,
|
|
}: {
|
|
config: Partial<Config>;
|
|
orgNames: string[];
|
|
}): Promise<Map<string, number>> {
|
|
const orgIdMap = new Map<string, number>();
|
|
|
|
for (const orgName of orgNames) {
|
|
try {
|
|
const orgId = await getOrCreateGiteaOrgEnhanced({
|
|
orgName,
|
|
config,
|
|
maxRetries: 3,
|
|
retryDelay: 100,
|
|
});
|
|
orgIdMap.set(orgName, orgId);
|
|
} catch (error) {
|
|
console.error(`Failed to create organization ${orgName}:`, error);
|
|
// Continue with other organizations
|
|
}
|
|
}
|
|
|
|
return orgIdMap;
|
|
}
|
|
|
|
/**
|
|
* Check and handle existing non-mirror repositories
|
|
*/
|
|
export async function handleExistingNonMirrorRepo({
|
|
config,
|
|
repository,
|
|
repoInfo,
|
|
strategy = "skip",
|
|
}: {
|
|
config: Partial<Config>;
|
|
repository: Repository;
|
|
repoInfo: GiteaRepoInfo;
|
|
strategy?: "skip" | "delete" | "rename";
|
|
}): Promise<void> {
|
|
const owner = typeof repoInfo.owner === 'string' ? repoInfo.owner : repoInfo.owner.login;
|
|
const repoName = repoInfo.name;
|
|
|
|
switch (strategy) {
|
|
case "skip":
|
|
console.log(`[Handle] Skipping existing non-mirror repository: ${owner}/${repoName}`);
|
|
|
|
await db
|
|
.update(repositories)
|
|
.set({
|
|
status: repoStatusEnum.parse("failed"),
|
|
updatedAt: new Date(),
|
|
errorMessage: "Repository exists but is not a mirror. Skipped.",
|
|
})
|
|
.where(eq(repositories.id, repository.id!));
|
|
|
|
break;
|
|
|
|
case "delete":
|
|
console.log(`[Handle] Deleting existing non-mirror repository: ${owner}/${repoName}`);
|
|
|
|
await deleteGiteaRepo({
|
|
config,
|
|
owner,
|
|
repoName,
|
|
});
|
|
|
|
console.log(`[Handle] Deleted repository ${owner}/${repoName}. It can now be recreated as a mirror.`);
|
|
break;
|
|
|
|
case "rename":
|
|
console.log(`[Handle] Renaming strategy not implemented yet for: ${owner}/${repoName}`);
|
|
// TODO: Implement rename strategy if needed
|
|
break;
|
|
}
|
|
}
|