mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 03:26:44 +03:00
- Implemented comprehensive GitHub API rate limit handling:
- Integrated @octokit/plugin-throttling for automatic retry with exponential backoff
- Added RateLimitManager service to track and enforce rate limits
- Store rate limit status in database for persistence across restarts
- Automatic pause and resume when limits are exceeded
- Proper user identification for 5000 req/hr authenticated limit (vs 60 unauthenticated)
- Improved rate limit UI/UX:
- Removed intrusive rate limit card from dashboard
- Toast notifications only at critical thresholds (80% and 100% usage)
- All rate limit events logged for debugging
- Optimized for GitHub's API constraints:
- Reduced default batch size from 10 to 5 repositories
- Added documentation about GitHub's 100 concurrent request limit
- Better handling of repositories with many issues/PRs
225 lines
8.0 KiB
TypeScript
225 lines
8.0 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
|
import { db, configs, repositories } from "@/lib/db";
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
|
import {
|
|
mirrorGithubRepoToGitea,
|
|
mirrorGitHubOrgRepoToGiteaOrg,
|
|
getGiteaRepoOwnerAsync,
|
|
} from "@/lib/gitea";
|
|
import { createGitHubClient } from "@/lib/github";
|
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
|
import { processWithResilience } from "@/lib/utils/concurrency";
|
|
import { createSecureErrorResponse } from "@/lib/utils";
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
try {
|
|
const body: MirrorRepoRequest = await request.json();
|
|
const { userId, repositoryIds } = body;
|
|
|
|
if (!userId || !repositoryIds || !Array.isArray(repositoryIds)) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: false,
|
|
message: "userId and repositoryIds are required.",
|
|
}),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
if (repositoryIds.length === 0) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
success: false,
|
|
message: "No repository IDs provided.",
|
|
}),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Fetch config
|
|
const configResult = await db
|
|
.select()
|
|
.from(configs)
|
|
.where(eq(configs.userId, userId))
|
|
.limit(1);
|
|
|
|
const config = configResult[0];
|
|
|
|
if (!config || !config.githubConfig.token) {
|
|
return new Response(
|
|
JSON.stringify({ error: "Config missing for the user or token." }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Fetch repos
|
|
const repos = await db
|
|
.select()
|
|
.from(repositories)
|
|
.where(inArray(repositories.id, repositoryIds));
|
|
|
|
if (!repos.length) {
|
|
return new Response(
|
|
JSON.stringify({ error: "No repositories found for the given IDs." }),
|
|
{ status: 404, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Start async mirroring in background with parallel processing and resilience
|
|
setTimeout(async () => {
|
|
if (!config.githubConfig.token) {
|
|
throw new Error("GitHub token is missing.");
|
|
}
|
|
|
|
// Create a single Octokit instance to be reused with rate limit tracking
|
|
const decryptedToken = getDecryptedGitHubToken(config);
|
|
const githubUsername = config.githubConfig?.owner || undefined;
|
|
const octokit = createGitHubClient(decryptedToken, userId, githubUsername);
|
|
|
|
// Define the concurrency limit - adjust based on API rate limits
|
|
const CONCURRENCY_LIMIT = 3;
|
|
|
|
// Process repositories in parallel with resilience to container restarts
|
|
await processWithResilience(
|
|
repos,
|
|
async (repo) => {
|
|
// Prepare repository data
|
|
const repoData = {
|
|
...repo,
|
|
status: repoStatusEnum.parse("imported"),
|
|
organization: repo.organization ?? undefined,
|
|
lastMirrored: repo.lastMirrored ?? undefined,
|
|
errorMessage: repo.errorMessage ?? undefined,
|
|
forkedFrom: repo.forkedFrom ?? undefined,
|
|
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
|
mirroredLocation: repo.mirroredLocation || "",
|
|
};
|
|
|
|
// Log the start of mirroring
|
|
console.log(`Starting mirror for repository: ${repo.name}`);
|
|
|
|
// Determine where the repository should be mirrored (with organization overrides)
|
|
const owner = await getGiteaRepoOwnerAsync({
|
|
config,
|
|
repository: repoData,
|
|
});
|
|
|
|
console.log(`Repository ${repo.name} will be mirrored to owner: ${owner}`);
|
|
|
|
// For single-org and starred repos strategies, or when mirroring to an org,
|
|
// always use the org mirroring function to ensure proper organization handling
|
|
const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
|
|
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
|
|
|
const shouldUseOrgMirror =
|
|
owner !== config.giteaConfig?.defaultOwner || // Different owner means org
|
|
mirrorStrategy === "single-org" || // Single-org strategy always uses org
|
|
repoData.isStarred; // Starred repos always go to org
|
|
|
|
if (shouldUseOrgMirror) {
|
|
await mirrorGitHubOrgRepoToGiteaOrg({
|
|
config,
|
|
octokit,
|
|
orgName: owner,
|
|
repository: repoData,
|
|
});
|
|
} else {
|
|
await mirrorGithubRepoToGitea({
|
|
octokit,
|
|
repository: repoData,
|
|
config,
|
|
});
|
|
}
|
|
|
|
return repo;
|
|
},
|
|
{
|
|
userId: config.userId || "",
|
|
jobType: "mirror",
|
|
getItemId: (repo) => repo.id,
|
|
getItemName: (repo) => repo.name,
|
|
concurrencyLimit: CONCURRENCY_LIMIT,
|
|
maxRetries: 2,
|
|
retryDelay: 2000,
|
|
checkpointInterval: 5, // Checkpoint every 5 repositories to reduce event frequency
|
|
onProgress: (completed, total, result) => {
|
|
const percentComplete = Math.round((completed / total) * 100);
|
|
console.log(
|
|
`Mirroring progress: ${percentComplete}% (${completed}/${total})`
|
|
);
|
|
|
|
if (result) {
|
|
console.log(`Successfully mirrored repository: ${result.name}`);
|
|
}
|
|
},
|
|
onRetry: (repo, error, attempt) => {
|
|
console.log(
|
|
`Retrying repository ${repo.name} (attempt ${attempt}): ${error.message}`
|
|
);
|
|
},
|
|
}
|
|
);
|
|
|
|
console.log("All repository mirroring tasks completed");
|
|
}, 0);
|
|
|
|
const responsePayload: MirrorRepoResponse = {
|
|
success: true,
|
|
message: "Mirror job started.",
|
|
repositories: repos.map((repo) => ({
|
|
...repo,
|
|
status: repoStatusEnum.parse(repo.status),
|
|
organization: repo.organization ?? undefined,
|
|
lastMirrored: repo.lastMirrored ?? undefined,
|
|
errorMessage: repo.errorMessage ?? undefined,
|
|
forkedFrom: repo.forkedFrom ?? undefined,
|
|
visibility: repositoryVisibilityEnum.parse(repo.visibility),
|
|
mirroredLocation: repo.mirroredLocation || "",
|
|
})),
|
|
};
|
|
|
|
// Return the updated repo list to the user
|
|
return new Response(JSON.stringify(responsePayload), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (error) {
|
|
// Enhanced error logging for better debugging
|
|
console.error("=== ERROR MIRRORING REPOSITORIES ===");
|
|
console.error("Error type:", error?.constructor?.name);
|
|
console.error(
|
|
"Error message:",
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
|
|
if (error instanceof Error) {
|
|
console.error("Error stack:", error.stack);
|
|
}
|
|
|
|
// Log additional context
|
|
console.error("Request details:");
|
|
console.error("- URL:", request.url);
|
|
console.error("- Method:", request.method);
|
|
console.error("- Headers:", Object.fromEntries(request.headers.entries()));
|
|
|
|
// If it's a JSON parsing error, provide more context
|
|
if (error instanceof SyntaxError && error.message.includes("JSON")) {
|
|
console.error("🚨 JSON PARSING ERROR DETECTED:");
|
|
console.error(
|
|
"This suggests the response from Gitea API is not valid JSON"
|
|
);
|
|
console.error("Common causes:");
|
|
console.error("- Gitea server returned HTML error page instead of JSON");
|
|
console.error("- Network connection interrupted");
|
|
console.error("- Gitea server is down or misconfigured");
|
|
console.error("- Authentication token is invalid");
|
|
console.error("Check your Gitea server logs and configuration");
|
|
}
|
|
|
|
console.error("=====================================");
|
|
|
|
return createSecureErrorResponse(error, "mirror-repo API", 500);
|
|
}
|
|
}; |