Allow starred repos to be mirrored preserving structure

This commit is contained in:
Tobeas Arren
2026-02-14 13:08:41 +01:00
parent 2496d6f6e0
commit f4d391b240
18 changed files with 237 additions and 72 deletions

View File

@@ -47,6 +47,7 @@ DOCKER_TAG=latest
# SKIP_FORKS=false # SKIP_FORKS=false
# MIRROR_STARRED=false # MIRROR_STARRED=false
# STARRED_REPOS_ORG=starred # Organization name for starred repos # STARRED_REPOS_ORG=starred # Organization name for starred repos
# STARRED_REPOS_MODE=dedicated-org # dedicated-org | preserve-owner
# Organization Settings # Organization Settings
# MIRROR_ORGANIZATIONS=false # MIRROR_ORGANIZATIONS=false

View File

@@ -209,7 +209,7 @@ bun run dev
3. **Customization** 3. **Customization**
- Click edit buttons on organization cards to set custom destinations - Click edit buttons on organization cards to set custom destinations
- Override individual repository destinations in the table view - Override individual repository destinations in the table view
- Starred repositories automatically go to a dedicated organization - Starred repositories can go to a dedicated org or preserve source owner/org paths
## Advanced Features ## Advanced Features

View File

@@ -62,6 +62,7 @@ Settings for connecting to and configuring GitHub repository sources.
| `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` | | `SKIP_FORKS` | Skip forked repositories | `false` | `true`, `false` |
| `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` | | `MIRROR_STARRED` | Mirror starred repositories | `false` | `true`, `false` |
| `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string | | `STARRED_REPOS_ORG` | Organization name for starred repos | `starred` | Any string |
| `STARRED_REPOS_MODE` | How starred repos are mirrored | `dedicated-org` | `dedicated-org`, `preserve-owner` |
### Organization Settings ### Organization Settings

View File

@@ -47,6 +47,7 @@ export function ConfigTabs() {
organization: 'github-mirrors', organization: 'github-mirrors',
visibility: 'public', visibility: 'public',
starredReposOrg: 'starred', starredReposOrg: 'starred',
starredReposMode: 'dedicated-org',
preserveOrgStructure: false, preserveOrgStructure: false,
}, },
scheduleConfig: { scheduleConfig: {

View File

@@ -224,6 +224,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
strategy={mirrorStrategy} strategy={mirrorStrategy}
destinationOrg={config.organization} destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg} starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
onStrategyChange={setMirrorStrategy} onStrategyChange={setMirrorStrategy}
githubUsername={githubUsername} githubUsername={githubUsername}
giteaUsername={config.username} giteaUsername={config.username}
@@ -235,6 +236,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
strategy={mirrorStrategy} strategy={mirrorStrategy}
destinationOrg={config.organization} destinationOrg={config.organization}
starredReposOrg={config.starredReposOrg} starredReposOrg={config.starredReposOrg}
starredReposMode={config.starredReposMode}
personalReposOrg={config.personalReposOrg} personalReposOrg={config.personalReposOrg}
visibility={config.visibility} visibility={config.visibility}
onDestinationOrgChange={(org) => { onDestinationOrgChange={(org) => {
@@ -247,6 +249,11 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
setConfig(newConfig); setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig); if (onAutoSave) onAutoSave(newConfig);
}} }}
onStarredReposModeChange={(mode) => {
const newConfig = { ...config, starredReposMode: mode };
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
onPersonalReposOrgChange={(org) => { onPersonalReposOrgChange={(org) => {
const newConfig = { ...config, personalReposOrg: org }; const newConfig = { ...config, personalReposOrg: org };
setConfig(newConfig); setConfig(newConfig);

View File

@@ -9,16 +9,18 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config"; import type { MirrorStrategy, GiteaOrgVisibility, StarredReposMode } from "@/types/config";
interface OrganizationConfigurationProps { interface OrganizationConfigurationProps {
strategy: MirrorStrategy; strategy: MirrorStrategy;
destinationOrg?: string; destinationOrg?: string;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: StarredReposMode;
personalReposOrg?: string; personalReposOrg?: string;
visibility: GiteaOrgVisibility; visibility: GiteaOrgVisibility;
onDestinationOrgChange: (org: string) => void; onDestinationOrgChange: (org: string) => void;
onStarredReposOrgChange: (org: string) => void; onStarredReposOrgChange: (org: string) => void;
onStarredReposModeChange: (mode: StarredReposMode) => void;
onPersonalReposOrgChange: (org: string) => void; onPersonalReposOrgChange: (org: string) => void;
onVisibilityChange: (visibility: GiteaOrgVisibility) => void; onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
} }
@@ -33,13 +35,19 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
strategy, strategy,
destinationOrg, destinationOrg,
starredReposOrg, starredReposOrg,
starredReposMode,
personalReposOrg, personalReposOrg,
visibility, visibility,
onDestinationOrgChange, onDestinationOrgChange,
onStarredReposOrgChange, onStarredReposOrgChange,
onStarredReposModeChange,
onPersonalReposOrgChange, onPersonalReposOrgChange,
onVisibilityChange, onVisibilityChange,
}) => { }) => {
const activeStarredMode = starredReposMode || "dedicated-org";
const showStarredReposOrgInput = activeStarredMode === "dedicated-org";
const showDestinationOrgInput = strategy === "single-org" || strategy === "mixed";
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -49,9 +57,52 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</h4> </h4>
</div> </div>
{/* First row - Organization inputs with consistent layout */} <div className="space-y-2">
<Label className="text-sm font-normal flex items-center gap-2">
Starred Repository Destination
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Choose whether starred repos use one org or keep their source owner paths</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
type="button"
onClick={() => onStarredReposModeChange("dedicated-org")}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-colors",
activeStarredMode === "dedicated-org"
? "bg-accent border-accent-foreground/20"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Dedicated organization
</button>
<button
type="button"
onClick={() => onStarredReposModeChange("preserve-owner")}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-colors",
activeStarredMode === "preserve-owner"
? "bg-accent border-accent-foreground/20"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Preserve source owner/org
</button>
</div>
</div>
{/* First row - Organization inputs */}
{(showStarredReposOrgInput || showDestinationOrgInput) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Left column - always shows starred repos org */} {showStarredReposOrgInput ? (
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2"> <Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
<Star className="h-3.5 w-3.5" /> <Star className="h-3.5 w-3.5" />
@@ -78,9 +129,11 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
Keep starred repos organized separately Keep starred repos organized separately
</p> </p>
</div> </div>
) : (
<div className="hidden md:block" />
)}
{/* Right column - shows destination org for single-org/mixed, personal repos org for preserve, empty div for others */} {showDestinationOrgInput ? (
{strategy === "single-org" || strategy === "mixed" ? (
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2"> <Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
{strategy === "mixed" ? "Personal Repos Organization" : "Destination Organization"} {strategy === "mixed" ? "Personal Repos Organization" : "Destination Organization"}
@@ -118,6 +171,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<div className="hidden md:block" /> <div className="hidden md:block" />
)} )}
</div> </div>
)}
{/* Second row - Organization Visibility (always shown) */} {/* Second row - Organization Visibility (always shown) */}
<div className="space-y-2"> <div className="space-y-2">
@@ -172,4 +226,3 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</div> </div>
); );
}; };

View File

@@ -8,6 +8,7 @@ import {
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card"; } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { StarredReposMode } from "@/types/config";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
@@ -15,6 +16,7 @@ interface OrganizationStrategyProps {
strategy: MirrorStrategy; strategy: MirrorStrategy;
destinationOrg?: string; destinationOrg?: string;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: StarredReposMode;
onStrategyChange: (strategy: MirrorStrategy) => void; onStrategyChange: (strategy: MirrorStrategy) => void;
githubUsername?: string; githubUsername?: string;
giteaUsername?: string; giteaUsername?: string;
@@ -76,13 +78,18 @@ const MappingPreview: React.FC<{
config: typeof strategyConfig.preserve; config: typeof strategyConfig.preserve;
destinationOrg?: string; destinationOrg?: string;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: StarredReposMode;
githubUsername?: string; githubUsername?: string;
giteaUsername?: string; giteaUsername?: string;
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => { }> = ({ strategy, config, destinationOrg, starredReposOrg, starredReposMode, githubUsername, giteaUsername }) => {
const displayGithubUsername = githubUsername || "<username>"; const displayGithubUsername = githubUsername || "<username>";
const displayGiteaUsername = giteaUsername || "<username>"; const displayGiteaUsername = giteaUsername || "<username>";
const isGithubPlaceholder = !githubUsername; const isGithubPlaceholder = !githubUsername;
const isGiteaPlaceholder = !giteaUsername; const isGiteaPlaceholder = !giteaUsername;
const starredDestination =
(starredReposMode || "dedicated-org") === "preserve-owner"
? "awesome/starred-repo"
: `${starredReposOrg || "starred"}/starred-repo`;
if (strategy === "preserve") { if (strategy === "preserve") {
return ( return (
@@ -122,7 +129,7 @@ const MappingPreview: React.FC<{
</div> </div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}> <div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} /> <Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span> <span>{starredDestination}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -168,7 +175,7 @@ const MappingPreview: React.FC<{
</div> </div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}> <div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} /> <Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span> <span>{starredDestination}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -214,7 +221,7 @@ const MappingPreview: React.FC<{
</div> </div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}> <div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} /> <Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span> <span>{starredDestination}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -260,7 +267,7 @@ const MappingPreview: React.FC<{
</div> </div>
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}> <div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
<Building2 className={cn("h-3 w-3", config.repoColors.icon)} /> <Building2 className={cn("h-3 w-3", config.repoColors.icon)} />
<span>{starredReposOrg || "starred"}/starred-repo</span> <span>{starredDestination}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -275,6 +282,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
strategy, strategy,
destinationOrg, destinationOrg,
starredReposOrg, starredReposOrg,
starredReposMode,
onStrategyChange, onStrategyChange,
githubUsername, githubUsername,
giteaUsername, giteaUsername,
@@ -339,7 +347,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
<span className="text-xs font-medium">Starred Repositories</span> <span className="text-xs font-medium">Starred Repositories</span>
</div> </div>
<p className="text-xs text-muted-foreground pl-5"> <p className="text-xs text-muted-foreground pl-5">
Always go to the configured starred repos organization and cannot be overridden. Follow your starred-repo mode and cannot be overridden per repository.
</p> </p>
</div> </div>
</div> </div>
@@ -415,6 +423,7 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
config={config} config={config}
destinationOrg={destinationOrg} destinationOrg={destinationOrg}
starredReposOrg={starredReposOrg} starredReposOrg={starredReposOrg}
starredReposMode={starredReposMode}
githubUsername={githubUsername} githubUsername={githubUsername}
giteaUsername={giteaUsername} giteaUsername={giteaUsername}
/> />

View File

@@ -28,10 +28,17 @@ export function InlineDestinationEditor({
// Determine the default destination based on repository properties and config // Determine the default destination based on repository properties and config
const getDefaultDestination = () => { const getDefaultDestination = () => {
// Starred repos always go to the configured starredReposOrg // Starred repos can use either dedicated org or preserved source owner
if (repository.isStarred && giteaConfig?.starredReposOrg) { if (repository.isStarred) {
const starredReposMode = giteaConfig?.starredReposMode || "dedicated-org";
if (starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
if (giteaConfig?.starredReposOrg) {
return giteaConfig.starredReposOrg; return giteaConfig.starredReposOrg;
} }
return "starred";
}
// Check mirror strategy // Check mirror strategy
const strategy = giteaConfig?.mirrorStrategy || 'preserve'; const strategy = giteaConfig?.mirrorStrategy || 'preserve';
@@ -60,7 +67,7 @@ export function InlineDestinationEditor({
const defaultDestination = getDefaultDestination(); const defaultDestination = getDefaultDestination();
const currentDestination = repository.destinationOrg || defaultDestination; const currentDestination = repository.destinationOrg || defaultDestination;
const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination; const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination;
const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg; const isStarredRepo = repository.isStarred;
useEffect(() => { useEffect(() => {
if (isEditing && inputRef.current) { if (isEditing && inputRef.current) {

View File

@@ -25,6 +25,7 @@ export const githubConfigSchema = z.object({
includePublic: z.boolean().default(true), includePublic: z.boolean().default(true),
includeOrganizations: z.array(z.string()).default([]), includeOrganizations: z.array(z.string()).default([]),
starredReposOrg: z.string().optional(), starredReposOrg: z.string().optional(),
starredReposMode: z.enum(["dedicated-org", "preserve-owner"]).default("dedicated-org"),
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"), mirrorStrategy: z.enum(["preserve", "single-org", "flat-user", "mixed"]).default("preserve"),
defaultOrg: z.string().optional(), defaultOrg: z.string().optional(),
starredCodeOnly: z.boolean().default(false), starredCodeOnly: z.boolean().default(false),

View File

@@ -23,6 +23,7 @@ interface EnvConfig {
onlyMirrorOrgs?: boolean; onlyMirrorOrgs?: boolean;
starredCodeOnly?: boolean; starredCodeOnly?: boolean;
starredReposOrg?: string; starredReposOrg?: string;
starredReposMode?: 'dedicated-org' | 'preserve-owner';
mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed'; mirrorStrategy?: 'preserve' | 'single-org' | 'flat-user' | 'mixed';
}; };
gitea: { gitea: {
@@ -112,6 +113,7 @@ function parseEnvConfig(): EnvConfig {
onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true', onlyMirrorOrgs: process.env.ONLY_MIRROR_ORGS === 'true',
starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true', starredCodeOnly: process.env.SKIP_STARRED_ISSUES === 'true',
starredReposOrg: process.env.STARRED_REPOS_ORG, starredReposOrg: process.env.STARRED_REPOS_ORG,
starredReposMode: process.env.STARRED_REPOS_MODE as 'dedicated-org' | 'preserve-owner',
mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed', mirrorStrategy: process.env.MIRROR_STRATEGY as 'preserve' | 'single-org' | 'flat-user' | 'mixed',
}, },
gitea: { gitea: {
@@ -256,6 +258,7 @@ export async function initializeConfigFromEnv(): Promise<void> {
includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true, includePublic: envConfig.github.publicRepositories ?? existingConfig?.[0]?.githubConfig?.includePublic ?? true,
includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []), includeOrganizations: envConfig.github.mirrorOrganizations ? [] : (existingConfig?.[0]?.githubConfig?.includeOrganizations ?? []),
starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred', starredReposOrg: envConfig.github.starredReposOrg || existingConfig?.[0]?.githubConfig?.starredReposOrg || 'starred',
starredReposMode: envConfig.github.starredReposMode || existingConfig?.[0]?.githubConfig?.starredReposMode || 'dedicated-org',
mirrorStrategy, mirrorStrategy,
defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors', defaultOrg: envConfig.gitea.organization || existingConfig?.[0]?.githubConfig?.defaultOrg || 'github-mirrors',
starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false, starredCodeOnly: envConfig.github.starredCodeOnly ?? existingConfig?.[0]?.githubConfig?.starredCodeOnly ?? false,

View File

@@ -342,6 +342,8 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
mirrorPublicOrgs: false, mirrorPublicOrgs: false,
publicOrgs: [], publicOrgs: [],
starredCodeOnly: false, starredCodeOnly: false,
starredReposOrg: "starred",
starredReposMode: "dedicated-org",
mirrorStrategy: "preserve" mirrorStrategy: "preserve"
}, },
giteaConfig: { giteaConfig: {
@@ -350,7 +352,6 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
token: "gitea-token", token: "gitea-token",
organization: "github-mirrors", organization: "github-mirrors",
visibility: "public", visibility: "public",
starredReposOrg: "starred",
preserveVisibility: false preserveVisibility: false
} }
}; };
@@ -390,8 +391,8 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
const repo = { ...baseRepo, isStarred: true }; const repo = { ...baseRepo, isStarred: true };
const configWithoutStarredOrg = { const configWithoutStarredOrg = {
...baseConfig, ...baseConfig,
giteaConfig: { githubConfig: {
...baseConfig.giteaConfig, ...baseConfig.githubConfig,
starredReposOrg: undefined starredReposOrg: undefined
} }
}; };
@@ -399,6 +400,34 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
expect(result).toBe("starred"); expect(result).toBe("starred");
}); });
test("starred repos preserve owner/org when starredReposMode is preserve-owner", () => {
const repo = { ...baseRepo, isStarred: true, owner: "FOO", organization: "FOO", fullName: "FOO/BAR" };
const configWithPreserveStarred = {
...baseConfig,
githubConfig: {
...baseConfig.githubConfig!,
starredReposMode: "preserve-owner" as const,
},
};
const result = getGiteaRepoOwner({ config: configWithPreserveStarred, repository: repo });
expect(result).toBe("FOO");
});
test("starred personal repos preserve owner when starredReposMode is preserve-owner", () => {
const repo = { ...baseRepo, isStarred: true, owner: "alice", organization: undefined, fullName: "alice/demo" };
const configWithPreserveStarred = {
...baseConfig,
githubConfig: {
...baseConfig.githubConfig!,
starredReposMode: "preserve-owner" as const,
},
};
const result = getGiteaRepoOwner({ config: configWithPreserveStarred, repository: repo });
expect(result).toBe("alice");
});
// Removed test for personalReposOrg as this field no longer exists // Removed test for personalReposOrg as this field no longer exists
test("preserve strategy: personal repos fallback to username when no override", () => { test("preserve strategy: personal repos fallback to username when no override", () => {
@@ -492,4 +521,24 @@ describe("getGiteaRepoOwner - Organization Override Tests", () => {
expect(result).toBe("custom-org"); expect(result).toBe("custom-org");
}); });
test("getGiteaRepoOwnerAsync preserves starred owner when preserve-owner mode is enabled", async () => {
const configWithUser: Partial<Config> = {
...baseConfig,
userId: "user-id",
githubConfig: {
...baseConfig.githubConfig!,
starredReposMode: "preserve-owner",
},
};
const repo = { ...baseRepo, isStarred: true, owner: "FOO", organization: "FOO", fullName: "FOO/BAR" };
const result = await getGiteaRepoOwnerAsync({
config: configWithUser,
repository: repo,
});
expect(result).toBe("FOO");
});
}); });

View File

@@ -77,8 +77,12 @@ export const getGiteaRepoOwnerAsync = async ({
throw new Error("User ID is required for organization overrides."); throw new Error("User ID is required for organization overrides.");
} }
// Check if repository is starred - starred repos always go to starredReposOrg (highest priority) // Check if repository is starred
if (repository.isStarred) { if (repository.isStarred) {
const starredReposMode = config.githubConfig.starredReposMode || "dedicated-org";
if (starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
return config.githubConfig.starredReposOrg || "starred"; return config.githubConfig.starredReposOrg || "starred";
} }
@@ -122,8 +126,12 @@ export const getGiteaRepoOwner = ({
throw new Error("Gitea username is required."); throw new Error("Gitea username is required.");
} }
// Check if repository is starred - starred repos always go to starredReposOrg // Check if repository is starred
if (repository.isStarred) { if (repository.isStarred) {
const starredReposMode = config.githubConfig.starredReposMode || "dedicated-org";
if (starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
return config.githubConfig.starredReposOrg || "starred"; return config.githubConfig.starredReposOrg || "starred";
} }
@@ -372,7 +380,11 @@ export const mirrorGithubRepoToGitea = async ({
// Determine the actual repository name to use (handle duplicates for starred repos) // Determine the actual repository name to use (handle duplicates for starred repos)
let targetRepoName = repository.name; let targetRepoName = repository.name;
if (repository.isStarred && config.githubConfig) { if (
repository.isStarred &&
config.githubConfig &&
(config.githubConfig.starredReposMode || "dedicated-org") === "dedicated-org"
) {
// Extract GitHub owner from full_name (format: owner/repo) // Extract GitHub owner from full_name (format: owner/repo)
const githubOwner = repository.fullName.split('/')[0]; const githubOwner = repository.fullName.split('/')[0];
@@ -990,7 +1002,11 @@ export async function mirrorGitHubRepoToGiteaOrg({
// Determine the actual repository name to use (handle duplicates for starred repos) // Determine the actual repository name to use (handle duplicates for starred repos)
let targetRepoName = repository.name; let targetRepoName = repository.name;
if (repository.isStarred && config.githubConfig) { if (
repository.isStarred &&
config.githubConfig &&
(config.githubConfig.starredReposMode || "dedicated-org") === "dedicated-org"
) {
// Extract GitHub owner from full_name (format: owner/repo) // Extract GitHub owner from full_name (format: owner/repo)
const githubOwner = repository.fullName.split('/')[0]; const githubOwner = repository.fullName.split('/')[0];

View File

@@ -92,8 +92,13 @@ async function preCreateOrganizations({
// Get unique organization names // Get unique organization names
const orgNames = new Set<string>(); const orgNames = new Set<string>();
// Add starred repos org const starredReposMode = config.githubConfig?.starredReposMode || "dedicated-org";
if (config.githubConfig?.starredReposOrg) {
if (starredReposMode === "preserve-owner") {
for (const repo of repositories) {
orgNames.add(repo.organization || repo.owner);
}
} else if (config.githubConfig?.starredReposOrg) {
orgNames.add(config.githubConfig.starredReposOrg); orgNames.add(config.githubConfig.starredReposOrg);
} else { } else {
orgNames.add("starred"); orgNames.add("starred");
@@ -129,7 +134,11 @@ async function processStarredRepository({
octokit: Octokit; octokit: Octokit;
strategyConfig: ReturnType<typeof getMirrorStrategyConfig>; strategyConfig: ReturnType<typeof getMirrorStrategyConfig>;
}): Promise<void> { }): Promise<void> {
const starredOrg = config.githubConfig?.starredReposOrg || "starred"; const starredReposMode = config.githubConfig?.starredReposMode || "dedicated-org";
const starredOrg =
starredReposMode === "preserve-owner"
? repository.organization || repository.owner
: config.githubConfig?.starredReposOrg || "starred";
// Check if repository exists in Gitea // Check if repository exists in Gitea
const existingRepo = await getGiteaRepoInfo({ const existingRepo = await getGiteaRepoInfo({
@@ -257,7 +266,11 @@ export async function syncStarredRepositories({
if (error instanceof Error && error.message.includes("not a mirror")) { if (error instanceof Error && error.message.includes("not a mirror")) {
console.warn(`Repository ${repository.name} is not a mirror, handling...`); console.warn(`Repository ${repository.name} is not a mirror, handling...`);
const starredOrg = config.githubConfig?.starredReposOrg || "starred"; const starredReposMode = config.githubConfig?.starredReposMode || "dedicated-org";
const starredOrg =
starredReposMode === "preserve-owner"
? repository.organization || repository.owner
: config.githubConfig?.starredReposOrg || "starred";
const repoInfo = await getGiteaRepoInfo({ const repoInfo = await getGiteaRepoInfo({
config, config,
owner: starredOrg, owner: starredOrg,

View File

@@ -71,6 +71,7 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
includePublic: true, includePublic: true,
includeOrganizations: [], includeOrganizations: [],
starredReposOrg: "starred", starredReposOrg: "starred",
starredReposMode: "dedicated-org",
mirrorStrategy: "preserve", mirrorStrategy: "preserve",
defaultOrg: "github-mirrors", defaultOrg: "github-mirrors",
}, },

View File

@@ -48,6 +48,7 @@ export function mapUiToDbConfig(
// Starred repos organization // Starred repos organization
starredReposOrg: giteaConfig.starredReposOrg, starredReposOrg: giteaConfig.starredReposOrg,
starredReposMode: giteaConfig.starredReposMode || "dedicated-org",
// Mirror strategy // Mirror strategy
mirrorStrategy: giteaConfig.mirrorStrategy || "preserve", mirrorStrategy: giteaConfig.mirrorStrategy || "preserve",
@@ -131,6 +132,7 @@ export function mapDbToUiConfig(dbConfig: any): {
organization: dbConfig.githubConfig?.defaultOrg || "github-mirrors", // Get from GitHub config organization: dbConfig.githubConfig?.defaultOrg || "github-mirrors", // Get from GitHub config
visibility: dbConfig.giteaConfig?.visibility === "default" ? "public" : dbConfig.giteaConfig?.visibility || "public", visibility: dbConfig.giteaConfig?.visibility === "default" ? "public" : dbConfig.giteaConfig?.visibility || "public",
starredReposOrg: dbConfig.githubConfig?.starredReposOrg || "starred", // Get from GitHub config starredReposOrg: dbConfig.githubConfig?.starredReposOrg || "starred", // Get from GitHub config
starredReposMode: dbConfig.githubConfig?.starredReposMode || "dedicated-org", // Get from GitHub config
preserveOrgStructure: dbConfig.giteaConfig?.preserveVisibility || false, // Map preserveVisibility preserveOrgStructure: dbConfig.giteaConfig?.preserveVisibility || false, // Map preserveVisibility
mirrorStrategy: dbConfig.githubConfig?.mirrorStrategy || "preserve", // Get from GitHub config mirrorStrategy: dbConfig.githubConfig?.mirrorStrategy || "preserve", // Get from GitHub config
personalReposOrg: undefined, // Not stored in current schema personalReposOrg: undefined, // Not stored in current schema

View File

@@ -108,15 +108,14 @@ export const POST: APIRoute = async ({ request }) => {
console.log(`Repository ${repo.name} will be mirrored to owner: ${owner}`); console.log(`Repository ${repo.name} will be mirrored to owner: ${owner}`);
// For single-org and starred repos strategies, or when mirroring to an org, // For single-org strategy, or when mirroring to an org,
// always use the org mirroring function to ensure proper organization handling // use the org mirroring function to ensure proper organization handling
const mirrorStrategy = config.githubConfig?.mirrorStrategy || const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user"); (config.giteaConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const shouldUseOrgMirror = const shouldUseOrgMirror =
owner !== config.giteaConfig?.defaultOwner || // Different owner means org owner !== config.giteaConfig?.defaultOwner || // Different owner means org
mirrorStrategy === "single-org" || // Single-org strategy always uses org mirrorStrategy === "single-org"; // Single-org strategy always uses org
repoData.isStarred; // Starred repos always go to org
if (shouldUseOrgMirror) { if (shouldUseOrgMirror) {
await mirrorGitHubOrgRepoToGiteaOrg({ await mirrorGitHubOrgRepoToGiteaOrg({

View File

@@ -142,15 +142,14 @@ export const POST: APIRoute = async ({ request }) => {
console.log(`Importing repo: ${repo.name} to owner: ${owner}`); console.log(`Importing repo: ${repo.name} to owner: ${owner}`);
// For single-org and starred repos strategies, or when mirroring to an org, // For single-org strategy, or when mirroring to an org,
// always use the org mirroring function to ensure proper organization handling // use the org mirroring function to ensure proper organization handling
const mirrorStrategy = config.githubConfig?.mirrorStrategy || const mirrorStrategy = config.githubConfig?.mirrorStrategy ||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user"); (config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
const shouldUseOrgMirror = const shouldUseOrgMirror =
owner !== config.giteaConfig?.defaultOwner || // Different owner means org owner !== config.giteaConfig?.defaultOwner || // Different owner means org
mirrorStrategy === "single-org" || // Single-org strategy always uses org mirrorStrategy === "single-org"; // Single-org strategy always uses org
repoData.isStarred; // Starred repos always go to org
if (shouldUseOrgMirror) { if (shouldUseOrgMirror) {
await mirrorGitHubOrgRepoToGiteaOrg({ await mirrorGitHubOrgRepoToGiteaOrg({

View File

@@ -2,6 +2,7 @@ import { type Config as ConfigType } from "@/lib/db/schema";
export type GiteaOrgVisibility = "public" | "private" | "limited"; export type GiteaOrgVisibility = "public" | "private" | "limited";
export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed"; export type MirrorStrategy = "preserve" | "single-org" | "flat-user" | "mixed";
export type StarredReposMode = "dedicated-org" | "preserve-owner";
export interface GiteaConfig { export interface GiteaConfig {
url: string; url: string;
@@ -10,6 +11,7 @@ export interface GiteaConfig {
organization: string; organization: string;
visibility: GiteaOrgVisibility; visibility: GiteaOrgVisibility;
starredReposOrg: string; starredReposOrg: string;
starredReposMode?: StarredReposMode;
preserveOrgStructure: boolean; preserveOrgStructure: boolean;
mirrorStrategy?: MirrorStrategy; // New field for the strategy mirrorStrategy?: MirrorStrategy; // New field for the strategy
personalReposOrg?: string; // Override destination for personal repos personalReposOrg?: string; // Override destination for personal repos
@@ -46,6 +48,7 @@ export interface GitHubConfig {
privateRepositories: boolean; privateRepositories: boolean;
mirrorStarred: boolean; mirrorStarred: boolean;
starredDuplicateStrategy?: DuplicateNameStrategy; starredDuplicateStrategy?: DuplicateNameStrategy;
starredReposMode?: StarredReposMode;
} }
export interface MirrorOptions { export interface MirrorOptions {