diff --git a/README.md b/README.md
index d15873e..789f1dd 100644
--- a/README.md
+++ b/README.md
@@ -322,9 +322,13 @@ Key configuration options include:
Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea:
#### 1. **Preserve GitHub Structure** (Default)
-- Personal repositories → Your Gitea username
-- Organization repositories → Same organization name in Gitea
-- Maintains the exact structure from GitHub
+- Personal repositories → Your Gitea username (or custom organization)
+- Organization repositories → Same organization name in Gitea (with individual overrides)
+- Maintains the exact structure from GitHub with optional customization
+
+**New Override Options:**
+- **Personal Repos Override**: Redirect your personal repositories to a custom organization instead of your username
+- **Organization Overrides**: Set custom destinations for specific GitHub organizations on their individual cards
#### 2. **Single Organization**
- All repositories → One designated organization
@@ -339,6 +343,13 @@ Gitea Mirror offers three flexible strategies for organizing your repositories i
> [!NOTE]
> **Starred Repositories**: Regardless of the chosen strategy, starred repositories are always mirrored to a separate organization (default: "starred") to keep them organized separately from your own repositories.
+> [!TIP]
+> **Example Use Case**: With the "Preserve" strategy and overrides, you can:
+> - Mirror personal repos to `username-mirror` organization
+> - Keep `company-org` repos in their own `company-org` organization
+> - Override `community-scripts` to go to `community-mirrors` organization
+> - This gives you complete control while maintaining GitHub's structure as the default
+
## 🚀 Development
### Local Development Setup
diff --git a/src/components/config/GiteaConfigForm.tsx b/src/components/config/GiteaConfigForm.tsx
index 1acc394..6f6f5f9 100644
--- a/src/components/config/GiteaConfigForm.tsx
+++ b/src/components/config/GiteaConfigForm.tsx
@@ -217,6 +217,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
strategy={mirrorStrategy}
destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg}
+ personalReposOrg={config.personalReposOrg}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
@@ -228,6 +229,11 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
+ onPersonalReposOrgChange={(org) => {
+ const newConfig = { ...config, personalReposOrg: org };
+ setConfig(newConfig);
+ if (onAutoSave) onAutoSave(newConfig);
+ }}
onVisibilityChange={(visibility) => {
const newConfig = { ...config, visibility };
setConfig(newConfig);
diff --git a/src/components/config/OrganizationConfiguration.tsx b/src/components/config/OrganizationConfiguration.tsx
index 48711ee..b04545b 100644
--- a/src/components/config/OrganizationConfiguration.tsx
+++ b/src/components/config/OrganizationConfiguration.tsx
@@ -15,9 +15,11 @@ interface OrganizationConfigurationProps {
strategy: MirrorStrategy;
destinationOrg?: string;
starredReposOrg?: string;
+ personalReposOrg?: string;
visibility: GiteaOrgVisibility;
onDestinationOrgChange: (org: string) => void;
onStarredReposOrgChange: (org: string) => void;
+ onPersonalReposOrgChange: (org: string) => void;
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
}
@@ -31,9 +33,11 @@ export const OrganizationConfiguration: React.FC
strategy,
destinationOrg,
starredReposOrg,
+ personalReposOrg,
visibility,
onDestinationOrgChange,
onStarredReposOrgChange,
+ onPersonalReposOrgChange,
onVisibilityChange,
}) => {
return (
@@ -75,7 +79,7 @@ export const OrganizationConfiguration: React.FC
- {/* Right column - shows destination org for single-org, empty div for others */}
+ {/* Right column - shows destination org for single-org, personal repos org for preserve, empty div for others */}
{strategy === "single-org" ? (
@@ -102,8 +106,34 @@ export const OrganizationConfiguration: React.FC
Organization for consolidated repositories
+ ) : strategy === "preserve" ? (
+
+
+ Personal Repos Organization
+
+
+
+
+
+
+ Override where your personal repositories are mirrored (leave empty to use your username)
+
+
+
+
+
onPersonalReposOrgChange(e.target.value)}
+ placeholder="my-personal-mirrors"
+ className=""
+ />
+
+ Override destination for your personal repos
+
+
) : (
-
+
)}
diff --git a/src/components/organizations/OrganizationsList.tsx b/src/components/organizations/OrganizationsList.tsx
index dbc3efe..1435f99 100644
--- a/src/components/organizations/OrganizationsList.tsx
+++ b/src/components/organizations/OrganizationsList.tsx
@@ -1,8 +1,9 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
-import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock, Settings, ArrowRight } from "lucide-react";
import { SiGithub } from "react-icons/si";
import type { Organization } from "@/lib/db/schema";
import type { FilterParams } from "@/types/filter";
@@ -140,6 +141,13 @@ export function OrganizationList({
{org.membershipRole}
+ {/* Show destination override if configured */}
+ {org.destinationOrg && (
+
+
+
Mirrors to: {org.destinationOrg}
+
+ )}
{StatusIcon && ;
+ const hasDestinationOrg = orgTableInfo.some(col => col.name === 'destination_org');
+
+ if (!hasDestinationOrg) {
+ console.log("🔄 Running migration: Adding destination_org column to organizations table");
+ db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT");
+ console.log("✅ Migration completed: destination_org column added");
+ }
+ } catch (error) {
+ console.error("❌ Error running migrations:", error);
+ // Don't throw - migrations should be non-breaking
+ }
+}
+
/**
* Ensure all required tables exist in the database
*/
@@ -159,6 +182,7 @@ function createTable(db: Database, tableName: string) {
last_mirrored INTEGER,
error_message TEXT,
repository_count INTEGER NOT NULL DEFAULT 0,
+ destination_org TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id),
@@ -437,6 +461,9 @@ export const organizations = sqliteTable("organizations", {
.notNull()
.default(true),
+ // Override destination organization for this GitHub org's repos
+ destinationOrg: text("destination_org"),
+
status: text("status").notNull().default("imported"),
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
errorMessage: text("error_message"),
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 45887d0..06ed135 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -45,6 +45,7 @@ export const configSchema = z.object({
starredReposOrg: z.string().default("github"),
preserveOrgStructure: z.boolean().default(false),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).optional(),
+ personalReposOrg: z.string().optional(), // Override destination for personal repos
}),
include: z.array(z.string()).default(["*"]),
exclude: z.array(z.string()).default([]),
@@ -158,6 +159,9 @@ export const organizationSchema = z.object({
privateRepositoryCount: z.number().optional(),
forkRepositoryCount: z.number().optional(),
+ // Override destination organization for this GitHub org's repos
+ destinationOrg: z.string().optional(),
+
createdAt: z.date().default(() => new Date()),
updatedAt: z.date().default(() => new Date()),
});
diff --git a/src/lib/gitea.test.ts b/src/lib/gitea.test.ts
index fc9dd07..6c4183f 100644
--- a/src/lib/gitea.test.ts
+++ b/src/lib/gitea.test.ts
@@ -1,7 +1,8 @@
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
import { Octokit } from "@octokit/rest";
import { repoStatusEnum } from "@/types/Repository";
-import { getOrCreateGiteaOrg } from "./gitea";
+import { getOrCreateGiteaOrg, getGiteaRepoOwner, getGiteaRepoOwnerAsync } from "./gitea";
+import type { Config, Repository, Organization } from "./db/schema";
// Mock the isRepoPresentInGitea function
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
@@ -291,3 +292,90 @@ describe("Gitea Repository Mirroring", () => {
expect(mockGetOrCreateGiteaOrg).toHaveBeenCalled();
});
});
+
+describe("getGiteaRepoOwner - Organization Override Tests", () => {
+ const baseConfig: Partial = {
+ githubConfig: {
+ username: "testuser",
+ token: "token",
+ preserveOrgStructure: false,
+ skipForks: false,
+ privateRepositories: false,
+ mirrorIssues: false,
+ mirrorWiki: false,
+ mirrorStarred: false,
+ useSpecificUser: false,
+ includeOrgs: [],
+ excludeOrgs: [],
+ mirrorPublicOrgs: false,
+ publicOrgs: [],
+ skipStarredIssues: false
+ },
+ giteaConfig: {
+ username: "giteauser",
+ url: "https://gitea.example.com",
+ token: "gitea-token",
+ organization: "github-mirrors",
+ visibility: "public",
+ starredReposOrg: "starred",
+ preserveOrgStructure: false,
+ mirrorStrategy: "preserve"
+ }
+ };
+
+ const baseRepo: Repository = {
+ id: "repo-id",
+ userId: "user-id",
+ configId: "config-id",
+ name: "test-repo",
+ fullName: "testuser/test-repo",
+ url: "https://github.com/testuser/test-repo",
+ cloneUrl: "https://github.com/testuser/test-repo.git",
+ owner: "testuser",
+ isPrivate: false,
+ isForked: false,
+ hasIssues: true,
+ isStarred: false,
+ isArchived: false,
+ size: 1000,
+ hasLFS: false,
+ hasSubmodules: false,
+ defaultBranch: "main",
+ visibility: "public",
+ status: "imported",
+ mirroredLocation: "",
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ test("starred repos go to starredReposOrg", () => {
+ const repo = { ...baseRepo, isStarred: true };
+ const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
+ expect(result).toBe("starred");
+ });
+
+ test("preserve strategy: personal repos use personalReposOrg override", () => {
+ const configWithOverride = {
+ ...baseConfig,
+ giteaConfig: {
+ ...baseConfig.giteaConfig!,
+ personalReposOrg: "my-personal-mirrors"
+ }
+ };
+ const repo = { ...baseRepo, organization: undefined };
+ const result = getGiteaRepoOwner({ config: configWithOverride, repository: repo });
+ expect(result).toBe("my-personal-mirrors");
+ });
+
+ test("preserve strategy: personal repos fallback to username when no override", () => {
+ const repo = { ...baseRepo, organization: undefined };
+ const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
+ expect(result).toBe("giteauser");
+ });
+
+ test("preserve strategy: org repos go to same org name", () => {
+ const repo = { ...baseRepo, organization: "myorg" };
+ const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
+ expect(result).toBe("myorg");
+ });
+});
diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts
index c6a5ed9..fa6c33b 100644
--- a/src/lib/gitea.ts
+++ b/src/lib/gitea.ts
@@ -9,7 +9,81 @@ import type { Organization, Repository } from "./db/schema";
import { httpPost, httpGet } from "./http-client";
import { createMirrorJob } from "./helpers";
import { db, organizations, repositories } from "./db";
-import { eq } from "drizzle-orm";
+import { eq, and } from "drizzle-orm";
+
+/**
+ * Helper function to get organization configuration including destination override
+ */
+export const getOrganizationConfig = async ({
+ orgName,
+ userId,
+}: {
+ orgName: string;
+ userId: string;
+}): Promise => {
+ try {
+ const [orgConfig] = await db
+ .select()
+ .from(organizations)
+ .where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
+ .limit(1);
+
+ return orgConfig || null;
+ } catch (error) {
+ console.error(`Error fetching organization config for ${orgName}:`, error);
+ return null;
+ }
+};
+
+/**
+ * Enhanced async version of getGiteaRepoOwner that supports organization overrides
+ */
+export const getGiteaRepoOwnerAsync = async ({
+ config,
+ repository,
+}: {
+ config: Partial;
+ repository: Repository;
+}): Promise => {
+ 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 (!config.userId) {
+ throw new Error("User ID is required for organization overrides.");
+ }
+
+ // Check if repository is starred - starred repos always go to starredReposOrg (highest priority)
+ if (repository.isStarred && config.giteaConfig.starredReposOrg) {
+ return config.giteaConfig.starredReposOrg;
+ }
+
+ // Check for organization-specific override
+ if (repository.organization) {
+ const orgConfig = await getOrganizationConfig({
+ orgName: repository.organization,
+ userId: config.userId,
+ });
+
+ if (orgConfig?.destinationOrg) {
+ console.log(`Using organization override: ${repository.organization} -> ${orgConfig.destinationOrg}`);
+ return orgConfig.destinationOrg;
+ }
+ }
+
+ // Check for personal repos override (when it's user's repo, not an organization)
+ if (!repository.organization && config.giteaConfig.personalReposOrg) {
+ console.log(`Using personal repos override: ${config.giteaConfig.personalReposOrg}`);
+ return config.giteaConfig.personalReposOrg;
+ }
+
+ // Fall back to existing strategy logic
+ return getGiteaRepoOwner({ config, repository });
+};
export const getGiteaRepoOwner = ({
config,
@@ -37,11 +111,12 @@ export const getGiteaRepoOwner = ({
switch (mirrorStrategy) {
case "preserve":
- // Keep GitHub structure - org repos go to same org, personal repos to user
+ // Keep GitHub structure - org repos go to same org, personal repos to user (or override)
if (repository.organization) {
return repository.organization;
}
- return config.giteaConfig.username;
+ // Use personal repos override if configured, otherwise use username
+ return config.giteaConfig.personalReposOrg || config.giteaConfig.username;
case "single-org":
// All non-starred repos go to the destination organization
@@ -160,8 +235,8 @@ export const mirrorGithubRepoToGitea = async ({
throw new Error("Gitea username is required.");
}
- // Get the correct owner based on the strategy
- const repoOwner = getGiteaRepoOwner({ config, repository });
+ // Get the correct owner based on the strategy (with organization overrides)
+ const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
const isExisting = await isRepoPresentInGitea({
config,
@@ -987,8 +1062,8 @@ export const syncGiteaRepo = async ({
status: repoStatusEnum.parse("syncing"),
});
- // Get the expected owner based on current config
- const repoOwner = getGiteaRepoOwner({ config, repository });
+ // Get the expected owner based on current config (with organization overrides)
+ const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
// Check if repo exists at the expected location or alternate location
const { present, actualOwner } = await checkRepoLocation({
@@ -1289,7 +1364,7 @@ export async function mirrorGitHubReleasesToGitea({
throw new Error("Gitea config is incomplete for mirroring releases.");
}
- const repoOwner = getGiteaRepoOwner({
+ const repoOwner = await getGiteaRepoOwnerAsync({
config,
repository,
});
diff --git a/src/lib/utils/config-mapper.ts b/src/lib/utils/config-mapper.ts
index 7cd644f..8b157b9 100644
--- a/src/lib/utils/config-mapper.ts
+++ b/src/lib/utils/config-mapper.ts
@@ -36,6 +36,7 @@ interface DbGiteaConfig {
starredReposOrg: string;
preserveOrgStructure: boolean;
mirrorStrategy?: "preserve" | "single-org" | "flat-user";
+ personalReposOrg?: string;
}
/**
@@ -106,6 +107,7 @@ export function mapDbToUiConfig(dbConfig: any): {
starredReposOrg: dbConfig.giteaConfig?.starredReposOrg || "github",
preserveOrgStructure: dbConfig.giteaConfig?.preserveOrgStructure || false,
mirrorStrategy: dbConfig.giteaConfig?.mirrorStrategy,
+ personalReposOrg: dbConfig.giteaConfig?.personalReposOrg,
};
const mirrorOptions: MirrorOptions = {
diff --git a/src/pages/api/job/mirror-repo.ts b/src/pages/api/job/mirror-repo.ts
index 95ee671..4e6acea 100644
--- a/src/pages/api/job/mirror-repo.ts
+++ b/src/pages/api/job/mirror-repo.ts
@@ -6,7 +6,7 @@ import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
import {
mirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg,
- getGiteaRepoOwner,
+ getGiteaRepoOwnerAsync,
} from "@/lib/gitea";
import { createGitHubClient } from "@/lib/github";
import { processWithResilience } from "@/lib/utils/concurrency";
@@ -97,8 +97,8 @@ export const POST: APIRoute = async ({ request }) => {
// Log the start of mirroring
console.log(`Starting mirror for repository: ${repo.name}`);
- // Determine where the repository should be mirrored
- const owner = getGiteaRepoOwner({
+ // Determine where the repository should be mirrored (with organization overrides)
+ const owner = await getGiteaRepoOwnerAsync({
config,
repository: repoData,
});
diff --git a/src/pages/api/job/retry-repo.ts b/src/pages/api/job/retry-repo.ts
index fc191a0..f283629 100644
--- a/src/pages/api/job/retry-repo.ts
+++ b/src/pages/api/job/retry-repo.ts
@@ -1,7 +1,7 @@
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 { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
import {
mirrorGithubRepoToGitea,
mirrorGitHubOrgRepoToGiteaOrg,
@@ -109,8 +109,8 @@ export const POST: APIRoute = async ({ request }) => {
status: "imported",
});
- // Determine if the repository exists in Gitea
- let owner = getGiteaRepoOwner({
+ // Determine if the repository exists in Gitea (with organization overrides)
+ let owner = await getGiteaRepoOwnerAsync({
config,
repository: repoData,
});
diff --git a/src/types/config.ts b/src/types/config.ts
index 01fbba1..4fbdeb5 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -12,6 +12,7 @@ export interface GiteaConfig {
starredReposOrg: string;
preserveOrgStructure: boolean;
mirrorStrategy?: MirrorStrategy; // New field for the strategy
+ personalReposOrg?: string; // Override destination for personal repos
}
export interface ScheduleConfig {