mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-01-27 04:40:52 +03:00
feat: implement InlineDestinationEditor for repository destination management and add API support for updating destination organization
This commit is contained in:
37
README.md
37
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
|
- 🔁 Sync public, private, or starred GitHub repos to Gitea
|
||||||
- 🏢 Mirror entire organizations with flexible organization strategies
|
- 🏢 Mirror entire organizations with flexible organization strategies
|
||||||
|
- 🎯 Custom destination control for both organizations and individual repositories
|
||||||
- 🐞 Optional mirroring of issues and labels
|
- 🐞 Optional mirroring of issues and labels
|
||||||
- 🌟 Mirror your starred repositories to a dedicated organization
|
- 🌟 Mirror your starred repositories to a dedicated organization
|
||||||
- 🕹️ Modern user interface with toast notifications and smooth experience
|
- 🕹️ Modern user interface with toast notifications and smooth experience
|
||||||
@@ -317,19 +318,15 @@ Key configuration options include:
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **SQLite is the only database required for Gitea Mirror**, handling both data storage and real-time event notifications.
|
> **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)
|
#### 1. **Preserve GitHub Structure** (Default)
|
||||||
- Personal repositories → Your Gitea username (or custom organization)
|
- Personal repositories → Your Gitea username (or custom organization)
|
||||||
- Organization repositories → Same organization name in Gitea (with individual overrides)
|
- Organization repositories → Same organization name in Gitea (with individual overrides)
|
||||||
- Maintains the exact structure from GitHub with optional customization
|
- 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**
|
#### 2. **Single Organization**
|
||||||
- All repositories → One designated organization
|
- All repositories → One designated organization
|
||||||
- Simplifies management by consolidating everything
|
- Simplifies management by consolidating everything
|
||||||
@@ -340,12 +337,34 @@ Gitea Mirror offers three flexible strategies for organizing your repositories i
|
|||||||
- No organizations needed
|
- No organizations needed
|
||||||
- Simplest approach for personal use
|
- 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]
|
> [!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]
|
> [!TIP]
|
||||||
> **Example Use Case**: With the "Preserve" strategy and overrides, you can:
|
> **Example Use Cases**:
|
||||||
> - Mirror personal repos to `username-mirror` organization
|
> - 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
|
> - Keep `company-org` repos in their own `company-org` organization
|
||||||
> - Override `community-scripts` to go to `community-mirrors` organization
|
> - Override `community-scripts` to go to `community-mirrors` organization
|
||||||
> - This gives you complete control while maintaining GitHub's structure as the default
|
> - This gives you complete control while maintaining GitHub's structure as the default
|
||||||
|
|||||||
187
src/components/repositories/InlineDestinationEditor.tsx
Normal file
187
src/components/repositories/InlineDestinationEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -721,6 +721,7 @@ export default function Repository() {
|
|||||||
loadingRepoIds={loadingRepoIds}
|
loadingRepoIds={loadingRepoIds}
|
||||||
selectedRepoIds={selectedRepoIds}
|
selectedRepoIds={selectedRepoIds}
|
||||||
onSelectionChange={setSelectedRepoIds}
|
onSelectionChange={setSelectedRepoIds}
|
||||||
|
onRefresh={() => fetchRepositories(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import Fuse from "fuse.js";
|
import Fuse from "fuse.js";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
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 { SiGithub, SiGitea } from "react-icons/si";
|
||||||
import type { Repository } from "@/lib/db/schema";
|
import type { Repository } from "@/lib/db/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||||
|
|
||||||
interface RepositoryTableProps {
|
interface RepositoryTableProps {
|
||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
@@ -29,6 +30,7 @@ interface RepositoryTableProps {
|
|||||||
loadingRepoIds: Set<string>;
|
loadingRepoIds: Set<string>;
|
||||||
selectedRepoIds: Set<string>;
|
selectedRepoIds: Set<string>;
|
||||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||||
|
onRefresh?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RepositoryTable({
|
export default function RepositoryTable({
|
||||||
@@ -43,10 +45,34 @@ export default function RepositoryTable({
|
|||||||
loadingRepoIds,
|
loadingRepoIds,
|
||||||
selectedRepoIds,
|
selectedRepoIds,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
onRefresh,
|
||||||
}: RepositoryTableProps) {
|
}: RepositoryTableProps) {
|
||||||
const tableParentRef = useRef<HTMLDivElement>(null);
|
const tableParentRef = useRef<HTMLDivElement>(null);
|
||||||
const { giteaConfig } = useGiteaConfig();
|
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
|
// Helper function to construct Gitea repository URL
|
||||||
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
const getGiteaRepoUrl = (repository: Repository): string | null => {
|
||||||
if (!giteaConfig?.url) {
|
if (!giteaConfig?.url) {
|
||||||
@@ -296,8 +322,13 @@ export default function RepositoryTable({
|
|||||||
{/* Repository */}
|
{/* Repository */}
|
||||||
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
<div className="h-full p-3 flex items-center gap-2 flex-[2.5]">
|
||||||
<GitFork className="h-4 w-4 text-muted-foreground" />
|
<GitFork className="h-4 w-4 text-muted-foreground" />
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-medium">{repo.name}</div>
|
<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">
|
<div className="text-xs text-muted-foreground">
|
||||||
{repo.fullName}
|
{repo.fullName}
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +352,12 @@ export default function RepositoryTable({
|
|||||||
|
|
||||||
{/* Organization */}
|
{/* Organization */}
|
||||||
<div className="h-full p-3 flex items-center flex-[1]">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Last Mirrored */}
|
{/* Last Mirrored */}
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ function runMigrations(db: Database) {
|
|||||||
db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT");
|
db.exec("ALTER TABLE organizations ADD COLUMN destination_org TEXT");
|
||||||
console.log("✅ Migration completed: destination_org column added");
|
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) {
|
} catch (error) {
|
||||||
console.error("❌ Error running migrations:", error);
|
console.error("❌ Error running migrations:", error);
|
||||||
// Don't throw - migrations should be non-breaking
|
// Don't throw - migrations should be non-breaking
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export const repositorySchema = z.object({
|
|||||||
errorMessage: z.string().optional(),
|
errorMessage: z.string().optional(),
|
||||||
|
|
||||||
mirroredLocation: z.string().default(""), // Store the full Gitea path where repo was mirrored
|
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()),
|
createdAt: z.date().default(() => new Date()),
|
||||||
updatedAt: z.date().default(() => new Date()),
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export const getGiteaRepoOwnerAsync = async ({
|
|||||||
return config.giteaConfig.starredReposOrg;
|
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
|
// Check for organization-specific override
|
||||||
if (repository.organization) {
|
if (repository.organization) {
|
||||||
const orgConfig = await getOrganizationConfig({
|
const orgConfig = await getOrganizationConfig({
|
||||||
|
|||||||
82
src/pages/api/repositories/[id].ts
Normal file
82
src/pages/api/repositories/[id].ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user