feat: implement InlineDestinationEditor for repository destination management and add API support for updating destination organization

This commit is contained in:
Arunavo Ray
2025-06-24 12:11:10 +05:30
parent f03405b87a
commit e6c4ca0731
8 changed files with 355 additions and 13 deletions

View File

@@ -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<void>;
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<HTMLInputElement>(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 (
<div className={cn("flex items-center gap-1", className)}>
<Input
ref={inputRef}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCancel}
className="h-6 text-sm px-2 py-0 w-24"
disabled={isLoading}
/>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={handleSave}
disabled={isLoading}
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={handleCancel}
disabled={isLoading}
>
<X className="h-3 w-3" />
</Button>
</div>
);
}
return (
<div className={cn("flex flex-col gap-0.5", className)}>
{/* Show GitHub org if exists */}
{repository.organization && (
<span className="text-xs text-muted-foreground">
{repository.organization}
</span>
)}
{/* Show Gitea destination */}
<div className="flex items-center gap-1 group">
<span className="text-sm">
{currentDestination || "-"}
</span>
{hasOverride && (
<Badge variant="outline" className="h-4 px-1 text-[10px] ml-1">
custom
</Badge>
)}
{isStarredRepo && (
<Badge variant="secondary" className="h-4 px-1 text-[10px] ml-1">
starred
</Badge>
)}
{!isStarredRepo && (
<Button
size="sm"
variant="ghost"
className="h-4 w-4 p-0 opacity-0 group-hover:opacity-60 hover:opacity-100 ml-1"
onClick={handleStartEdit}
disabled={isUpdating || isLoading}
title="Edit destination"
>
<Edit3 className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -721,6 +721,7 @@ export default function Repository() {
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
onRefresh={() => fetchRepositories(false)}
/>
)}

View File

@@ -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<string>;
selectedRepoIds: Set<string>;
onSelectionChange: (selectedIds: Set<string>) => void;
onRefresh?: () => Promise<void>;
}
export default function RepositoryTable({
@@ -43,10 +45,34 @@ export default function RepositoryTable({
loadingRepoIds,
selectedRepoIds,
onSelectionChange,
onRefresh,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(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 */}
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
<GitFork className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">{repo.name}</div>
<div className="flex-1">
<div className="font-medium flex items-center gap-1">
{repo.name}
{repo.isStarred && (
<Star className="h-3 w-3 fill-yellow-500 text-yellow-500" />
)}
</div>
<div className="text-xs text-muted-foreground">
{repo.fullName}
</div>
@@ -321,7 +352,12 @@ export default function RepositoryTable({
{/* Organization */}
<div className="h-full p-3 flex items-center flex-[1]">
<p className="text-sm"> {repo.organization || "-"}</p>
<InlineDestinationEditor
repository={repo}
giteaConfig={giteaConfig}
onUpdate={handleUpdateDestination}
isUpdating={loadingRepoIds.has(repo.id ?? "")}
/>
</div>
{/* Last Mirrored */}

View File

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

View File

@@ -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()),

View File

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

View File

@@ -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);
}
};