Merge pull request #174 from tasarren/feat/starred-by-org

Allow starred repos to be mirrored preserving structure
This commit is contained in:
ARUNAVO RAY
2026-02-24 08:49:43 +05:30
committed by GitHub
20 changed files with 256 additions and 76 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
@@ -183,4 +184,4 @@ DOCKER_TAG=latest
# =========================================== # ===========================================
# TLS/SSL Configuration # TLS/SSL Configuration
# GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing # GITEA_SKIP_TLS_VERIFY=false # WARNING: Only use for testing

View File

@@ -30,15 +30,17 @@ This workflow runs on all branches and pull requests. It:
### Docker Build and Push (`docker-build.yml`) ### Docker Build and Push (`docker-build.yml`)
This workflow builds and pushes Docker images to GitHub Container Registry (ghcr.io), but only when changes are merged to the main branch. This workflow builds Docker images on pushes and pull requests, and pushes to GitHub Container Registry (ghcr.io) when permissions allow (main/tags and same-repo PRs).
**When it runs:** **When it runs:**
- On push to the main branch - On push to the main branch
- On tag creation (v*) - On tag creation (v*)
- On pull requests (build + scan; push only for same-repo PRs)
**Key features:** **Key features:**
- Builds multi-architecture images (amd64 and arm64) - Builds multi-architecture images (amd64 and arm64)
- Pushes images only on main branch, not for PRs - Pushes images for main/tags and same-repo PRs
- Skips registry push for fork PRs (avoids package write permission failures)
- Uses build caching to speed up builds - Uses build caching to speed up builds
- Creates multiple tags for each image (latest, semver, sha) - Creates multiple tags for each image (latest, semver, sha)

View File

@@ -55,6 +55,7 @@ jobs:
driver-opts: network=host driver-opts: network=host
- name: Log into registry - name: Log into registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
@@ -105,7 +106,7 @@ jobs:
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
@@ -128,13 +129,14 @@ jobs:
# Wait for image to be available in registry # Wait for image to be available in registry
- name: Wait for image availability - name: Wait for image availability
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: | run: |
echo "Waiting for image to be available in registry..." echo "Waiting for image to be available in registry..."
sleep 5 sleep 5
# Add comment to PR with image details # Add comment to PR with image details
- name: Comment PR with image tag - name: Comment PR with image tag
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}

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,38 +57,94 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
</h4> </h4>
</div> </div>
{/* First row - Organization inputs with consistent layout */} <div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Label className="text-sm font-normal flex items-center gap-2">
{/* Left column - always shows starred repos org */} Starred Repository Destination
<div className="space-y-1"> <TooltipProvider>
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2"> <Tooltip>
<Star className="h-3.5 w-3.5" /> <TooltipTrigger>
Starred Repos Organization <Info className="h-3.5 w-3.5 text-muted-foreground" />
<TooltipProvider> </TooltipTrigger>
<Tooltip> <TooltipContent>
<TooltipTrigger> <p>Choose whether starred repos use one org or keep their source Owner/Org paths</p>
<Info className="h-3.5 w-3.5 text-muted-foreground" /> </TooltipContent>
</TooltipTrigger> </Tooltip>
<TooltipContent> </TooltipProvider>
<p>Starred repositories will be organized separately in this organization</p> </Label>
</TooltipContent> <div className="rounded-lg border bg-muted/20 p-2">
</Tooltip> <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
</TooltipProvider> <button
</Label> type="button"
<Input onClick={() => onStarredReposModeChange("dedicated-org")}
id="starredReposOrg" aria-pressed={activeStarredMode === "dedicated-org"}
value={starredReposOrg || ""} className={cn(
onChange={(e) => onStarredReposOrgChange(e.target.value)} "text-left px-3 py-2 rounded-md border text-sm transition-all",
placeholder="starred" activeStarredMode === "dedicated-org"
className="" ? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
/> : "bg-background hover:bg-accent/50 border-input"
<p className="text-xs text-muted-foreground mt-1"> )}
Keep starred repos organized separately >
Dedicated Organization
</button>
<button
type="button"
onClick={() => onStarredReposModeChange("preserve-owner")}
aria-pressed={activeStarredMode === "preserve-owner"}
className={cn(
"text-left px-3 py-2 rounded-md border text-sm transition-all",
activeStarredMode === "preserve-owner"
? "bg-accent border-accent-foreground/30 ring-1 ring-accent-foreground/20 font-medium shadow-sm"
: "bg-background hover:bg-accent/50 border-input"
)}
>
Preserve Source Owner/Org
</button>
</div>
<p className="mt-2 px-1 text-xs text-muted-foreground">
{
activeStarredMode === "dedicated-org"
? "All starred repositories go to a single destination organization."
: "Starred repositories keep their original GitHub Owner/Org destination."
}
</p> </p>
</div> </div>
</div>
{/* Right column - shows destination org for single-org/mixed, personal repos org for preserve, empty div for others */} {/* First row - Organization inputs */}
{strategy === "single-org" || strategy === "mixed" ? ( {(showStarredReposOrgInput || showDestinationOrgInput) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{showStarredReposOrgInput ? (
<div className="space-y-1">
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
<Star className="h-3.5 w-3.5" />
Starred Repos Organization
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>Starred repositories will be organized separately in this organization</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Label>
<Input
id="starredReposOrg"
value={starredReposOrg || ""}
onChange={(e) => onStarredReposOrgChange(e.target.value)}
placeholder="starred"
className=""
/>
<p className="text-xs text-muted-foreground mt-1">
Keep starred repos organized separately
</p>
</div>
) : (
<div className="hidden md:block" />
)}
{showDestinationOrgInput ? (
<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"}
@@ -114,10 +178,11 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
} }
</p> </p>
</div> </div>
) : ( ) : (
<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 +237,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}
/> />
@@ -434,4 +443,4 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
</RadioGroup> </RadioGroup>
</div> </div>
); );
}; };

View File

@@ -28,9 +28,16 @@ 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) {
return giteaConfig.starredReposOrg; const starredReposMode = giteaConfig?.starredReposMode || "dedicated-org";
if (starredReposMode === "preserve-owner") {
return repository.organization || repository.owner;
}
if (giteaConfig?.starredReposOrg) {
return giteaConfig.starredReposOrg;
}
return "starred";
} }
// Check mirror strategy // Check mirror strategy
@@ -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) {
@@ -184,4 +191,4 @@ export function InlineDestinationEditor({
</div> </div>
</div> </div>
); );
} }

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,
@@ -287,4 +300,4 @@ export async function syncStarredRepositories({
}, },
} }
); );
} }

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({
@@ -222,4 +221,4 @@ export const POST: APIRoute = async ({ request }) => {
return createSecureErrorResponse(error, "mirror-repo API", 500); return createSecureErrorResponse(error, "mirror-repo API", 500);
} }
}; };

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.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

@@ -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 {