mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 04: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
222 lines
7.7 KiB
TypeScript
222 lines
7.7 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import { db, configs, repositories } from "@/lib/db";
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
|
|
import {
|
|
mirrorGithubRepoToGitea,
|
|
mirrorGitHubOrgRepoToGiteaOrg,
|
|
syncGiteaRepo,
|
|
} from "@/lib/gitea";
|
|
import { createGitHubClient } from "@/lib/github";
|
|
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
|
|
import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
|
|
import { processWithRetry } from "@/lib/utils/concurrency";
|
|
import { createMirrorJob } from "@/lib/helpers";
|
|
import { createSecureErrorResponse } from "@/lib/utils";
|
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
|
|
|
export const POST: APIRoute = async ({ request }) => {
|
|
try {
|
|
const body: RetryRepoRequest = 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 user config
|
|
const configResult = await db
|
|
.select()
|
|
.from(configs)
|
|
.where(eq(configs.userId, userId))
|
|
.limit(1);
|
|
|
|
const config = configResult[0];
|
|
|
|
if (!config || !config.githubConfig.token || !config.giteaConfig?.token) {
|
|
return new Response(
|
|
JSON.stringify({ error: "Missing GitHub or Gitea configuration." }),
|
|
{ status: 400, headers: { "Content-Type": "application/json" } }
|
|
);
|
|
}
|
|
|
|
// Fetch repositories
|
|
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 background retry with parallel processing
|
|
setTimeout(async () => {
|
|
// Create a single Octokit instance to be reused if needed with rate limit tracking
|
|
const decryptedToken = config.githubConfig.token
|
|
? getDecryptedGitHubToken(config)
|
|
: null;
|
|
const githubUsername = config.githubConfig?.owner || undefined;
|
|
const octokit = decryptedToken
|
|
? createGitHubClient(decryptedToken, userId, githubUsername)
|
|
: null;
|
|
|
|
// Define the concurrency limit - adjust based on API rate limits
|
|
const CONCURRENCY_LIMIT = 3;
|
|
|
|
// Process repositories in parallel with retry capability
|
|
await processWithRetry(
|
|
repos,
|
|
async (repo) => {
|
|
// Prepare repository data
|
|
const visibility = repositoryVisibilityEnum.parse(repo.visibility);
|
|
const status = repoStatusEnum.parse(repo.status);
|
|
const repoData = {
|
|
...repo,
|
|
visibility,
|
|
status,
|
|
organization: repo.organization ?? undefined,
|
|
lastMirrored: repo.lastMirrored ?? undefined,
|
|
errorMessage: repo.errorMessage ?? undefined,
|
|
forkedFrom: repo.forkedFrom ?? undefined,
|
|
mirroredLocation: repo.mirroredLocation || "",
|
|
};
|
|
|
|
// Log the start of retry operation
|
|
console.log(`Starting retry for repository: ${repo.name}`);
|
|
|
|
// Create a mirror job entry to track progress
|
|
await createMirrorJob({
|
|
userId: config.userId || "",
|
|
repositoryId: repo.id,
|
|
repositoryName: repo.name,
|
|
message: `Started retry operation for repository: ${repo.name}`,
|
|
details: `Repository ${repo.name} is now in the retry queue.`,
|
|
status: "imported",
|
|
});
|
|
|
|
// Determine if the repository exists in Gitea (with organization overrides)
|
|
let owner = await getGiteaRepoOwnerAsync({
|
|
config,
|
|
repository: repoData,
|
|
});
|
|
|
|
const present = await isRepoPresentInGitea({
|
|
config,
|
|
owner,
|
|
repoName: repo.name,
|
|
});
|
|
|
|
if (present) {
|
|
// If the repository exists, sync it
|
|
await syncGiteaRepo({ config, repository: repoData });
|
|
console.log(`Synced existing repo: ${repo.name}`);
|
|
} else {
|
|
// If the repository doesn't exist, mirror it
|
|
if (!config.githubConfig.token) {
|
|
throw new Error("GitHub token is missing.");
|
|
}
|
|
|
|
if (!octokit) {
|
|
throw new Error("Octokit client is not initialized.");
|
|
}
|
|
|
|
console.log(`Importing repo: ${repo.name} 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,
|
|
status: repoStatusEnum.parse("imported"),
|
|
},
|
|
});
|
|
} else {
|
|
await mirrorGithubRepoToGitea({
|
|
config,
|
|
octokit,
|
|
repository: {
|
|
...repoData,
|
|
status: repoStatusEnum.parse("imported"),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return repo;
|
|
},
|
|
{
|
|
concurrencyLimit: CONCURRENCY_LIMIT,
|
|
maxRetries: 2,
|
|
retryDelay: 2000,
|
|
onProgress: (completed, total, result) => {
|
|
const percentComplete = Math.round((completed / total) * 100);
|
|
console.log(`Retry progress: ${percentComplete}% (${completed}/${total})`);
|
|
|
|
if (result) {
|
|
console.log(`Successfully processed repository: ${result.name}`);
|
|
}
|
|
},
|
|
onRetry: (repo, error, attempt) => {
|
|
console.log(`Retrying repository ${repo.name} (attempt ${attempt}): ${error.message}`);
|
|
}
|
|
}
|
|
);
|
|
|
|
console.log("All repository retry tasks completed");
|
|
}, 0);
|
|
|
|
const responsePayload: RetryRepoResponse = {
|
|
success: true,
|
|
message: "Retry job (sync/mirror) 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),
|
|
})),
|
|
};
|
|
|
|
return new Response(JSON.stringify(responsePayload), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
} catch (err) {
|
|
return createSecureErrorResponse(err, "repository retry", 500);
|
|
}
|
|
};
|