Files
gitea-mirror/src/pages/api/job/mirror-repo.ts
Arunavo Ray 37e5b68bd5 Added Github API rate limiting
- 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
2025-09-09 11:14:43 +05:30

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);
}
};