mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-14 06:23:01 +03:00
* fix: preserve disabled repos while skipping new imports * ci: upgrade bun to 1.3.6 for test workflow
237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
import type { APIRoute } from "astro";
|
|
import { configs, db, organizations, repositories } from "@/lib/db";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
|
import type {
|
|
AddOrganizationApiRequest,
|
|
AddOrganizationApiResponse,
|
|
} from "@/types/organizations";
|
|
import type { RepositoryVisibility, RepoStatus } from "@/types/Repository";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { decryptConfigTokens } from "@/lib/utils/config-encryption";
|
|
import { createGitHubClient } from "@/lib/github";
|
|
import { requireAuthenticatedUserId } from "@/lib/auth-guards";
|
|
|
|
export const POST: APIRoute = async ({ request, locals }) => {
|
|
try {
|
|
const authResult = await requireAuthenticatedUserId({ request, locals });
|
|
if ("response" in authResult) return authResult.response;
|
|
const userId = authResult.userId;
|
|
|
|
const body: AddOrganizationApiRequest = await request.json();
|
|
const { role, org, force = false } = body;
|
|
|
|
if (!org || !role) {
|
|
return jsonResponse({
|
|
data: { success: false, error: "Missing org or role" },
|
|
status: 400,
|
|
});
|
|
}
|
|
|
|
const trimmedOrg = org.trim();
|
|
const normalizedOrg = trimmedOrg.toLowerCase();
|
|
|
|
// Check if org already exists (case-insensitive)
|
|
const [existingOrg] = await db
|
|
.select()
|
|
.from(organizations)
|
|
.where(
|
|
and(
|
|
eq(organizations.userId, userId),
|
|
eq(organizations.normalizedName, normalizedOrg)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
if (existingOrg && !force) {
|
|
return jsonResponse({
|
|
data: {
|
|
success: false,
|
|
error: "Organization already exists for this user",
|
|
},
|
|
status: 409,
|
|
});
|
|
}
|
|
|
|
if (existingOrg && force) {
|
|
const [updatedOrg] = await db
|
|
.update(organizations)
|
|
.set({
|
|
membershipRole: role,
|
|
normalizedName: normalizedOrg,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(organizations.id, existingOrg.id))
|
|
.returning();
|
|
|
|
const resPayload: AddOrganizationApiResponse = {
|
|
success: true,
|
|
organization: updatedOrg ?? existingOrg,
|
|
message: "Organization already exists; using existing record.",
|
|
};
|
|
|
|
return jsonResponse({ data: resPayload, status: 200 });
|
|
}
|
|
|
|
if (existingOrg) {
|
|
return jsonResponse({
|
|
data: {
|
|
success: false,
|
|
error: "Organization already exists for this user",
|
|
},
|
|
status: 409,
|
|
});
|
|
}
|
|
|
|
// Get user's config
|
|
const [config] = await db
|
|
.select()
|
|
.from(configs)
|
|
.where(and(eq(configs.userId, userId), eq(configs.isActive, true)))
|
|
.limit(1);
|
|
|
|
if (!config) {
|
|
return jsonResponse({
|
|
data: { error: "No active configuration found for this user" },
|
|
status: 404,
|
|
});
|
|
}
|
|
|
|
const configId = config.id;
|
|
|
|
// Decrypt the config to get tokens
|
|
const decryptedConfig = decryptConfigTokens(config);
|
|
|
|
// Check if we have a GitHub token
|
|
if (!decryptedConfig.githubConfig?.token) {
|
|
return jsonResponse({
|
|
data: { error: "GitHub token not configured" },
|
|
status: 401,
|
|
});
|
|
}
|
|
|
|
// Create authenticated Octokit instance with rate limit tracking
|
|
const githubUsername = decryptedConfig.githubConfig?.owner || undefined;
|
|
const octokit = createGitHubClient(
|
|
decryptedConfig.githubConfig.token,
|
|
userId,
|
|
githubUsername
|
|
);
|
|
|
|
// Fetch org metadata
|
|
const { data: orgData } = await octokit.orgs.get({ org: trimmedOrg });
|
|
|
|
// Fetch repos based on config settings
|
|
const allRepos = [];
|
|
|
|
// Fetch all repos (public, private, and member) to show in UI
|
|
const publicRepos = await octokit.paginate(octokit.repos.listForOrg, {
|
|
org: trimmedOrg,
|
|
type: "public",
|
|
per_page: 100,
|
|
});
|
|
allRepos.push(...publicRepos);
|
|
|
|
// Always fetch private repos to show them in the UI
|
|
const privateRepos = await octokit.paginate(octokit.repos.listForOrg, {
|
|
org: trimmedOrg,
|
|
type: "private",
|
|
per_page: 100,
|
|
});
|
|
allRepos.push(...privateRepos);
|
|
|
|
// Also fetch member repos (includes private repos the user has access to)
|
|
const memberRepos = await octokit.paginate(octokit.repos.listForOrg, {
|
|
org: trimmedOrg,
|
|
type: "member",
|
|
per_page: 100,
|
|
});
|
|
// Filter out duplicates
|
|
const existingIds = new Set(allRepos.map(r => r.id));
|
|
const uniqueMemberRepos = memberRepos.filter(r => !existingIds.has(r.id));
|
|
allRepos.push(...uniqueMemberRepos);
|
|
const mirrorableRepos = allRepos.filter((repo) => !repo.disabled);
|
|
|
|
// Insert repositories
|
|
const repoRecords = mirrorableRepos.map((repo) => {
|
|
const normalizedOwner = repo.owner.login.trim().toLowerCase();
|
|
const normalizedRepoName = repo.name.trim().toLowerCase();
|
|
|
|
return {
|
|
id: uuidv4(),
|
|
userId,
|
|
configId,
|
|
name: repo.name,
|
|
fullName: repo.full_name,
|
|
normalizedFullName: `${normalizedOwner}/${normalizedRepoName}`,
|
|
url: repo.html_url,
|
|
cloneUrl: repo.clone_url ?? "",
|
|
owner: repo.owner.login,
|
|
organization:
|
|
repo.owner.type === "Organization" ? repo.owner.login : null,
|
|
mirroredLocation: "",
|
|
destinationOrg: null,
|
|
isPrivate: repo.private,
|
|
isForked: repo.fork,
|
|
forkedFrom: null,
|
|
hasIssues: repo.has_issues,
|
|
isStarred: false,
|
|
isArchived: repo.archived,
|
|
size: repo.size,
|
|
hasLFS: false,
|
|
hasSubmodules: false,
|
|
language: repo.language ?? null,
|
|
description: repo.description ?? null,
|
|
defaultBranch: repo.default_branch ?? "main",
|
|
visibility: (repo.visibility ?? "public") as RepositoryVisibility,
|
|
status: "imported" as RepoStatus,
|
|
lastMirrored: null,
|
|
errorMessage: null,
|
|
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
|
|
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
|
|
};
|
|
});
|
|
|
|
// Batch insert repositories to avoid SQLite parameter limit
|
|
// Compute batch size based on column count
|
|
const sample = repoRecords[0];
|
|
const columnCount = Object.keys(sample ?? {}).length || 1;
|
|
const BATCH_SIZE = Math.max(1, Math.floor(999 / columnCount));
|
|
for (let i = 0; i < repoRecords.length; i += BATCH_SIZE) {
|
|
const batch = repoRecords.slice(i, i + BATCH_SIZE);
|
|
await db
|
|
.insert(repositories)
|
|
.values(batch)
|
|
.onConflictDoNothing({ target: [repositories.userId, repositories.normalizedFullName] });
|
|
}
|
|
|
|
// Insert organization metadata
|
|
const organizationRecord = {
|
|
id: uuidv4(),
|
|
userId,
|
|
configId,
|
|
name: orgData.login,
|
|
normalizedName: normalizedOrg,
|
|
avatarUrl: orgData.avatar_url,
|
|
membershipRole: role,
|
|
isIncluded: false,
|
|
status: "imported" as RepoStatus,
|
|
repositoryCount: allRepos.length,
|
|
createdAt: orgData.created_at ? new Date(orgData.created_at) : new Date(),
|
|
updatedAt: orgData.updated_at ? new Date(orgData.updated_at) : new Date(),
|
|
};
|
|
|
|
await db.insert(organizations).values(organizationRecord);
|
|
|
|
const resPayload: AddOrganizationApiResponse = {
|
|
success: true,
|
|
organization: organizationRecord,
|
|
message: "Organization and repositories imported successfully",
|
|
};
|
|
|
|
return jsonResponse({ data: resPayload, status: 200 });
|
|
} catch (error) {
|
|
return createSecureErrorResponse(error, "organization sync", 500);
|
|
}
|
|
};
|