From e6c4ca0731b961f81b6cd6eba7d66547774bedca Mon Sep 17 00:00:00 2001 From: Arunavo Ray Date: Tue, 24 Jun 2025 12:11:10 +0530 Subject: [PATCH] feat: implement InlineDestinationEditor for repository destination management and add API support for updating destination organization --- README.md | 37 +++- .../repositories/InlineDestinationEditor.tsx | 187 ++++++++++++++++++ src/components/repositories/Repository.tsx | 1 + .../repositories/RepositoryTable.tsx | 44 ++++- src/lib/db/index.ts | 10 + src/lib/db/schema.ts | 1 + src/lib/gitea.ts | 6 + src/pages/api/repositories/[id].ts | 82 ++++++++ 8 files changed, 355 insertions(+), 13 deletions(-) create mode 100644 src/components/repositories/InlineDestinationEditor.tsx create mode 100644 src/pages/api/repositories/[id].ts diff --git a/README.md b/README.md index 789f1dd..803b501 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ See the [LXC Container Deployment Guide](scripts/README-lxc.md). - 🔁 Sync public, private, or starred GitHub repos to Gitea - đŸĸ Mirror entire organizations with flexible organization strategies +- đŸŽ¯ Custom destination control for both organizations and individual repositories - 🐞 Optional mirroring of issues and labels - 🌟 Mirror your starred repositories to a dedicated organization - đŸ•šī¸ Modern user interface with toast notifications and smooth experience @@ -317,19 +318,15 @@ Key configuration options include: > [!IMPORTANT] > **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications. -### Mirror Strategies +### Mirror Strategies & Destination Customization -Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea: +Gitea Mirror offers three flexible strategies for organizing your repositories in Gitea, with fine-grained control over destinations: #### 1. **Preserve GitHub Structure** (Default) - 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 - Simplifies management by consolidating everything @@ -340,12 +337,34 @@ Gitea Mirror offers three flexible strategies for organizing your repositories i - No organizations needed - Simplest approach for personal use +#### Destination Customization + +**Organization-Level Overrides:** +- Click the edit button on any organization card to set a custom destination +- All repositories from that GitHub organization will be mirrored to your specified Gitea organization +- Visual indicators show when custom destinations are active + +**Repository-Level Overrides:** +- Fine-tune individual repository destinations in the repository table +- Click the edit button in the "Destination" column to customize where a specific repo is mirrored +- Overrides organization-level settings for maximum flexibility +- Starred repositories display a ⭐ icon and always go to the configured starred repos organization + +**Priority Hierarchy:** +1. Starred repositories → Always go to `starredReposOrg` (not editable) +2. Repository-level custom destination (highest priority for non-starred) +3. Organization-level custom destination +4. Personal repos override (for non-organization repos) +5. Default strategy rules (lowest priority) + > [!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. +> **Starred Repositories**: Repositories you've starred on GitHub are automatically organized into a separate organization (default: "starred") and cannot have custom destinations. They're marked with a ⭐ icon for easy identification. > [!TIP] -> **Example Use Case**: With the "Preserve" strategy and overrides, you can: -> - Mirror personal repos to `username-mirror` organization +> **Example Use Cases**: +> - Mirror personal repos to `personal-archive` organization +> - Redirect `work-org` repos to `company-mirror` in Gitea +> - Override a single important repo to go to a special 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 diff --git a/src/components/repositories/InlineDestinationEditor.tsx b/src/components/repositories/InlineDestinationEditor.tsx new file mode 100644 index 0000000..951b399 --- /dev/null +++ b/src/components/repositories/InlineDestinationEditor.tsx @@ -0,0 +1,187 @@ +import { useState, useRef, useEffect } from "react"; +import { Edit3, Check, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Repository } from "@/lib/db/schema"; + +interface InlineDestinationEditorProps { + repository: Repository; + giteaConfig: any; + onUpdate: (repoId: string, newDestination: string | null) => Promise; + isUpdating?: boolean; + className?: string; +} + +export function InlineDestinationEditor({ + repository, + giteaConfig, + onUpdate, + isUpdating = false, + className, +}: InlineDestinationEditorProps) { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + // Determine the default destination based on repository properties and config + const getDefaultDestination = () => { + // Starred repos always go to the configured starredReposOrg + if (repository.isStarred && giteaConfig?.starredReposOrg) { + return giteaConfig.starredReposOrg; + } + + // Check mirror strategy + const strategy = giteaConfig?.mirrorStrategy || 'preserve'; + + if (strategy === 'single-org' && giteaConfig?.organization) { + // All repos go to a single organization + return giteaConfig.organization; + } else if (strategy === 'flat-user') { + // All repos go under the user account + return giteaConfig?.username || repository.owner; + } else { + // 'preserve' strategy or default + // For organization repos, use the organization name + if (repository.organization) { + return repository.organization; + } + // For personal repos, check if personalReposOrg is configured + if (!repository.organization && giteaConfig?.personalReposOrg) { + return giteaConfig.personalReposOrg; + } + // Default to the gitea username or owner + return giteaConfig?.username || repository.owner; + } + }; + + const defaultDestination = getDefaultDestination(); + const currentDestination = repository.destinationOrg || defaultDestination; + const hasOverride = repository.destinationOrg && repository.destinationOrg !== defaultDestination; + const isStarredRepo = repository.isStarred && giteaConfig?.starredReposOrg; + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleStartEdit = () => { + if (isStarredRepo) return; // Don't allow editing starred repos + setEditValue(currentDestination); + setIsEditing(true); + }; + + const handleSave = async () => { + const trimmedValue = editValue.trim(); + const newDestination = trimmedValue === defaultDestination ? null : trimmedValue; + + if (trimmedValue === currentDestination) { + setIsEditing(false); + return; + } + + setIsLoading(true); + try { + await onUpdate(repository.id!, newDestination); + setIsEditing(false); + } catch (error) { + // Revert on error + setEditValue(currentDestination); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + setEditValue(currentDestination); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSave(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancel(); + } + }; + + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleCancel} + className="h-6 text-sm px-2 py-0 w-24" + disabled={isLoading} + /> + + +
+ ); + } + + return ( +
+ {/* Show GitHub org if exists */} + {repository.organization && ( + + {repository.organization} + + )} + + {/* Show Gitea destination */} +
+ + {currentDestination || "-"} + + {hasOverride && ( + + custom + + )} + {isStarredRepo && ( + + starred + + )} + {!isStarredRepo && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/repositories/Repository.tsx b/src/components/repositories/Repository.tsx index b70a5b9..01b2202 100644 --- a/src/components/repositories/Repository.tsx +++ b/src/components/repositories/Repository.tsx @@ -721,6 +721,7 @@ export default function Repository() { loadingRepoIds={loadingRepoIds} selectedRepoIds={selectedRepoIds} onSelectionChange={setSelectedRepoIds} + onRefresh={() => fetchRepositories(false)} /> )} diff --git a/src/components/repositories/RepositoryTable.tsx b/src/components/repositories/RepositoryTable.tsx index b79091d..0585500 100644 --- a/src/components/repositories/RepositoryTable.tsx +++ b/src/components/repositories/RepositoryTable.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef } from "react"; import Fuse from "fuse.js"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { FlipHorizontal, GitFork, RefreshCw, RotateCcw } from "lucide-react"; +import { FlipHorizontal, GitFork, RefreshCw, RotateCcw, Star } from "lucide-react"; import { SiGithub, SiGitea } from "react-icons/si"; import type { Repository } from "@/lib/db/schema"; import { Button } from "@/components/ui/button"; @@ -16,6 +16,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { InlineDestinationEditor } from "./InlineDestinationEditor"; interface RepositoryTableProps { repositories: Repository[]; @@ -29,6 +30,7 @@ interface RepositoryTableProps { loadingRepoIds: Set; selectedRepoIds: Set; onSelectionChange: (selectedIds: Set) => void; + onRefresh?: () => Promise; } export default function RepositoryTable({ @@ -43,10 +45,34 @@ export default function RepositoryTable({ loadingRepoIds, selectedRepoIds, onSelectionChange, + onRefresh, }: RepositoryTableProps) { const tableParentRef = useRef(null); const { giteaConfig } = useGiteaConfig(); + const handleUpdateDestination = async (repoId: string, newDestination: string | null) => { + // Call API to update repository destination + const response = await fetch(`/api/repositories/${repoId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + destinationOrg: newDestination, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to update repository"); + } + + // Refresh repositories data + if (onRefresh) { + await onRefresh(); + } + }; + // Helper function to construct Gitea repository URL const getGiteaRepoUrl = (repository: Repository): string | null => { if (!giteaConfig?.url) { @@ -296,8 +322,13 @@ export default function RepositoryTable({ {/* Repository */}
-
-
{repo.name}
+
+
+ {repo.name} + {repo.isStarred && ( + + )} +
{repo.fullName}
@@ -321,7 +352,12 @@ export default function RepositoryTable({ {/* Organization */}
-

{repo.organization || "-"}

+
{/* Last Mirrored */} diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 377f566..66414b3 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -50,6 +50,16 @@ function runMigrations(db: Database) { db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT"); console.log("✅ Migration completed: destination_org column added"); } + + // Migration 2: Add destination_org column to repositories table + const repoTableInfo = db.query("PRAGMA table_info(repositories)").all() as Array<{name: string}>; + const hasRepoDestinationOrg = repoTableInfo.some(col => col.name === 'destination_org'); + + if (!hasRepoDestinationOrg) { + console.log("🔄 Running migration: Adding destination_org column to repositories table"); + db.exec("ALTER TABLE repositories ADD COLUMN destination_org TEXT"); + console.log("✅ Migration completed: destination_org column added to repositories"); + } } catch (error) { console.error("❌ Error running migrations:", error); // Don't throw - migrations should be non-breaking diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 06ed135..7c59bfd 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -101,6 +101,7 @@ export const repositorySchema = z.object({ errorMessage: z.string().optional(), mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored + destinationOrg: z.string().optional(), // Custom destination organization override createdAt: z.date().default(() => new Date()), updatedAt: z.date().default(() => new Date()), diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index fa6c33b..c80053e 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -62,6 +62,12 @@ export const getGiteaRepoOwnerAsync = async ({ return config.giteaConfig.starredReposOrg; } + // Check for repository-specific override (second highest priority) + if (repository.destinationOrg) { + console.log(`Using repository override: ${repository.fullName} -> ${repository.destinationOrg}`); + return repository.destinationOrg; + } + // Check for organization-specific override if (repository.organization) { const orgConfig = await getOrganizationConfig({ diff --git a/src/pages/api/repositories/[id].ts b/src/pages/api/repositories/[id].ts new file mode 100644 index 0000000..b79bcce --- /dev/null +++ b/src/pages/api/repositories/[id].ts @@ -0,0 +1,82 @@ +import type { APIRoute } from "astro"; +import { db, repositories } from "@/lib/db"; +import { eq, and } from "drizzle-orm"; +import { createSecureErrorResponse } from "@/lib/utils"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +export const PATCH: APIRoute = async ({ request, params, cookies }) => { + try { + // Get token from Authorization header or cookies + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.split(" ")[1] || cookies.get("token")?.value; + + if (!token) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // Verify token and get user ID + let userId: string; + try { + const decoded = jwt.verify(token, JWT_SECRET) as { id: string }; + userId = decoded.id; + } catch (error) { + return new Response(JSON.stringify({ error: "Invalid token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + const repoId = params.id; + if (!repoId) { + return new Response(JSON.stringify({ error: "Repository ID is required" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const body = await request.json(); + const { destinationOrg } = body; + + // Validate that the repository belongs to the user + const [existingRepo] = await db + .select() + .from(repositories) + .where(and(eq(repositories.id, repoId), eq(repositories.userId, userId))) + .limit(1); + + if (!existingRepo) { + return new Response(JSON.stringify({ error: "Repository not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Update the repository's destination override + await db + .update(repositories) + .set({ + destinationOrg: destinationOrg || null, + updatedAt: new Date(), + }) + .where(eq(repositories.id, repoId)); + + return new Response( + JSON.stringify({ + success: true, + message: "Repository destination updated successfully", + destinationOrg: destinationOrg || null, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } catch (error) { + return createSecureErrorResponse(error, "Update repository destination", 500); + } +}; \ No newline at end of file