🎉 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

90
src/lib/api.ts Normal file
View File

@@ -0,0 +1,90 @@
// Base API URL
const API_BASE = "/api";
// Helper function for API requests
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE}${endpoint}`;
const headers = {
"Content-Type": "application/json",
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
message: "An unknown error occurred",
}));
throw new Error(error.message || "An unknown error occurred");
}
return response.json();
}
// Auth API
export const authApi = {
login: async (username: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include", // Send cookies
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("Login failed");
return await res.json(); // returns user
},
register: async (username: string, email: string, password: string) => {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ username, email, password }),
});
if (!res.ok) throw new Error("Registration failed");
return await res.json(); // returns user
},
getCurrentUser: async () => {
const res = await fetch(`${API_BASE}/auth`, {
method: "GET",
credentials: "include", // Send cookies
});
if (!res.ok) throw new Error("Not authenticated");
return await res.json();
},
logout: async () => {
await fetch(`${API_BASE}/auth/logout`, {
method: "POST",
credentials: "include",
});
},
};
// GitHub API
export const githubApi = {
testConnection: (token: string) =>
apiRequest<{ success: boolean }>("/github/test-connection", {
method: "POST",
body: JSON.stringify({ token }),
}),
};
// Gitea API
export const giteaApi = {
testConnection: (url: string, token: string) =>
apiRequest<{ success: boolean }>("/gitea/test-connection", {
method: "POST",
body: JSON.stringify({ url, token }),
}),
};

28
src/lib/config.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* Application configuration
*/
// Environment variables
export const ENV = {
// Node environment (development, production, test)
NODE_ENV: process.env.NODE_ENV || "development",
// Database URL - use SQLite by default
get DATABASE_URL() {
// If explicitly set, use the provided DATABASE_URL
if (process.env.DATABASE_URL) {
return process.env.DATABASE_URL;
}
// Otherwise, use the default database
return "sqlite://data/gitea-mirror.db";
},
// JWT secret for authentication
JWT_SECRET:
process.env.JWT_SECRET || "your-secret-key-change-this-in-production",
// Server host and port
HOST: process.env.HOST || "localhost",
PORT: parseInt(process.env.PORT || "3000", 10),
};

177
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,177 @@
import { z } from "zod";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import path from "path";
import { configSchema } from "./schema";
// Define the database URL - for development we'll use a local SQLite file
const dataDir = path.join(process.cwd(), "data");
const dbUrl =
process.env.DATABASE_URL || `file:${path.join(dataDir, "gitea-mirror.db")}`;
// Create a client connection to the database
export const client = createClient({ url: dbUrl });
// Create a drizzle instance
export const db = drizzle(client);
// Define the tables
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
password: text("password").notNull(),
email: text("email").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
const githubSchema = configSchema.shape.githubConfig;
const giteaSchema = configSchema.shape.giteaConfig;
const scheduleSchema = configSchema.shape.scheduleConfig;
export const configs = sqliteTable("configs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
githubConfig: text("github_config", { mode: "json" })
.$type<z.infer<typeof githubSchema>>()
.notNull(),
giteaConfig: text("gitea_config", { mode: "json" })
.$type<z.infer<typeof giteaSchema>>()
.notNull(),
include: text("include", { mode: "json" })
.$type<string[]>()
.notNull()
.default(["*"]),
exclude: text("exclude", { mode: "json" })
.$type<string[]>()
.notNull()
.default([]),
scheduleConfig: text("schedule_config", { mode: "json" })
.$type<z.infer<typeof scheduleSchema>>()
.notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const repositories = sqliteTable("repositories", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
fullName: text("full_name").notNull(),
url: text("url").notNull(),
cloneUrl: text("clone_url").notNull(),
owner: text("owner").notNull(),
organization: text("organization"),
mirroredLocation: text("mirrored_location").default(""),
isPrivate: integer("is_private", { mode: "boolean" })
.notNull()
.default(false),
isForked: integer("is_fork", { mode: "boolean" }).notNull().default(false),
forkedFrom: text("forked_from"),
hasIssues: integer("has_issues", { mode: "boolean" })
.notNull()
.default(false),
isStarred: integer("is_starred", { mode: "boolean" })
.notNull()
.default(false),
isArchived: integer("is_archived", { mode: "boolean" })
.notNull()
.default(false),
size: integer("size").notNull().default(0),
hasLFS: integer("has_lfs", { mode: "boolean" }).notNull().default(false),
hasSubmodules: integer("has_submodules", { mode: "boolean" })
.notNull()
.default(false),
defaultBranch: text("default_branch").notNull(),
visibility: text("visibility").notNull().default("public"),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const mirrorJobs = sqliteTable("mirror_jobs", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
repositoryId: text("repository_id"),
repositoryName: text("repository_name"),
organizationId: text("organization_id"),
organizationName: text("organization_name"),
details: text("details"),
status: text("status").notNull().default("imported"),
message: text("message").notNull(),
timestamp: integer("timestamp", { mode: "timestamp" })
.notNull()
.default(new Date()),
});
export const organizations = sqliteTable("organizations", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id),
configId: text("config_id")
.notNull()
.references(() => configs.id),
name: text("name").notNull(),
avatarUrl: text("avatar_url").notNull(),
membershipRole: text("membership_role").notNull().default("member"),
isIncluded: integer("is_included", { mode: "boolean" })
.notNull()
.default(true),
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
repositoryCount: integer("repository_count").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(new Date()),
});

View File

@@ -0,0 +1,27 @@
import { client } from "@/lib/db";
/**
* Migration script to add the mirrored_location column to the repositories table
*/
export async function addMirroredLocationColumn() {
try {
console.log("Starting migration: Adding mirrored_location column to repositories table");
// Check if the column already exists
const tableInfo = await client.execute(`PRAGMA table_info(repositories)`);
const columnExists = tableInfo.rows.some((row: any) => row.name === "mirrored_location");
if (columnExists) {
console.log("Column mirrored_location already exists, skipping migration");
return;
}
// Add the mirrored_location column
await client.execute(`ALTER TABLE repositories ADD COLUMN mirrored_location TEXT DEFAULT ''`);
console.log("Migration completed successfully: mirrored_location column added");
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}

75
src/lib/db/schema.sql Normal file
View File

@@ -0,0 +1,75 @@
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- Configurations table
CREATE TABLE IF NOT EXISTS configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
github_config TEXT NOT NULL,
gitea_config TEXT NOT NULL,
schedule_config TEXT NOT NULL,
include TEXT NOT NULL,
exclude TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
-- Repositories table
CREATE TABLE IF NOT EXISTS repositories (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
full_name TEXT NOT NULL,
url TEXT NOT NULL,
is_private BOOLEAN NOT NULL,
is_fork BOOLEAN NOT NULL,
owner TEXT NOT NULL,
organization TEXT,
mirrored_location TEXT DEFAULT '',
has_issues BOOLEAN NOT NULL,
is_starred BOOLEAN NOT NULL,
status TEXT NOT NULL,
error_message TEXT,
last_mirrored DATETIME,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Organizations table
CREATE TABLE IF NOT EXISTS organizations (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
is_included BOOLEAN NOT NULL,
repository_count INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE
);
-- Mirror jobs table
CREATE TABLE IF NOT EXISTS mirror_jobs (
id TEXT PRIMARY KEY,
config_id TEXT NOT NULL,
repository_id TEXT,
status TEXT NOT NULL,
started_at DATETIME NOT NULL,
completed_at DATETIME,
log TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (config_id) REFERENCES configs (id) ON DELETE CASCADE,
FOREIGN KEY (repository_id) REFERENCES repositories (id) ON DELETE SET NULL
);

142
src/lib/db/schema.ts Normal file
View File

@@ -0,0 +1,142 @@
import { z } from "zod";
import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import { membershipRoleEnum } from "@/types/organizations";
// User schema
export const userSchema = z.object({
id: z.string().uuid().optional(),
username: z.string().min(3),
password: z.string().min(8).optional(), // Hashed password
email: z.string().email(),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type User = z.infer<typeof userSchema>;
// Configuration schema
export const configSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid(),
name: z.string().min(1),
isActive: z.boolean().default(true),
githubConfig: z.object({
username: z.string().min(1),
token: z.string().optional(),
skipForks: z.boolean().default(false),
privateRepositories: z.boolean().default(false),
mirrorIssues: z.boolean().default(false),
mirrorStarred: z.boolean().default(false),
useSpecificUser: z.boolean().default(false),
singleRepo: z.string().optional(),
includeOrgs: z.array(z.string()).default([]),
excludeOrgs: z.array(z.string()).default([]),
mirrorPublicOrgs: z.boolean().default(false),
publicOrgs: z.array(z.string()).default([]),
preserveOrgStructure: z.boolean().default(false),
skipStarredIssues: z.boolean().default(false),
}),
giteaConfig: z.object({
username: z.string().min(1),
url: z.string().url(),
token: z.string().min(1),
organization: z.string().optional(),
visibility: z.enum(["public", "private", "limited"]).default("public"),
starredReposOrg: z.string().default("github"),
}),
include: z.array(z.string()).default(["*"]),
exclude: z.array(z.string()).default([]),
scheduleConfig: z.object({
enabled: z.boolean().default(false),
interval: z.number().min(1).default(3600), // in seconds
lastRun: z.date().optional(),
nextRun: z.date().optional(),
}),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Config = z.infer<typeof configSchema>;
// Repository schema
export const repositorySchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
name: z.string().min(1),
fullName: z.string().min(1),
url: z.string().url(),
cloneUrl: z.string().url(),
owner: z.string().min(1),
organization: z.string().optional(),
isPrivate: z.boolean().default(false),
isForked: z.boolean().default(false),
forkedFrom: z.string().optional(),
hasIssues: z.boolean().default(false),
isStarred: z.boolean().default(false),
isArchived: z.boolean().default(false),
size: z.number(),
hasLFS: z.boolean().default(false),
hasSubmodules: z.boolean().default(false),
defaultBranch: z.string(),
visibility: repositoryVisibilityEnum.default("public"),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Repository = z.infer<typeof repositorySchema>;
// Mirror job schema
export const mirrorJobSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
repositoryId: z.string().uuid().optional(),
repositoryName: z.string().optional(),
organizationId: z.string().uuid().optional(),
organizationName: z.string().optional(),
details: z.string().optional(),
status: repoStatusEnum.default("imported"),
message: z.string(),
timestamp: z.date().default(() => new Date()),
});
export type MirrorJob = z.infer<typeof mirrorJobSchema>;
// Organization schema
export const organizationSchema = z.object({
id: z.string().uuid().optional(),
userId: z.string().uuid().optional(),
configId: z.string().uuid(),
avatarUrl: z.string().url(),
name: z.string().min(1),
membershipRole: membershipRoleEnum.default("member"),
isIncluded: z.boolean().default(false),
status: repoStatusEnum.default("imported"),
lastMirrored: z.date().optional(),
errorMessage: z.string().optional(),
repositoryCount: z.number().default(0),
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
export type Organization = z.infer<typeof organizationSchema>;

962
src/lib/gitea.ts Normal file
View File

@@ -0,0 +1,962 @@
import {
repoStatusEnum,
type RepositoryVisibility,
type RepoStatus,
} from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config";
import type { Organization, Repository } from "./db/schema";
import superagent from "superagent";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
import { eq } from "drizzle-orm";
export const getGiteaRepoOwner = ({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}): string => {
if (!config.githubConfig || !config.giteaConfig) {
throw new Error("GitHub or Gitea config is required.");
}
if (!config.giteaConfig.username) {
throw new Error("Gitea username is required.");
}
// if the config has preserveOrgStructure set to true, then use the org name as the owner
if (config.githubConfig.preserveOrgStructure && repository.organization) {
return repository.organization;
}
// if the config has preserveOrgStructure set to false, then use the gitea username as the owner
return config.giteaConfig.username;
};
export const isRepoPresentInGitea = async ({
config,
owner,
repoName,
}: {
config: Partial<Config>;
owner: string;
repoName: string;
}): Promise<boolean> => {
try {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
// Check if the repository exists at the specified owner location
const response = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
return response.ok;
} catch (error) {
console.error("Error checking if repo exists in Gitea:", error);
return false;
}
};
/**
* Helper function to check if a repository exists in Gitea.
* First checks the recorded mirroredLocation, then falls back to the expected location.
*/
export const checkRepoLocation = async ({
config,
repository,
expectedOwner,
}: {
config: Partial<Config>;
repository: Repository;
expectedOwner: string;
}): Promise<{ present: boolean; actualOwner: string }> => {
// First check if we have a recorded mirroredLocation and if the repo exists there
if (repository.mirroredLocation && repository.mirroredLocation.trim() !== "") {
const [mirroredOwner] = repository.mirroredLocation.split('/');
if (mirroredOwner) {
const mirroredPresent = await isRepoPresentInGitea({
config,
owner: mirroredOwner,
repoName: repository.name,
});
if (mirroredPresent) {
console.log(`Repository found at recorded mirrored location: ${repository.mirroredLocation}`);
return { present: true, actualOwner: mirroredOwner };
}
}
}
// If not found at the recorded location, check the expected location
const present = await isRepoPresentInGitea({
config,
owner: expectedOwner,
repoName: repository.name,
});
if (present) {
return { present: true, actualOwner: expectedOwner };
}
// Repository not found at any location
return { present: false, actualOwner: expectedOwner };
};
export const mirrorGithubRepoToGitea = async ({
octokit,
repository,
config,
}: {
octokit: Octokit;
repository: Repository;
config: Partial<Config>;
}): Promise<any> => {
try {
if (!config.userId || !config.githubConfig || !config.giteaConfig) {
throw new Error("github config and gitea config are required.");
}
if (!config.giteaConfig.username) {
throw new Error("Gitea username is required.");
}
const isExisting = await isRepoPresentInGitea({
config,
owner: config.giteaConfig.username,
repoName: repository.name,
});
if (isExisting) {
console.log(
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
);
return;
}
console.log(`Mirroring repository ${repository.name}`);
// Mark repos as "mirroring" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started mirroring repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the mirroring state.`,
status: "mirroring",
});
let cloneAddress = repository.cloneUrl;
// If the repository is private, inject the GitHub token into the clone URL
if (repository.isPrivate) {
if (!config.githubConfig.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${config.githubConfig.token}@`
);
}
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const response = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`)
.set("Content-Type", "application/json")
.send({
clone_addr: cloneAddress,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
repo_owner: config.giteaConfig.username,
description: "",
service: "git",
});
// clone issues
if (config.githubConfig.mirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
repository,
isRepoInOrg: false,
});
}
console.log(`Repository ${repository.name} mirrored successfully`);
// Mark repos as "mirrored" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Successfully mirrored repository: ${repository.name}`,
details: `Repository ${repository.name} was mirrored to Gitea.`,
status: "mirrored",
});
return response.body;
} catch (error) {
console.error(
`Error while mirroring repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId ?? "", // userId is going to be there anyways
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to mirror repository: ${repository.name}`,
details: `Repository ${repository.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: "failed",
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
};
export async function getOrCreateGiteaOrg({
orgName,
orgId,
config,
}: {
orgId?: string; //db id
orgName: string;
config: Partial<Config>;
}): Promise<number> {
if (
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.userId
) {
throw new Error("Gitea config is required.");
}
try {
const orgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
"Content-Type": "application/json",
},
}
);
if (orgRes.ok) {
const org = await orgRes.json();
await createMirrorJob({
userId: config.userId,
organizationId: org.id,
organizationName: orgName,
status: "imported",
message: `Organization ${orgName} fetched successfully`,
details: `Organization ${orgName} was fetched from GitHub`,
});
return org.id;
}
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
method: "POST",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
username: orgName,
full_name: `${orgName} Org`,
description: `Mirrored organization from GitHub ${orgName}`,
visibility: "public",
}),
});
if (!createRes.ok) {
throw new Error(`Failed to create Gitea org: ${await createRes.text()}`);
}
await createMirrorJob({
userId: config.userId,
organizationName: orgName,
status: "imported",
message: `Organization ${orgName} created successfully`,
details: `Organization ${orgName} was created in Gitea`,
});
const newOrg = await createRes.json();
return newOrg.id;
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: "Unknown error occurred in getOrCreateGiteaOrg.";
await createMirrorJob({
userId: config.userId,
organizationId: orgId,
organizationName: orgName,
message: `Failed to create or fetch Gitea organization: ${orgName}`,
status: "failed",
details: `Error: ${errorMessage}`,
});
throw new Error(`Error in getOrCreateGiteaOrg: ${errorMessage}`);
}
}
export async function mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository,
giteaOrgId,
orgName,
}: {
octokit: Octokit;
config: Partial<Config>;
repository: Repository;
giteaOrgId: number;
orgName: string;
}) {
try {
if (
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.userId
) {
throw new Error("Gitea config is required.");
}
const isExisting = await isRepoPresentInGitea({
config,
owner: orgName,
repoName: repository.name,
});
if (isExisting) {
console.log(
`Repository ${repository.name} already exists in Gitea. Skipping migration.`
);
return;
}
console.log(
`Mirroring repository ${repository.name} to organization ${orgName}`
);
let cloneAddress = repository.cloneUrl;
if (repository.isPrivate) {
if (!config.githubConfig?.token) {
throw new Error(
"GitHub token is required to mirror private repositories."
);
}
cloneAddress = repository.cloneUrl.replace(
"https://",
`https://${config.githubConfig.token}@`
);
}
// Mark repos as "mirroring" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started mirroring repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the mirroring state.`,
status: "mirroring",
});
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
const migrateRes = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`)
.set("Content-Type", "application/json")
.send({
clone_addr: cloneAddress,
uid: giteaOrgId,
repo_name: repository.name,
mirror: true,
private: repository.isPrivate,
});
// Clone issues
if (config.githubConfig?.mirrorIssues) {
await mirrorGitRepoIssuesToGitea({
config,
octokit,
repository,
isRepoInOrg: true,
});
}
console.log(
`Repository ${repository.name} mirrored successfully to organization ${orgName}`
);
// Mark repos as "mirrored" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${orgName}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
//create a mirror job
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Repository ${repository.name} mirrored successfully`,
details: `Repository ${repository.name} was mirrored to Gitea`,
status: "mirrored",
});
return migrateRes.body;
} catch (error) {
console.error(
`Error while mirroring repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark repos as "failed" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(repositories.id, repository.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId || "", // userId is going to be there anyways
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to mirror repository: ${repository.name}`,
details: `Repository ${repository.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: "failed",
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export async function mirrorGitHubOrgRepoToGiteaOrg({
config,
octokit,
repository,
orgName,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
orgName: string;
}) {
try {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
const giteaOrgId = await getOrCreateGiteaOrg({
orgName,
config,
});
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository,
giteaOrgId,
orgName,
});
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export async function mirrorGitHubOrgToGitea({
organization,
octokit,
config,
}: {
organization: Organization;
octokit: Octokit;
config: Partial<Config>;
}) {
try {
if (
!config.userId ||
!config.id ||
!config.githubConfig?.token ||
!config.giteaConfig?.url
) {
throw new Error("Config, GitHub token and Gitea URL are required.");
}
console.log(`Mirroring organization ${organization.name}`);
//mark the org as "mirroring" in DB
await db
.update(organizations)
.set({
isIncluded: true,
status: repoStatusEnum.parse("mirroring"),
updatedAt: new Date(),
})
.where(eq(organizations.id, organization.id!));
// Append log for "mirroring" status
await createMirrorJob({
userId: config.userId,
organizationId: organization.id,
organizationName: organization.name,
message: `Started mirroring organization: ${organization.name}`,
details: `Organization ${organization.name} is now in the mirroring state.`,
status: repoStatusEnum.parse("mirroring"),
});
const giteaOrgId = await getOrCreateGiteaOrg({
orgId: organization.id,
orgName: organization.name,
config,
});
//query the db with the org name and get the repos
const orgRepos = await db
.select()
.from(repositories)
.where(eq(repositories.organization, organization.name));
for (const repo of orgRepos) {
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: {
...repo,
status: repo.status as RepoStatus,
visibility: repo.visibility as RepositoryVisibility,
lastMirrored: repo.lastMirrored ?? undefined,
errorMessage: repo.errorMessage ?? undefined,
organization: repo.organization ?? undefined,
forkedFrom: repo.forkedFrom ?? undefined,
mirroredLocation: repo.mirroredLocation || "",
},
giteaOrgId,
orgName: organization.name,
});
}
console.log(`Organization ${organization.name} mirrored successfully`);
// Mark org as "mirrored" in DB
await db
.update(organizations)
.set({
status: repoStatusEnum.parse("mirrored"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
})
.where(eq(organizations.id, organization.id!));
// Append log for "mirrored" status
await createMirrorJob({
userId: config.userId,
organizationId: organization.id,
organizationName: organization.name,
message: `Successfully mirrored organization: ${organization.name}`,
details: `Organization ${organization.name} was mirrored to Gitea.`,
status: repoStatusEnum.parse("mirrored"),
});
} catch (error) {
console.error(
`Error while mirroring organization ${organization.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Mark org as "failed" in DB
await db
.update(organizations)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: error instanceof Error ? error.message : "Unknown error",
})
.where(eq(organizations.id, organization.id!));
// Append log for failure
await createMirrorJob({
userId: config.userId || "", // userId is going to be there anyways
organizationId: organization.id,
organizationName: organization.name,
message: `Failed to mirror organization: ${organization.name}`,
details: `Organization ${organization.name} failed to mirror. Error: ${
error instanceof Error ? error.message : "Unknown error"
}`,
status: repoStatusEnum.parse("failed"),
});
if (error instanceof Error) {
throw new Error(`Failed to mirror repository: ${error.message}`);
}
throw new Error("Failed to mirror repository: An unknown error occurred.");
}
}
export const syncGiteaRepo = async ({
config,
repository,
}: {
config: Partial<Config>;
repository: Repository;
}) => {
try {
if (
!config.userId ||
!config.giteaConfig?.url ||
!config.giteaConfig?.token ||
!config.giteaConfig?.username
) {
throw new Error("Gitea config is required.");
}
console.log(`Syncing repository ${repository.name}`);
// Mark repo as "syncing" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("syncing"),
updatedAt: new Date(),
})
.where(eq(repositories.id, repository.id!));
// Append log for "syncing" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Started syncing repository: ${repository.name}`,
details: `Repository ${repository.name} is now in the syncing state.`,
status: repoStatusEnum.parse("syncing"),
});
// Get the expected owner based on current config
const repoOwner = getGiteaRepoOwner({ config, repository });
// Check if repo exists at the expected location or alternate location
const { present, actualOwner } = await checkRepoLocation({
config,
repository,
expectedOwner: repoOwner
});
if (!present) {
throw new Error(`Repository ${repository.name} not found in Gitea at any expected location`);
}
// Use the actual owner where the repo was found
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/${actualOwner}/${repository.name}/mirror-sync`;
const response = await superagent
.post(apiUrl)
.set("Authorization", `token ${config.giteaConfig.token}`);
// Mark repo as "synced" in DB
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("synced"),
updatedAt: new Date(),
lastMirrored: new Date(),
errorMessage: null,
mirroredLocation: `${actualOwner}/${repository.name}`,
})
.where(eq(repositories.id, repository.id!));
// Append log for "synced" status
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Successfully synced repository: ${repository.name}`,
details: `Repository ${repository.name} was synced with Gitea.`,
status: repoStatusEnum.parse("synced"),
});
console.log(`Repository ${repository.name} synced successfully`);
return response.body;
} catch (error) {
console.error(
`Error while syncing repository ${repository.name}: ${
error instanceof Error ? error.message : String(error)
}`
);
// Optional: update repo with error status
await db
.update(repositories)
.set({
status: repoStatusEnum.parse("failed"),
updatedAt: new Date(),
errorMessage: (error as Error).message,
})
.where(eq(repositories.id, repository.id!));
// Append log for "error" status
if (config.userId && repository.id && repository.name) {
await createMirrorJob({
userId: config.userId,
repositoryId: repository.id,
repositoryName: repository.name,
message: `Failed to sync repository: ${repository.name}`,
details: (error as Error).message,
status: repoStatusEnum.parse("failed"),
});
}
if (error instanceof Error) {
throw new Error(`Failed to sync repository: ${error.message}`);
}
throw new Error("Failed to sync repository: An unknown error occurred.");
}
};
export const mirrorGitRepoIssuesToGitea = async ({
config,
octokit,
repository,
isRepoInOrg,
}: {
config: Partial<Config>;
octokit: Octokit;
repository: Repository;
isRepoInOrg: boolean;
}) => {
//things covered here are- issue, title, body, labels, comments and assignees
if (
!config.githubConfig?.token ||
!config.giteaConfig?.token ||
!config.giteaConfig?.url ||
!config.giteaConfig?.username
) {
throw new Error("Missing GitHub or Gitea configuration.");
}
const repoOrigin = isRepoInOrg
? repository.organization
: config.githubConfig.username;
const [owner, repo] = repository.fullName.split("/");
// Fetch GitHub issues
const issues = await octokit.paginate(
octokit.rest.issues.listForRepo,
{
owner,
repo,
state: "all",
per_page: 100,
},
(res) => res.data
);
console.log(`Mirroring ${issues.length} issues from ${repository.fullName}`);
// Get existing labels from Gitea
const giteaLabelsRes = await superagent
.get(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
)
.set("Authorization", `token ${config.giteaConfig.token}`);
const giteaLabels = giteaLabelsRes.body;
const labelMap = new Map<string, number>(
giteaLabels.map((label: any) => [label.name, label.id])
);
for (const issue of issues) {
if ((issue as any).pull_request) {
continue;
}
const githubLabelNames =
issue.labels
?.map((l) => (typeof l === "string" ? l : l.name))
.filter((l): l is string => !!l) || [];
const giteaLabelIds: number[] = [];
// Resolve or create labels in Gitea
for (const name of githubLabelNames) {
if (labelMap.has(name)) {
giteaLabelIds.push(labelMap.get(name)!);
} else {
try {
const created = await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/labels`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send({ name, color: "#ededed" }); // Default color
labelMap.set(name, created.body.id);
giteaLabelIds.push(created.body.id);
} catch (labelErr) {
console.error(
`Failed to create label "${name}" in Gitea: ${labelErr}`
);
}
}
}
const originalAssignees =
issue.assignees && issue.assignees.length > 0
? `\n\nOriginally assigned to: ${issue.assignees
.map((a) => `@${a.login}`)
.join(", ")} on GitHub.`
: "";
const issuePayload: any = {
title: issue.title,
body: `Originally created by @${
issue.user?.login
} on GitHub.${originalAssignees}\n\n${issue.body || ""}`,
closed: issue.state === "closed",
labels: giteaLabelIds,
};
try {
const createdIssue = await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send(issuePayload);
// Clone comments
const comments = await octokit.paginate(
octokit.rest.issues.listComments,
{
owner,
repo,
issue_number: issue.number,
per_page: 100,
},
(res) => res.data
);
for (const comment of comments) {
try {
await superagent
.post(
`${config.giteaConfig.url}/api/v1/repos/${repoOrigin}/${repository.name}/issues/${createdIssue.body.number}/comments`
)
.set("Authorization", `token ${config.giteaConfig.token}`)
.send({
body: `@${comment.user?.login} commented on GitHub:\n\n${comment.body}`,
});
} catch (commentErr) {
console.error(
`Failed to copy comment to Gitea for issue "${issue.title}": ${
commentErr instanceof Error
? commentErr.message
: String(commentErr)
}`
);
}
}
} catch (err) {
if (err instanceof Error && (err as any).response) {
console.error(
`Failed to create issue "${issue.title}" in Gitea: ${err.message}`
);
console.error(
`Response body: ${JSON.stringify((err as any).response.body)}`
);
} else {
console.error(
`Failed to create issue "${issue.title}" in Gitea: ${
err instanceof Error ? err.message : String(err)
}`
);
}
}
}
};

265
src/lib/github.ts Normal file
View File

@@ -0,0 +1,265 @@
import type { GitOrg, MembershipRole } from "@/types/organizations";
import type { GitRepo, RepoStatus } from "@/types/Repository";
import { Octokit } from "@octokit/rest";
import type { Config } from "@/types/config";
/**
* Creates an authenticated Octokit instance
*/
export function createGitHubClient(token: string): Octokit {
return new Octokit({
auth: token,
});
}
/**
* Clone a repository from GitHub
*/
export async function getGithubRepoCloneUrl({
octokit,
owner,
repo,
}: {
octokit: Octokit;
owner: string;
repo: string;
}): Promise<{ url: string; cloneUrl: string }> {
const { data } = await octokit.repos.get({
owner,
repo,
});
return {
url: data.html_url,
cloneUrl: data.clone_url,
};
}
/**
* Get user repositories from GitHub
* todo: need to handle pagination and apply more filters based on user config
*/
export async function getGithubRepositories({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}): Promise<GitRepo[]> {
try {
const repos = await octokit.paginate(
octokit.repos.listForAuthenticatedUser,
{ per_page: 100 }
);
const includePrivate = config.githubConfig?.privateRepositories ?? false;
const skipForks = config.githubConfig?.skipForks ?? false;
const filteredRepos = repos.filter((repo) => {
const isPrivateAllowed = includePrivate || !repo.private;
const isForkAllowed = !skipForks || !repo.fork;
return isPrivateAllowed && isForkAllowed;
});
return filteredRepos.map((repo) => ({
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 : undefined,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
.parent?.full_name,
hasIssues: repo.has_issues,
isStarred: false,
isArchived: repo.archived,
size: repo.size,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repo.default_branch,
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
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(),
}));
} catch (error) {
throw new Error(
`Error fetching repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
export async function getGithubStarredRepositories({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}) {
try {
const starredRepos = await octokit.paginate(
octokit.activity.listReposStarredByAuthenticatedUser,
{
per_page: 100,
}
);
return starredRepos.map((repo) => ({
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 : undefined,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: undefined,
hasIssues: repo.has_issues,
isStarred: true,
isArchived: repo.archived,
size: repo.size,
hasLFS: false, // Placeholder
hasSubmodules: false, // Placeholder
defaultBranch: repo.default_branch,
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
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(),
}));
} catch (error) {
throw new Error(
`Error fetching starred repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Get user github organizations
*/
export async function getGithubOrganizations({
octokit,
config,
}: {
octokit: Octokit;
config: Partial<Config>;
}): Promise<GitOrg[]> {
try {
const { data: orgs } = await octokit.orgs.listForAuthenticatedUser({
per_page: 100,
});
const organizations = await Promise.all(
orgs.map(async (org) => {
const [{ data: orgDetails }, { data: membership }] = await Promise.all([
octokit.orgs.get({ org: org.login }),
octokit.orgs.getMembershipForAuthenticatedUser({ org: org.login }),
]);
const totalRepos =
orgDetails.public_repos + (orgDetails.total_private_repos ?? 0);
return {
name: org.login,
avatarUrl: org.avatar_url,
membershipRole: membership.role as MembershipRole,
isIncluded: false,
status: "imported" as RepoStatus,
repositoryCount: totalRepos,
createdAt: new Date(),
updatedAt: new Date(),
};
})
);
return organizations;
} catch (error) {
throw new Error(
`Error fetching organizations: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
/**
* Get repositories for a specific organization
*/
export async function getGithubOrganizationRepositories({
octokit,
organizationName,
}: {
octokit: Octokit;
organizationName: string;
}): Promise<GitRepo[]> {
try {
const repos = await octokit.paginate(octokit.repos.listForOrg, {
org: organizationName,
per_page: 100,
});
return repos.map((repo) => ({
name: repo.name,
fullName: repo.full_name,
url: repo.html_url,
cloneUrl: repo.clone_url ?? "",
owner: repo.owner.login,
organization: repo.owner.login,
isPrivate: repo.private,
isForked: repo.fork,
forkedFrom: (repo as typeof repo & { parent?: { full_name: string } })
.parent?.full_name,
hasIssues: repo.has_issues ?? false,
isStarred: false, // Organization starred repos are separate API
isArchived: repo.archived ?? false,
size: repo.size ?? 0,
hasLFS: false,
hasSubmodules: false,
defaultBranch: repo.default_branch ?? "main",
visibility: (repo.visibility ?? "public") as GitRepo["visibility"],
status: "imported",
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(),
}));
} catch (error) {
throw new Error(
`Error fetching organization repositories: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}

53
src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,53 @@
import type { RepoStatus } from "@/types/Repository";
import { db, mirrorJobs } from "./db";
import { v4 as uuidv4 } from "uuid";
import { redisPublisher } from "./redis";
export async function createMirrorJob({
userId,
organizationId,
organizationName,
repositoryId,
repositoryName,
message,
status,
details,
}: {
userId: string;
organizationId?: string;
organizationName?: string;
repositoryId?: string;
repositoryName?: string;
details?: string;
message: string;
status: RepoStatus;
}) {
const jobId = uuidv4();
const currentTimestamp = new Date();
const job = {
id: jobId,
userId,
repositoryId,
repositoryName,
organizationId,
organizationName,
configId: uuidv4(),
details,
message: message,
status: status,
timestamp: currentTimestamp,
};
try {
await db.insert(mirrorJobs).values(job);
const channel = `mirror-status:${userId}`;
await redisPublisher.publish(channel, JSON.stringify(job));
return jobId;
} catch (error) {
console.error("Error creating mirror job:", error);
throw new Error("Error creating mirror job");
}
}

4
src/lib/redis.ts Normal file
View File

@@ -0,0 +1,4 @@
import Redis from "ioredis";
export const redisPublisher = new Redis(); // For publishing
export const redisSubscriber = new Redis(); // For subscribing

160
src/lib/rough.ts Normal file
View File

@@ -0,0 +1,160 @@
// this is a temporary file for testing purposes
import type { Config } from "@/types/config";
export async function deleteAllReposInOrg({
config,
org,
}: {
config: Partial<Config>;
org: string;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
// Step 1: Get all repositories in the organization
const repoRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${org}/repos`,
{
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!repoRes.ok) {
console.error(
`Failed to fetch repos for org ${org}: ${await repoRes.text()}`
);
return;
}
const repos = await repoRes.json();
// Step 2: Delete each repository
for (const repo of repos) {
const deleteRes = await fetch(
`${config.giteaConfig.url}/api/v1/repos/${org}/${repo.name}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!deleteRes.ok) {
console.error(
`Failed to delete repo ${repo.name}: ${await deleteRes.text()}`
);
} else {
console.log(`Successfully deleted repo ${repo.name}`);
}
}
}
export async function deleteOrg({
config,
org,
}: {
config: Partial<Config>;
org: string;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
const deleteOrgRes = await fetch(
`${config.giteaConfig.url}/api/v1/orgs/${org}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
}
);
if (!deleteOrgRes.ok) {
console.error(`Failed to delete org ${org}: ${await deleteOrgRes.text()}`);
} else {
console.log(`Successfully deleted org ${org}`);
}
}
export async function deleteAllOrgs({
config,
orgs,
}: {
config: Partial<Config>;
orgs: string[];
}) {
for (const org of orgs) {
console.log(`Starting deletion for org: ${org}`);
// First, delete all repositories in the organization
await deleteAllReposInOrg({ config, org });
// Then, delete the organization itself
await deleteOrg({ config, org });
console.log(`Finished deletion for org: ${org}`);
}
}
export async function deleteAllReposInGitea({
config,
}: {
config: Partial<Config>;
}) {
if (!config.giteaConfig?.url || !config.giteaConfig?.token) {
throw new Error("Gitea config is required.");
}
console.log("Fetching all repositories...");
// Step 1: Get all repositories (user + org repos)
const repoRes = await fetch(`${config.giteaConfig.url}/api/v1/user/repos`, {
headers: {
Authorization: `token ${config.giteaConfig.token}`,
},
});
if (!repoRes.ok) {
console.error(`Failed to fetch repositories: ${await repoRes.text()}`);
return;
}
const repos = await repoRes.json();
if (repos.length === 0) {
console.log("No repositories found to delete.");
return;
}
console.log(`Found ${repos.length} repositories. Starting deletion...`);
// Step 2: Delete all repositories in parallel
await Promise.allSettled(
repos.map((repo: any) =>
fetch(
`${config.giteaConfig?.url}/api/v1/repos/${repo.owner.username}/${repo.name}`,
{
method: "DELETE",
headers: {
Authorization: `token ${config.giteaConfig?.token}`,
},
}
).then(async (res) => {
if (!res.ok) {
console.error(
`Failed to delete repo ${repo.full_name}: ${await res.text()}`
);
} else {
console.log(`Successfully deleted repo ${repo.full_name}`);
}
})
)
);
console.log("Finished deleting all repositories.");
}

98
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,98 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import axios from "axios";
import type { AxiosError, AxiosRequestConfig } from "axios";
import type { RepoStatus } from "@/types/Repository";
export const API_BASE = "/api";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date?: Date | string | null): string {
if (!date) return "Never";
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(date));
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length) + "...";
}
export function safeParse<T>(value: unknown): T | undefined {
if (typeof value === "string") {
try {
return JSON.parse(value) as T;
} catch {
return undefined;
}
}
return value as T;
}
// Helper function for API requests
export async function apiRequest<T>(
endpoint: string,
options: AxiosRequestConfig = {}
): Promise<T> {
try {
const response = await axios<T>(`${API_BASE}${endpoint}`, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
return response.data;
} catch (err) {
const error = err as AxiosError<{ message?: string }>;
const message =
error.response?.data?.message ||
error.message ||
"An unknown error occurred";
throw new Error(message);
}
}
export const getStatusColor = (status: RepoStatus): string => {
switch (status) {
case "imported":
return "bg-blue-500"; // Info/primary-like
case "mirroring":
return "bg-yellow-400"; // In progress
case "mirrored":
return "bg-emerald-500"; // Success
case "failed":
return "bg-rose-500"; // Error
case "syncing":
return "bg-indigo-500"; // Sync in progress
case "synced":
return "bg-teal-500"; // Sync complete
default:
return "bg-gray-400"; // Unknown/neutral
}
};
export const jsonResponse = ({
data,
status = 200,
}: {
data: unknown;
status?: number;
}): Response => {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
};