🎉 Gitea Mirror: Added

This commit is contained in:
Arunavo Ray
2025-05-18 09:31:23 +05:30
commit 5d40023de0
139 changed files with 22033 additions and 0 deletions

65
src/pages/activity.astro Normal file
View File

@@ -0,0 +1,65 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, mirrorJobs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
// Fetch activity data from the database
let activityData = [];
try {
// Fetch activity from mirror jobs
const jobs = await db.select().from(mirrorJobs).limit(20);
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}
try {
const log = JSON.parse(job.log);
if (!Array.isArray(log)) {
console.warn(`Job ${job.id} log is not an array`);
return [];
}
return log.map((entry: any) => ({
id: `${job.id}-${entry.timestamp}`,
message: entry.message,
timestamp: new Date(entry.timestamp),
status: entry.level,
details: entry.details,
repositoryName: entry.repositoryName,
}));
} catch (parseError) {
console.error(`Failed to parse log for job ${job.id}:`, parseError);
return [];
}
}).slice(0, 20);
} catch (error) {
console.error('Error fetching activity:', error);
// Fallback to empty array if database access fails
activityData = [];
}
// Client-side function to handle refresh
const handleRefresh = () => {
console.log('Refreshing activity log');
// In a real implementation, this would call the API to refresh the activity log
};
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Activity Log - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page='activity-log'client:load />
</body>
</html>

View File

@@ -0,0 +1,58 @@
import type { APIRoute } from "astro";
import { db, mirrorJobs, configs } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import type { MirrorJob } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
export const GET: APIRoute = async ({ url }) => {
try {
const searchParams = new URL(url).searchParams;
const userId = searchParams.get("userId");
if (!userId) {
return new Response(
JSON.stringify({ error: "Missing 'userId' in query parameters." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Fetch mirror jobs associated with the user
const jobs = await db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`);
const activities: MirrorJob[] = jobs.map((job) => ({
id: job.id,
userId: job.userId,
repositoryId: job.repositoryId ?? undefined,
repositoryName: job.repositoryName ?? undefined,
organizationId: job.organizationId ?? undefined,
organizationName: job.organizationName ?? undefined,
status: repoStatusEnum.parse(job.status),
details: job.details ?? undefined,
message: job.message,
timestamp: job.timestamp,
}));
return new Response(
JSON.stringify({
success: true,
message: "Mirror job activities retrieved successfully.",
activities,
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error fetching mirror job activities:", error);
return new Response(
JSON.stringify({
success: false,
error:
error instanceof Error ? error.message : "An unknown error occurred.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,83 @@
import type { APIRoute } from "astro";
import { db, users, configs, client } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const GET: APIRoute = async ({ request, cookies }) => {
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
if (!token) {
const userCountResult = await client.execute(
`SELECT COUNT(*) as count FROM users`
);
const userCount = userCountResult.rows[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
const userResult = await db
.select()
.from(users)
.where(eq(users.id, decoded.id))
.limit(1);
if (!userResult.length) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
const { password, ...userWithoutPassword } = userResult[0];
const configResult = await db
.select({
scheduleConfig: configs.scheduleConfig,
})
.from(configs)
.where(and(eq(configs.userId, decoded.id), eq(configs.isActive, true)))
.limit(1);
const scheduleConfig = configResult[0]?.scheduleConfig;
const syncEnabled = scheduleConfig?.enabled ?? false;
const syncInterval = scheduleConfig?.interval ?? 3600;
const lastSync = scheduleConfig?.lastRun ?? null;
const nextSync = scheduleConfig?.nextRun ?? null;
return new Response(
JSON.stringify({
...userWithoutPassword,
syncEnabled,
syncInterval,
lastSync,
nextSync,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,62 @@
import type { APIRoute } from "astro";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { db, users } from "@/lib/db";
import { eq } from "drizzle-orm";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const POST: APIRoute = async ({ request }) => {
const { username, password } = await request.json();
if (!username || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const user = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
if (!user.length) {
return new Response(
JSON.stringify({ error: "Invalid username or password" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
const isPasswordValid = await bcrypt.compare(password, user[0].password);
if (!isPasswordValid) {
return new Response(
JSON.stringify({ error: "Invalid username or password" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
const { password: _, ...userWithoutPassword } = user[0];
const token = jwt.sign({ id: user[0].id }, JWT_SECRET, { expiresIn: "7d" });
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
60 * 60 * 24 * 7
}`,
},
});
};

View File

@@ -0,0 +1,11 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async () => {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": "token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0",
},
});
};

View File

@@ -0,0 +1,72 @@
import type { APIRoute } from "astro";
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import { db, users } from "@/lib/db";
import { eq, or } from "drizzle-orm";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const POST: APIRoute = async ({ request }) => {
const { username, email, password } = await request.json();
if (!username || !email || !password) {
return new Response(
JSON.stringify({ error: "Username, email, and password are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Check if username or email already exists
const existingUser = await db
.select()
.from(users)
.where(or(eq(users.username, username), eq(users.email, email)))
.limit(1);
if (existingUser.length) {
return new Response(
JSON.stringify({ error: "Username or email already exists" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
}
);
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Generate UUID
const id = crypto.randomUUID();
// Create user
const newUser = await db
.insert(users)
.values({
id,
username,
email,
password: hashedPassword,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
const { password: _, ...userWithoutPassword } = newUser[0];
const token = jwt.sign({ id: newUser[0].id }, JWT_SECRET, {
expiresIn: "7d",
});
return new Response(JSON.stringify({ token, user: userWithoutPassword }), {
status: 201,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${
60 * 60 * 24 * 7
}`,
},
});
};

View File

@@ -0,0 +1,225 @@
import type { APIRoute } from "astro";
import { db, configs, users } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { eq } from "drizzle-orm";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { userId, githubConfig, giteaConfig, scheduleConfig } = body;
if (!userId || !githubConfig || !giteaConfig || !scheduleConfig) {
return new Response(
JSON.stringify({
success: false,
message:
"userId, githubConfig, giteaConfig, and scheduleConfig are required.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
// Fetch existing config
const existingConfigResult = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
const existingConfig = existingConfigResult[0];
// Preserve tokens if fields are empty
if (existingConfig) {
try {
const existingGithub =
typeof existingConfig.githubConfig === "string"
? JSON.parse(existingConfig.githubConfig)
: existingConfig.githubConfig;
const existingGitea =
typeof existingConfig.giteaConfig === "string"
? JSON.parse(existingConfig.giteaConfig)
: existingConfig.giteaConfig;
if (!githubConfig.token && existingGithub.token) {
githubConfig.token = existingGithub.token;
}
if (!giteaConfig.token && existingGitea.token) {
giteaConfig.token = existingGitea.token;
}
} catch (tokenError) {
console.error("Failed to preserve tokens:", tokenError);
}
}
if (existingConfig) {
// Update path
await db
.update(configs)
.set({
githubConfig,
giteaConfig,
scheduleConfig,
updatedAt: new Date(),
})
.where(eq(configs.id, existingConfig.id));
return new Response(
JSON.stringify({
success: true,
message: "Configuration updated successfully",
configId: existingConfig.id,
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
// Fallback user check (optional if you're always passing userId)
const userExists = await db
.select()
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (userExists.length === 0) {
return new Response(
JSON.stringify({
success: false,
message: "Invalid userId. No matching user found.",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
// Create new config
const configId = uuidv4();
await db.insert(configs).values({
id: configId,
userId,
name: "Default Configuration",
isActive: true,
githubConfig,
giteaConfig,
include: [],
exclude: [],
scheduleConfig,
createdAt: new Date(),
updatedAt: new Date(),
});
return new Response(
JSON.stringify({
success: true,
message: "Configuration created successfully",
configId,
}),
{
status: 201,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
console.error("Error saving configuration:", error);
return new Response(
JSON.stringify({
success: false,
message:
"Error saving configuration: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "User ID is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch the configuration for the user
const config = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (config.length === 0) {
// Return a default empty configuration instead of a 404 error
return new Response(
JSON.stringify({
id: null,
userId: userId,
name: "Default Configuration",
isActive: true,
githubConfig: {
username: "",
token: "",
skipForks: false,
privateRepositories: false,
mirrorIssues: false,
mirrorStarred: true,
useSpecificUser: false,
preserveOrgStructure: true,
skipStarredIssues: false,
},
giteaConfig: {
url: "",
token: "",
username: "",
organization: "github-mirrors",
visibility: "public",
starredReposOrg: "github",
},
scheduleConfig: {
enabled: false,
interval: 3600,
lastRun: null,
nextRun: null,
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(JSON.stringify(config[0]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error fetching configuration:", error);
return new Response(
JSON.stringify({
error: error instanceof Error ? error.message : "Something went wrong",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
};

View File

@@ -0,0 +1,122 @@
import type { APIRoute } from "astro";
import { db, repositories, organizations, mirrorJobs, configs } from "@/lib/db";
import { eq, count, and, sql, or } from "drizzle-orm";
import { jsonResponse } from "@/lib/utils";
import type { DashboardApiResponse } from "@/types/dashboard";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: {
success: false,
error: "Missing userId",
},
status: 400,
});
}
try {
const [
userRepos,
userOrgs,
userLogs,
[userConfig],
[{ value: repoCount }],
[{ value: orgCount }],
[{ value: mirroredCount }],
] = await Promise.all([
db
.select()
.from(repositories)
.where(eq(repositories.userId, userId))
.orderBy(sql`${repositories.updatedAt} DESC`)
.limit(10),
db
.select()
.from(organizations)
.where(eq(organizations.userId, userId))
.orderBy(sql`${organizations.updatedAt} DESC`)
.limit(10), // not really needed in the frontend but just in case
db
.select()
.from(mirrorJobs)
.where(eq(mirrorJobs.userId, userId))
.orderBy(sql`${mirrorJobs.timestamp} DESC`)
.limit(10),
db.select().from(configs).where(eq(configs.userId, userId)).limit(1),
db
.select({ value: count() })
.from(repositories)
.where(eq(repositories.userId, userId)),
db
.select({ value: count() })
.from(organizations)
.where(eq(organizations.userId, userId)),
db
.select({ value: count() })
.from(repositories)
.where(
and(
eq(repositories.userId, userId),
or(
eq(repositories.status, "mirrored"),
eq(repositories.status, "synced")
)
)
),
]);
const successResponse: DashboardApiResponse = {
success: true,
message: "Dashboard data loaded successfully",
repoCount: repoCount ?? 0,
orgCount: orgCount ?? 0,
mirroredCount: mirroredCount ?? 0,
repositories: userRepos.map((repo) => ({
...repo,
organization: repo.organization ?? undefined,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
status: repoStatusEnum.parse(repo.status),
visibility: repositoryVisibilityEnum.parse(repo.visibility),
})),
organizations: userOrgs.map((org) => ({
...org,
status: repoStatusEnum.parse(org.status),
membershipRole: membershipRoleEnum.parse(org.membershipRole),
lastMirrored: org.lastMirrored ?? undefined,
errorMessage: org.errorMessage ?? undefined,
})),
activities: userLogs.map((job) => ({
id: job.id,
userId: job.userId,
repositoryName: job.repositoryName ?? undefined,
organizationName: job.organizationName ?? undefined,
status: repoStatusEnum.parse(job.status),
details: job.details ?? undefined,
message: job.message,
timestamp: job.timestamp,
})),
lastSync: userConfig?.scheduleConfig.lastRun ?? null,
};
return jsonResponse({ data: successResponse });
} catch (error) {
console.error("Error loading dashboard for user:", userId, error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Internal server error",
message: "Failed to fetch dashboard data",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,135 @@
import type { APIRoute } from 'astro';
import axios from 'axios';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { url, token, username } = body;
if (!url || !token) {
return new Response(
JSON.stringify({
success: false,
message: 'Gitea URL and token are required',
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Normalize the URL (remove trailing slash if present)
const baseUrl = url.endsWith('/') ? url.slice(0, -1) : url;
// Test the connection by fetching the authenticated user
const response = await axios.get(`${baseUrl}/api/v1/user`, {
headers: {
'Authorization': `token ${token}`,
'Accept': 'application/json',
},
});
const data = response.data;
// Verify that the authenticated user matches the provided username (if provided)
if (username && data.login !== username) {
return new Response(
JSON.stringify({
success: false,
message: `Token belongs to ${data.login}, not ${username}`,
}),
{
status: 400,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Return success response with user data
return new Response(
JSON.stringify({
success: true,
message: `Successfully connected to Gitea as ${data.login}`,
user: {
login: data.login,
name: data.full_name,
avatar_url: data.avatar_url,
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
);
} catch (error) {
console.error('Gitea connection test failed:', error);
// Handle specific error types
if (axios.isAxiosError(error) && error.response) {
if (error.response.status === 401) {
return new Response(
JSON.stringify({
success: false,
message: 'Invalid Gitea token',
}),
{
status: 401,
headers: {
'Content-Type': 'application/json',
},
}
);
} else if (error.response.status === 404) {
return new Response(
JSON.stringify({
success: false,
message: 'Gitea API endpoint not found. Please check the URL.',
}),
{
status: 404,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}
// Handle connection errors
if (axios.isAxiosError(error) && (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND')) {
return new Response(
JSON.stringify({
success: false,
message: 'Could not connect to Gitea server. Please check the URL.',
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
// Generic error response
return new Response(
JSON.stringify({
success: false,
message: `Gitea connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
}),
{
status: 500,
headers: {
'Content-Type': 'application/json',
},
}
);
}
};

View File

@@ -0,0 +1,60 @@
import type { APIRoute } from "astro";
import { db } from "@/lib/db";
import { organizations } from "@/lib/db";
import { eq, sql } from "drizzle-orm";
import {
membershipRoleEnum,
type OrganizationsApiResponse,
} from "@/types/organizations";
import type { Organization } from "@/lib/db/schema";
import { repoStatusEnum } from "@/types/Repository";
import { jsonResponse } from "@/lib/utils";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: {
success: false,
error: "Missing userId",
},
status: 400,
});
}
try {
const rawOrgs = await db
.select()
.from(organizations)
.where(eq(organizations.userId, userId))
.orderBy(sql`name COLLATE NOCASE`);
const orgsWithIds: Organization[] = rawOrgs.map((org) => ({
...org,
status: repoStatusEnum.parse(org.status),
membershipRole: membershipRoleEnum.parse(org.membershipRole),
lastMirrored: org.lastMirrored ?? undefined,
errorMessage: org.errorMessage ?? undefined,
}));
const resPayload: OrganizationsApiResponse = {
success: true,
message: "Organizations fetched successfully",
organizations: orgsWithIds,
};
return jsonResponse({ data: resPayload, status: 200 });
} catch (error) {
console.error("Error fetching organizations:", error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,96 @@
import type { APIRoute } from "astro";
import { db, repositories, configs } from "@/lib/db";
import { and, eq, sql } from "drizzle-orm";
import {
repositoryVisibilityEnum,
repoStatusEnum,
type RepositoryApiResponse,
} from "@/types/Repository";
import { jsonResponse } from "@/lib/utils";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({
data: { success: false, error: "Missing userId" },
status: 400,
});
}
try {
// Fetch the user's active configuration
const [config] = await db
.select()
.from(configs)
.where(and(eq(configs.userId, userId), eq(configs.isActive, true)));
if (!config) {
return jsonResponse({
data: {
success: false,
error: "No active configuration found for this user",
},
status: 404,
});
}
const githubConfig = config.githubConfig as {
mirrorStarred: boolean;
skipForks: boolean;
privateRepositories: boolean;
};
// Build query conditions based on config
const conditions = [eq(repositories.userId, userId)];
if (!githubConfig.mirrorStarred) {
conditions.push(eq(repositories.isStarred, false));
}
if (githubConfig.skipForks) {
conditions.push(eq(repositories.isForked, false));
}
if (!githubConfig.privateRepositories) {
conditions.push(eq(repositories.isPrivate, false));
}
const rawRepositories = await db
.select()
.from(repositories)
.where(and(...conditions))
.orderBy(sql`name COLLATE NOCASE`);
const response: RepositoryApiResponse = {
success: true,
message: "Repositories fetched successfully",
repositories: rawRepositories.map((repo) => ({
...repo,
organization: repo.organization ?? undefined,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
status: repoStatusEnum.parse(repo.status),
visibility: repositoryVisibilityEnum.parse(repo.visibility),
})),
};
return jsonResponse({
data: response,
status: 200,
});
} catch (error) {
console.error("Error fetching repositories:", error);
return jsonResponse({
data: {
success: false,
error: error instanceof Error ? error.message : "Something went wrong",
message: "An error occurred while fetching repositories.",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,101 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { token, username } = body;
if (!token) {
return new Response(
JSON.stringify({
success: false,
message: "GitHub token is required",
}),
{
status: 400,
headers: {
"Content-Type": "application/json",
},
}
);
}
// Create an Octokit instance with the provided token
const octokit = new Octokit({
auth: token,
});
// Test the connection by fetching the authenticated user
const { data } = await octokit.users.getAuthenticated();
// Verify that the authenticated user matches the provided username (if provided)
if (username && data.login !== username) {
return new Response(
JSON.stringify({
success: false,
message: `Token belongs to ${data.login}, not ${username}`,
}),
{
status: 400,
headers: {
"Content-Type": "application/json",
},
}
);
}
// Return success response with user data
return new Response(
JSON.stringify({
success: true,
message: `Successfully connected to GitHub as ${data.login}`,
user: {
login: data.login,
name: data.name,
avatar_url: data.avatar_url,
},
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
},
}
);
} catch (error) {
console.error("GitHub connection test failed:", error);
// Handle specific error types
if (error instanceof Error && (error as any).status === 401) {
return new Response(
JSON.stringify({
success: false,
message: "Invalid GitHub token",
}),
{
status: 401,
headers: {
"Content-Type": "application/json",
},
}
);
}
// Generic error response
return new Response(
JSON.stringify({
success: false,
message: `GitHub connection test failed: ${
error instanceof Error ? error.message : "Unknown error"
}`,
}),
{
status: 500,
headers: {
"Content-Type": "application/json",
},
}
);
}
};

View File

@@ -0,0 +1,118 @@
import type { APIRoute } from "astro";
import type { MirrorOrgRequest, MirrorOrgResponse } from "@/types/mirror";
import { db, configs, organizations } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { createGitHubClient } from "@/lib/github";
import { mirrorGitHubOrgToGitea } from "@/lib/gitea";
import { repoStatusEnum } from "@/types/Repository";
import { type MembershipRole } from "@/types/organizations";
export const POST: APIRoute = async ({ request }) => {
try {
const body: MirrorOrgRequest = await request.json();
const { userId, organizationIds } = body;
if (!userId || !organizationIds || !Array.isArray(organizationIds)) {
return new Response(
JSON.stringify({
success: false,
message: "userId and organizationIds are required.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
if (organizationIds.length === 0) {
return new Response(
JSON.stringify({
success: false,
message: "No organization 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 organizations
const orgs = await db
.select()
.from(organizations)
.where(inArray(organizations.id, organizationIds));
if (!orgs.length) {
return new Response(
JSON.stringify({ error: "No organizations found for the given IDs." }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
// Fire async mirroring without blocking response
setTimeout(async () => {
for (const org of orgs) {
if (!config.githubConfig.token) {
throw new Error("GitHub token is missing in config.");
}
const octokit = createGitHubClient(config.githubConfig.token);
try {
await mirrorGitHubOrgToGitea({
config,
octokit,
organization: {
...org,
status: repoStatusEnum.parse("imported"),
membershipRole: org.membershipRole as MembershipRole,
lastMirrored: org.lastMirrored ?? undefined,
errorMessage: org.errorMessage ?? undefined,
},
});
} catch (error) {
console.error(`Mirror failed for organization ${org.name}:`, error);
}
}
}, 0);
const responsePayload: MirrorOrgResponse = {
success: true,
message: "Mirror job started.",
organizations: orgs.map((org) => ({
...org,
status: repoStatusEnum.parse(org.status),
membershipRole: org.membershipRole as MembershipRole,
lastMirrored: org.lastMirrored ?? undefined,
errorMessage: org.errorMessage ?? undefined,
})),
};
// Immediate response
return new Response(JSON.stringify(responsePayload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in mirroring organization:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "An unknown error occurred.",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,144 @@
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,
} from "@/lib/gitea";
import { createGitHubClient } from "@/lib/github";
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
setTimeout(async () => {
for (const repo of repos) {
if (!config.githubConfig.token) {
throw new Error("GitHub token is missing.");
}
const octokit = createGitHubClient(config.githubConfig.token);
try {
if (repo.organization && config.githubConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
repository: {
...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 || "",
},
});
} else {
await mirrorGithubRepoToGitea({
octokit,
repository: {
...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 || "",
},
config,
});
}
} catch (error) {
console.error(`Mirror failed for repo ${repo.name}:`, error);
}
}
}, 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) {
console.error("Error mirroring repositories:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "An unknown error occurred",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,160 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { getGiteaRepoOwner, 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";
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
setTimeout(async () => {
for (const repo of repos) {
try {
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,
};
let owner = getGiteaRepoOwner({
config,
repository: repoData,
});
const present = await isRepoPresentInGitea({
config,
owner,
repoName: repo.name,
});
if (present) {
await syncGiteaRepo({ config, repository: repoData });
console.log(`Synced existing repo: ${repo.name}`);
} else {
if (!config.githubConfig.token) {
throw new Error("GitHub token is missing.");
}
console.log(`Importing repo: ${repo.name} ${owner}`);
const octokit = createGitHubClient(config.githubConfig.token);
if (repo.organization && config.githubConfig.preserveOrgStructure) {
await mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
orgName: repo.organization,
repository: {
...repoData,
status: repoStatusEnum.parse("imported"),
},
});
} else {
await mirrorGithubRepoToGitea({
config,
octokit,
repository: {
...repoData,
status: repoStatusEnum.parse("imported"),
},
});
}
}
} catch (err) {
console.error(`Failed to retry repo ${repo.name}:`, err);
}
}
}, 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) {
console.error("Error retrying repo:", err);
return new Response(
JSON.stringify({
error: err instanceof Error ? err.message : "An unknown error occurred",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,154 @@
import type { APIRoute } from "astro";
import { db, configs, repositories } from "@/lib/db";
import { eq, or } from "drizzle-orm";
import { repoStatusEnum, repositoryVisibilityEnum } from "@/types/Repository";
import { isRepoPresentInGitea, syncGiteaRepo } from "@/lib/gitea";
import type {
ScheduleSyncRepoRequest,
ScheduleSyncRepoResponse,
} from "@/types/sync";
export const POST: APIRoute = async ({ request }) => {
try {
const body: ScheduleSyncRepoRequest = await request.json();
const { userId } = body;
if (!userId) {
return new Response(
JSON.stringify({
success: false,
error: "Missing userId in request body.",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Fetch config for the user
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({
success: false,
error: "Config missing for the user or GitHub token not found.",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// Fetch repositories with status 'mirrored' or 'synced'
const repos = await db
.select()
.from(repositories)
.where(
eq(repositories.userId, userId) &&
or(
eq(repositories.status, "mirrored"),
eq(repositories.status, "synced"),
eq(repositories.status, "failed")
)
);
if (!repos.length) {
return new Response(
JSON.stringify({
success: false,
error:
"No repositories found with status mirrored, synced or failed.",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
}
// Calculate nextRun and update lastRun and nextRun in the config
const currentTime = new Date();
const interval = config.scheduleConfig?.interval ?? 3600;
const nextRun = new Date(currentTime.getTime() + interval * 1000);
// Update the full giteaConfig object
await db
.update(configs)
.set({
scheduleConfig: {
...config.scheduleConfig,
lastRun: currentTime,
nextRun: nextRun,
},
})
.where(eq(configs.userId, userId));
// Start async sync in background
setTimeout(async () => {
for (const repo of repos) {
try {
// Only check Gitea presence if the repo failed previously
if (repo.status === "failed") {
const isPresent = await isRepoPresentInGitea({
config,
owner: repo.owner,
repoName: repo.name,
});
if (!isPresent) {
continue; //silently skip if repo is not present in Gitea
}
}
await syncGiteaRepo({
config,
repository: {
...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),
},
});
} catch (error) {
console.error(`Sync failed for repo ${repo.name}:`, error);
}
}
}, 0);
const resPayload: ScheduleSyncRepoResponse = {
success: true,
message: "Sync job scheduled for eligible repositories.",
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(resPayload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in scheduling sync:", error);
return new Response(
JSON.stringify({
success: false,
error:
error instanceof Error ? error.message : "An unknown error occurred",
repositories: [],
} satisfies ScheduleSyncRepoResponse),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

View File

@@ -0,0 +1,114 @@
import type { APIRoute } from "astro";
import type { MirrorRepoRequest } from "@/types/mirror";
import { db, configs, repositories } from "@/lib/db";
import { eq, inArray } from "drizzle-orm";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { syncGiteaRepo } from "@/lib/gitea";
import type { SyncRepoResponse } from "@/types/sync";
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
setTimeout(async () => {
for (const repo of repos) {
try {
await syncGiteaRepo({
config,
repository: {
...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),
},
});
} catch (error) {
console.error(`Sync failed for repo ${repo.name}:`, error);
}
}
}, 0);
const responsePayload: SyncRepoResponse = {
success: true,
message: "Sync 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),
})),
};
// Return the updated repo list to the user
return new Response(JSON.stringify(responsePayload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in syncing repositories:", error);
return new Response(
JSON.stringify({
error:
error instanceof Error ? error.message : "An unknown error occurred",
}),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
};

75
src/pages/api/rough.ts Normal file
View File

@@ -0,0 +1,75 @@
//this is a rough api route that will delete all the orgs and repos in gitea. can be used for some other testing purposes as well
import { configs, db } from "@/lib/db";
import { deleteAllOrgs, deleteAllReposInGitea } from "@/lib/rough";
import type { APIRoute } from "astro";
import { eq } from "drizzle-orm";
export const GET: APIRoute = async ({ request }) => {
try {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response(JSON.stringify({ error: "Missing userId" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Fetch user configuration
const userConfig = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (userConfig.length === 0) {
return new Response(
JSON.stringify({ error: "No configuration found for this user" }),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
const config = userConfig[0];
if (!config.githubConfig || !config.githubConfig.token) {
return new Response(
JSON.stringify({ error: "GitHub token is missing in config" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
//adjust this based on the orgs you you want to delete
await deleteAllOrgs({
config,
orgs: [
"Neucruit",
"initify",
"BitBustersx719",
"uiastra",
"conductor-oss",
"HackForge-JUSL",
"vercel",
],
});
await deleteAllReposInGitea({
config,
});
return new Response(JSON.stringify({ message: "Process completed." }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Error in long-running process:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,68 @@
import type { APIRoute } from "astro";
import { redisSubscriber } from "@/lib/redis";
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return new Response("Missing userId", { status: 400 });
}
const channel = `mirror-status:${userId}`;
let isClosed = false;
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const handleMessage = (ch: string, message: string) => {
if (isClosed || ch !== channel) return;
try {
controller.enqueue(encoder.encode(`data: ${message}\n\n`));
} catch (err) {
console.error("Stream enqueue error:", err);
}
};
redisSubscriber.subscribe(channel, (err) => {
if (err) {
isClosed = true;
controller.error(err);
}
});
redisSubscriber.on("message", handleMessage);
try {
controller.enqueue(encoder.encode(": connected\n\n"));
} catch (err) {
console.error("Initial enqueue error:", err);
}
request.signal?.addEventListener("abort", () => {
if (!isClosed) {
isClosed = true;
redisSubscriber.off("message", handleMessage);
redisSubscriber.unsubscribe(channel);
controller.close();
}
});
},
cancel() {
// extra safety in case cancel is triggered
if (!isClosed) {
isClosed = true;
redisSubscriber.unsubscribe(channel);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
};

177
src/pages/api/sync/index.ts Normal file
View File

@@ -0,0 +1,177 @@
import type { APIRoute } from "astro";
import { db, organizations, repositories, configs } from "@/lib/db";
import { eq } from "drizzle-orm";
import { v4 as uuidv4 } from "uuid";
import { createMirrorJob } from "@/lib/helpers";
import {
createGitHubClient,
getGithubOrganizations,
getGithubRepositories,
getGithubStarredRepositories,
} from "@/lib/github";
import { jsonResponse } from "@/lib/utils";
export const POST: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
if (!userId) {
return jsonResponse({ data: { error: "Missing userId" }, status: 400 });
}
try {
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (!config) {
return jsonResponse({
data: { error: "No configuration found for this user" },
status: 404,
});
}
const token = config.githubConfig?.token;
if (!token) {
return jsonResponse({
data: { error: "GitHub token is missing in config" },
status: 400,
});
}
const octokit = createGitHubClient(token);
// Fetch GitHub data in parallel
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
getGithubRepositories({ octokit, config }),
config.githubConfig?.mirrorStarred
? getGithubStarredRepositories({ octokit, config })
: Promise.resolve([]),
getGithubOrganizations({ octokit, config }),
]);
const allGithubRepos = [...basicAndForkedRepos, ...starredRepos];
// Prepare full list of repos and orgs
const newRepos = allGithubRepos.map((repo) => ({
id: uuidv4(),
userId,
configId: config.id,
name: repo.name,
fullName: repo.fullName,
url: repo.url,
cloneUrl: repo.cloneUrl,
owner: repo.owner,
organization: repo.organization,
isPrivate: repo.isPrivate,
isForked: repo.isForked,
forkedFrom: repo.forkedFrom,
hasIssues: repo.hasIssues,
isStarred: repo.isStarred,
isArchived: repo.isArchived,
size: repo.size,
hasLFS: repo.hasLFS,
hasSubmodules: repo.hasSubmodules,
defaultBranch: repo.defaultBranch,
visibility: repo.visibility,
status: repo.status,
lastMirrored: repo.lastMirrored,
errorMessage: repo.errorMessage,
createdAt: repo.createdAt,
updatedAt: repo.updatedAt,
}));
const newOrgs = gitOrgs.map((org) => ({
id: uuidv4(),
userId,
configId: config.id,
name: org.name,
avatarUrl: org.avatarUrl,
membershipRole: org.membershipRole,
isIncluded: false,
status: org.status,
repositoryCount: org.repositoryCount,
createdAt: new Date(),
updatedAt: new Date(),
}));
let insertedRepos: typeof newRepos = [];
let insertedOrgs: typeof newOrgs = [];
// Transaction to insert only new items
await db.transaction(async (tx) => {
const [existingRepos, existingOrgs] = await Promise.all([
tx
.select({ fullName: repositories.fullName })
.from(repositories)
.where(eq(repositories.userId, userId)),
tx
.select({ name: organizations.name })
.from(organizations)
.where(eq(organizations.userId, userId)),
]);
const existingRepoNames = new Set(existingRepos.map((r) => r.fullName));
const existingOrgNames = new Set(existingOrgs.map((o) => o.name));
insertedRepos = newRepos.filter(
(r) => !existingRepoNames.has(r.fullName)
);
insertedOrgs = newOrgs.filter((o) => !existingOrgNames.has(o.name));
if (insertedRepos.length > 0) {
await tx.insert(repositories).values(insertedRepos);
}
if (insertedOrgs.length > 0) {
await tx.insert(organizations).values(insertedOrgs);
}
});
// Create mirror jobs only for newly inserted items
const mirrorJobPromises = [
...insertedRepos.map((repo) =>
createMirrorJob({
userId,
repositoryId: repo.id,
repositoryName: repo.name,
status: "imported",
message: `Repository ${repo.name} fetched successfully`,
details: `Repository ${repo.name} was fetched from GitHub`,
})
),
...insertedOrgs.map((org) =>
createMirrorJob({
userId,
organizationId: org.id,
organizationName: org.name,
status: "imported",
message: `Organization ${org.name} fetched successfully`,
details: `Organization ${org.name} was fetched from GitHub`,
})
),
];
await Promise.all(mirrorJobPromises);
return jsonResponse({
data: {
success: true,
message: "Repositories and organizations synced successfully",
newRepositories: insertedRepos.length,
newOrganizations: insertedOrgs.length,
},
});
} catch (error) {
console.error("Error syncing GitHub data for user:", userId, error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,136 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, organizations, repositories } from "@/lib/db";
import { and, eq } from "drizzle-orm";
import { jsonResponse } from "@/lib/utils";
import type {
AddOrganizationApiRequest,
AddOrganizationApiResponse,
} from "@/types/organizations";
import type { RepositoryVisibility, RepoStatus } from "@/types/Repository";
import { v4 as uuidv4 } from "uuid";
export const POST: APIRoute = async ({ request }) => {
try {
const body: AddOrganizationApiRequest = await request.json();
const { role, org, userId } = body;
if (!org || !userId || !role) {
return jsonResponse({
data: { success: false, error: "Missing org, role or userId" },
status: 400,
});
}
// Check if org already exists
const existingOrg = await db
.select()
.from(organizations)
.where(
and(eq(organizations.name, org), eq(organizations.userId, userId))
);
if (existingOrg.length > 0) {
return jsonResponse({
data: {
success: false,
error: "Organization already exists for this user",
},
status: 400,
});
}
// Get user's config
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (!config) {
return jsonResponse({
data: { error: "No configuration found for this user" },
status: 404,
});
}
const configId = config.id;
const octokit = new Octokit();
// Fetch org metadata
const { data: orgData } = await octokit.orgs.get({ org });
// Fetch public repos using Octokit paginator
const publicRepos = await octokit.paginate(octokit.repos.listForOrg, {
org,
type: "public",
per_page: 100,
});
// Insert repositories
const repoRecords = publicRepos.map((repo) => ({
id: uuidv4(),
userId,
configId,
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url ?? "",
owner: repo.owner.login,
organization:
repo.owner.type === "Organization" ? repo.owner.login : null,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: undefined,
hasIssues: repo.has_issues,
isStarred: false,
isArchived: repo.archived,
size: repo.size,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repo.default_branch ?? "main",
visibility: (repo.visibility ?? "public") as RepositoryVisibility,
status: "imported" as RepoStatus,
lastMirrored: undefined,
errorMessage: undefined,
createdAt: repo.created_at ? new Date(repo.created_at) : new Date(),
updatedAt: repo.updated_at ? new Date(repo.updated_at) : new Date(),
}));
await db.insert(repositories).values(repoRecords);
// Insert organization metadata
const organizationRecord = {
id: uuidv4(),
userId,
configId,
name: orgData.login,
avatarUrl: orgData.avatar_url,
membershipRole: role,
isIncluded: false,
status: "imported" as RepoStatus,
repositoryCount: publicRepos.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) {
console.error("Error inserting organization/repositories:", error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
}
};

View File

@@ -0,0 +1,137 @@
import type { APIRoute } from "astro";
import { Octokit } from "@octokit/rest";
import { configs, db, repositories } from "@/lib/db";
import { v4 as uuidv4 } from "uuid";
import { and, eq } from "drizzle-orm";
import { type Repository } from "@/lib/db/schema";
import { jsonResponse } from "@/lib/utils";
import type {
AddRepositoriesApiRequest,
AddRepositoriesApiResponse,
RepositoryVisibility,
} from "@/types/Repository";
import { createMirrorJob } from "@/lib/helpers";
export const POST: APIRoute = async ({ request }) => {
try {
const body: AddRepositoriesApiRequest = await request.json();
const { owner, repo, userId } = body;
if (!owner || !repo || !userId) {
return new Response(
JSON.stringify({
success: false,
error: "Missing owner, repo, or userId",
}),
{ status: 400 }
);
}
// Check if repository with the same owner, name, and userId already exists
const existingRepo = await db
.select()
.from(repositories)
.where(
and(
eq(repositories.owner, owner),
eq(repositories.name, repo),
eq(repositories.userId, userId)
)
);
if (existingRepo.length > 0) {
return jsonResponse({
data: {
success: false,
error:
"Repository with this name and owner already exists for this user",
},
status: 400,
});
}
// Get user's active config
const [config] = await db
.select()
.from(configs)
.where(eq(configs.userId, userId))
.limit(1);
if (!config) {
return jsonResponse({
data: { error: "No configuration found for this user" },
status: 404,
});
}
const configId = config.id;
const octokit = new Octokit(); // No auth for public repos
const { data: repoData } = await octokit.rest.repos.get({ owner, repo });
const metadata = {
id: uuidv4(),
userId,
configId,
name: repoData.name,
fullName: repoData.full_name,
url: repoData.html_url,
cloneUrl: repoData.clone_url,
owner: repoData.owner.login,
organization:
repoData.owner.type === "Organization"
? repoData.owner.login
: undefined,
isPrivate: repoData.private,
isForked: repoData.fork,
forkedFrom: undefined,
hasIssues: repoData.has_issues,
isStarred: false,
isArchived: repoData.archived,
size: repoData.size,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repoData.default_branch,
visibility: (repoData.visibility ?? "public") as RepositoryVisibility,
status: "imported" as Repository["status"],
lastMirrored: undefined,
errorMessage: undefined,
createdAt: repoData.created_at
? new Date(repoData.created_at)
: new Date(),
updatedAt: repoData.updated_at
? new Date(repoData.updated_at)
: new Date(),
};
await db.insert(repositories).values(metadata);
createMirrorJob({
userId,
organizationId: metadata.organization,
organizationName: metadata.organization,
repositoryId: metadata.id,
repositoryName: metadata.name,
status: "imported",
message: `Repository ${metadata.name} fetched successfully`,
details: `Repository ${metadata.name} was fetched from GitHub`,
});
const resPayload: AddRepositoriesApiResponse = {
success: true,
repository: metadata,
message: "Repository added successfully",
};
return jsonResponse({ data: resPayload, status: 200 });
} catch (error) {
console.error("Error inserting repository:", error);
return jsonResponse({
data: {
error: error instanceof Error ? error.message : "Something went wrong",
},
status: 500,
});
}
};

24
src/pages/config.astro Normal file
View File

@@ -0,0 +1,24 @@
---
import '../styles/global.css';
import App, { MainLayout } from '@/components/layout/MainLayout';
import { ConfigTabs } from '@/components/config/ConfigTabs';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { db, configs } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { Button } from '@/components/ui/button';
import type { SaveConfigApiRequest,SaveConfigApiResponse } from '@/types/config';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Configuration - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page='configuration' client:load />
</body>
</html>

View File

@@ -0,0 +1,63 @@
---
import { getCollection } from 'astro:content';
import MainLayout from '../../layouts/main.astro';
// Enable prerendering for this dynamic route
export const prerender = true;
// Generate static paths for all documentation pages
export async function getStaticPaths() {
const docs = await getCollection('docs');
return docs.map(entry => ({
params: { slug: entry.slug },
props: { entry },
}));
}
// Get the documentation entry from props
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<MainLayout title={entry.data.title}>
<main class="max-w-5xl mx-auto px-4 py-12">
<div class="sticky top-4 z-10 mb-6">
<a
href="/docs/"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-card text-foreground hover:bg-muted transition-colors border border-border focus:ring-2 focus:ring-ring outline-none"
>
<span aria-hidden="true">&larr;</span> Back to Documentation
</a>
</div>
<article class="bg-card rounded-2xl shadow-lg p-6 border border-border">
<div class="prose prose-neutral dark:prose-invert prose-code:bg-muted prose-code:text-foreground prose-pre:bg-muted prose-pre:text-foreground prose-pre:rounded-lg prose-pre:p-4 prose-table:rounded-lg prose-table:bg-muted prose-th:text-foreground prose-td:text-muted-foreground prose-blockquote:border-l-4 prose-blockquote:border-muted prose-blockquote:bg-muted/50 prose-blockquote:p-4">
<Content />
</div>
</article>
<script type="module">
// Mermaid diagram rendering for code blocks
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: document.documentElement.classList.contains('dark') ? 'dark' : 'default' });
function renderMermaidDiagrams() {
document.querySelectorAll('pre code.language-mermaid').forEach((block, i) => {
const parent = block.parentElement;
if (!parent) return;
const code = block.textContent;
const id = `mermaid-diagram-${i}`;
const container = document.createElement('div');
container.className = 'my-6';
container.id = id;
parent.replaceWith(container);
mermaid.render(id, code, (svgCode) => {
container.innerHTML = svgCode;
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', renderMermaidDiagrams);
} else {
renderMermaidDiagrams();
}
</script>
</main>
</MainLayout>

View File

@@ -0,0 +1,44 @@
---
import { getCollection } from 'astro:content';
import MainLayout from '../../layouts/main.astro';
import { LuSettings, LuRocket, LuBookOpen } from 'react-icons/lu';
// Helper to pick an icon based on doc.slug
// We'll use inline conditional rendering instead of this function
// Get all documentation entries, sorted by order
const docs = await getCollection('docs');
const sortedDocs = docs.sort((a, b) => {
const orderA = a.data.order || 999;
const orderB = b.data.order || 999;
return orderA - orderB;
});
---
<MainLayout title="Documentation">
<main class="max-w-5xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-2 text-center text-foreground">Gitea Mirror Documentation</h1>
<p class="mb-10 text-lg text-muted-foreground text-center">Browse guides and technical docs for Gitea Mirror.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
{sortedDocs.map(doc => (
<a
href={`/docs/${doc.slug}`}
class="group block p-7 border border-border rounded-2xl bg-card hover:bg-muted transition-colors shadow-lg focus:ring-2 focus:ring-ring outline-none"
tabindex="0"
>
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 bg-muted rounded-full flex items-center justify-center text-muted-foreground">
{doc.slug === 'architecture' && <LuBookOpen className="w-5 h-5" />}
{doc.slug === 'configuration' && <LuSettings className="w-5 h-5" />}
{doc.slug === 'quickstart' && <LuRocket className="w-5 h-5" />}
{!['architecture', 'configuration', 'quickstart'].includes(doc.slug) && <LuBookOpen className="w-5 h-5" />}
</div>
<h2 class="text-xl font-semibold group-hover:text-foreground transition">{doc.data.title}</h2>
</div>
<p class="text-muted-foreground">{doc.data.description}</p>
</a>
))}
</div>
</main>
</MainLayout>

70
src/pages/index.astro Normal file
View File

@@ -0,0 +1,70 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, client } from '@/lib/db';
import ThemeScript from '@/components/theme/ThemeScript.astro';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0].count;
// Redirect to signup if no users exist
if (userCount === 0) {
return Astro.redirect('/signup');
}
// Fetch data from the database
let repoData:any[] = [];
let activityData = [];
try {
// Fetch repositories from database
const dbRepos = await db.select().from(repositories).limit(10);
repoData = dbRepos;
// Fetch recent activity from mirror jobs
const jobs = await db.select().from(mirrorJobs).limit(10);
activityData = jobs.flatMap((job: any) => {
// Check if log exists before parsing
if (!job.log) {
console.warn(`Job ${job.id} has no log data`);
return [];
}
try {
const log = JSON.parse(job.log);
if (!Array.isArray(log)) {
console.warn(`Job ${job.id} log is not an array`);
return [];
}
return log.map((entry: any) => ({
id: `${job.id}-${entry.timestamp}`,
message: entry.message,
timestamp: new Date(entry.timestamp),
status: entry.level,
}));
} catch (parseError) {
console.error(`Failed to parse log for job ${job.id}:`, parseError);
return [];
}
}).slice(0, 10);
} catch (error) {
console.error('Error fetching data:', error);
// Fallback to empty arrays if database access fails
repoData = [];
activityData = [];
}
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Dashboard - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page='dashboard' client:load />
</body>
</html>

33
src/pages/login.astro Normal file
View File

@@ -0,0 +1,33 @@
---
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { LoginForm } from '@/components/auth/LoginForm';
import { client } from '../lib/db';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0].count;
// Redirect to signup if no users exist
if (userCount === 0) {
return Astro.redirect('/signup');
}
const generator = Astro.generator;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={generator} />
<title>Login - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<div class="h-dvh flex items-center justify-center bg-muted/30 p-4">
<LoginForm client:load />
</div>
</body>
</html>

View File

@@ -0,0 +1,16 @@
---
title: 'Markdown + Tailwind'
layout: ../layouts/main.astro
---
<div class="grid place-items-center h-screen content-center">
<div class="py-2 px-4 bg-purple-500 text-white font-semibold rounded-lg shadow-md">
Tailwind classes also work in Markdown!
</div>
<a
href="/"
class="p-4 underline hover:text-purple-500 transition-colors ease-in-out duration-200"
>
Go home
</a>
</div>

View File

@@ -0,0 +1,22 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Organizations - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page="organizations" client:load />
</body>
</html>

View File

@@ -0,0 +1,21 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import ThemeScript from '@/components/theme/ThemeScript.astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<title>Repositories - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<App page='repositories' client:load />
</body>
</html>

37
src/pages/signup.astro Normal file
View File

@@ -0,0 +1,37 @@
---
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { SignupForm } from '@/components/auth/SignupForm';
import { client } from '../lib/db';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0]?.count;
// Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) {
return Astro.redirect('/login');
}
const generator = Astro.generator;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={generator} />
<title>Setup Admin Account - Gitea Mirror</title>
<ThemeScript />
</head>
<body>
<div class="h-dvh flex flex-col items-center justify-center bg-muted/30 p-4">
<div class="mb-8 text-center">
<h1 class="text-3xl font-bold mb-2">Welcome to Gitea Mirror</h1>
<p class="text-muted-foreground">Let's set up your administrator account to get started.</p>
</div>
<SignupForm client:load />
</div>
</body>
</html>