mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-06 19:46:44 +03:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d8d75c8a6 | ||
|
|
e6c4ca0731 | ||
|
|
f03405b87a | ||
|
|
ce367e3761 | ||
|
|
cfe65cadca | ||
|
|
68108b8383 | ||
|
|
d2bec1d56e | ||
|
|
c9404f2674 | ||
|
|
80ef19c634 | ||
|
|
83c924566c | ||
|
|
7b58df375e | ||
|
|
1d27bd31d8 | ||
|
|
13d4257c4f | ||
|
|
818ba77693 | ||
|
|
056970e577 | ||
|
|
65ea73e238 | ||
|
|
398f00aceb | ||
|
|
50972713a3 | ||
|
|
fbf3033455 | ||
|
|
cc4d8dabbc | ||
|
|
8bba3d3521 | ||
|
|
be63555e5c | ||
|
|
32a906369f | ||
|
|
064474fd13 | ||
|
|
2ac933b599 | ||
|
|
403fe08bae | ||
|
|
23c7ff7349 | ||
|
|
3169af44cb | ||
|
|
c1d93dbbc6 | ||
|
|
047719cde9 | ||
|
|
13d4b03541 | ||
|
|
f07ae220b0 | ||
|
|
01647445f2 | ||
|
|
13cbf86309 | ||
|
|
792096d209 | ||
|
|
e94de5c9ca |
72
CHANGELOG.md
72
CHANGELOG.md
@@ -7,6 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.17.0] - 2025-06-24
|
||||
|
||||
### Added
|
||||
- Custom destination control for individual repositories with inline editing
|
||||
- Organization-level destination overrides with visual destination editor
|
||||
- Personal repositories organization override configuration option
|
||||
- Visual indicators for starred repositories (⭐ icon) in repository list
|
||||
- Repository-level destination override API endpoint
|
||||
- Destination customization priority hierarchy system
|
||||
- "View on Gitea" buttons for organizations with smart tooltip states
|
||||
|
||||
### Changed
|
||||
- Enhanced repository table with destination column showing both GitHub org and Gitea destination
|
||||
- Updated organization cards to display custom destinations with visual indicators
|
||||
- Improved getGiteaRepoOwnerAsync to support repository-level destination overrides
|
||||
|
||||
### Improved
|
||||
- Better visual feedback for custom destinations with badges and inline editing
|
||||
- Enhanced user experience with hover-based edit buttons
|
||||
- Comprehensive destination customization documentation in README
|
||||
|
||||
## [2.16.3] - 2025-06-20
|
||||
|
||||
### Added
|
||||
- Custom 404 error page with helpful navigation links
|
||||
- HoverCard components for better UX in configuration forms
|
||||
|
||||
### Improved
|
||||
- Replaced popover components with hover cards for information tooltips
|
||||
- Enhanced user experience with responsive hover interactions
|
||||
|
||||
## [2.16.2] - 2025-06-17
|
||||
|
||||
### Added
|
||||
- Bulk actions for repository management with selection support
|
||||
|
||||
### Improved
|
||||
- Enhanced organization card display with status badges and improved layout
|
||||
|
||||
## [2.16.1] - 2025-06-17
|
||||
|
||||
### Improved
|
||||
- Improved repository owner handling and mirror strategy in Gitea integration
|
||||
- Updated label for starred repositories organization for consistency
|
||||
|
||||
## [2.16.0] - 2025-06-17
|
||||
|
||||
### Added
|
||||
- Enhanced OrganizationConfiguration component with improved layout and metadata options
|
||||
- New GitHubMirrorSettings component with better organization and flexibility
|
||||
- Enhanced starred repositories content selection and improved layout
|
||||
|
||||
### Improved
|
||||
- Enhanced configuration interface layout and spacing across multiple components
|
||||
- Streamlined OrganizationStrategy component with cleaner imports and better organization
|
||||
- Improved responsive layout for larger screens in configuration forms
|
||||
- Better icon usage and clarity in configuration components
|
||||
- Enhanced tooltip descriptions and component organization
|
||||
- Improved version comparison logic in health API
|
||||
- Enhanced issue mirroring logic for starred repositories
|
||||
|
||||
### Fixed
|
||||
- Fixed mirror to single organization functionality
|
||||
- Resolved organization strategy layout issues
|
||||
- Cleaned up unused imports across multiple components
|
||||
|
||||
### Refactored
|
||||
- Simplified component structures by removing unused imports and dependencies
|
||||
- Enhanced layout flexibility in GitHubConfigForm and GiteaConfigForm components
|
||||
- Improved component organization and code clarity
|
||||
- Removed ConnectionsForm and useMirror hook for better code organization
|
||||
|
||||
## [2.14.0] - 2025-06-17
|
||||
|
||||
### Added
|
||||
|
||||
42
README.md
42
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,14 +318,14 @@ 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
|
||||
- Organization repositories → Same organization name in Gitea
|
||||
- Maintains the exact structure from GitHub
|
||||
- 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
|
||||
|
||||
#### 2. **Single Organization**
|
||||
- All repositories → One designated organization
|
||||
@@ -336,8 +337,37 @@ 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 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
|
||||
|
||||
## 🚀 Development
|
||||
|
||||
|
||||
3
bun.lock
3
bun.lock
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
@@ -334,6 +335,8 @@
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "2.14.0",
|
||||
"version": "2.17.0",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -42,6 +42,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
|
||||
81
src/components/NotFound.tsx
Normal file
81
src/components/NotFound.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Home, ArrowLeft, GitBranch, BookOpen, Settings, FileQuestion } from "lucide-react";
|
||||
|
||||
export function NotFound() {
|
||||
return (
|
||||
<div className="h-dvh bg-muted/30 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold">404</h1>
|
||||
<h2 className="text-xl font-semibold mt-2">Page Not Found</h2>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild className="w-full">
|
||||
<a href="/">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full" onClick={() => window.history.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or visit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<a
|
||||
href="/repositories"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Repositories</span>
|
||||
</a>
|
||||
<a
|
||||
href="/config"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Config</span>
|
||||
</a>
|
||||
<a
|
||||
href="/docs"
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-xs">Docs</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Error Code */}
|
||||
<div className="text-center pt-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Error Code: <code className="font-mono">404_NOT_FOUND</code>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,11 @@ export function ConfigTabs() {
|
||||
return isGitHubValid && isGiteaValid;
|
||||
};
|
||||
|
||||
const isGitHubConfigValid = (): boolean => {
|
||||
const { githubConfig } = config;
|
||||
return !!(githubConfig.username.trim() && githubConfig.token.trim());
|
||||
};
|
||||
|
||||
// Removed the problematic useEffect that was causing circular dependencies
|
||||
// The lastRun and nextRun should be managed by the backend and fetched via API
|
||||
|
||||
@@ -571,10 +576,10 @@ export function ConfigTabs() {
|
||||
<div className="flex gap-x-4">
|
||||
<Button
|
||||
onClick={handleImportGitHubData}
|
||||
disabled={isSyncing || !isConfigFormValid()}
|
||||
disabled={isSyncing || !isGitHubConfigValid()}
|
||||
title={
|
||||
!isConfigFormValid()
|
||||
? 'Please fill all required GitHub and Gitea fields'
|
||||
!isGitHubConfigValid()
|
||||
? 'Please fill GitHub username and token fields'
|
||||
: isSyncing
|
||||
? 'Import in progress'
|
||||
: 'Import GitHub Data'
|
||||
@@ -598,7 +603,7 @@ export function ConfigTabs() {
|
||||
{/* Content section - Grid layout */}
|
||||
<div className="space-y-6">
|
||||
{/* GitHub & Gitea connections - Side by side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:items-stretch">
|
||||
<GitHubConfigForm
|
||||
config={config.githubConfig}
|
||||
setConfig={update =>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { GitHubConfigForm } from './GitHubConfigForm';
|
||||
import { GiteaConfigForm } from './GiteaConfigForm';
|
||||
import { Separator } from '../ui/separator';
|
||||
import type { GitHubConfig, GiteaConfig } from '@/types/config';
|
||||
|
||||
interface ConnectionsFormProps {
|
||||
githubConfig: GitHubConfig;
|
||||
giteaConfig: GiteaConfig;
|
||||
setGithubConfig: (update: GitHubConfig | ((prev: GitHubConfig) => GitHubConfig)) => void;
|
||||
setGiteaConfig: (update: GiteaConfig | ((prev: GiteaConfig) => GiteaConfig)) => void;
|
||||
onAutoSaveGitHub?: (config: GitHubConfig) => Promise<void>;
|
||||
onAutoSaveGitea?: (config: GiteaConfig) => Promise<void>;
|
||||
isAutoSavingGitHub?: boolean;
|
||||
isAutoSavingGitea?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionsForm({
|
||||
githubConfig,
|
||||
giteaConfig,
|
||||
setGithubConfig,
|
||||
setGiteaConfig,
|
||||
onAutoSaveGitHub,
|
||||
onAutoSaveGitea,
|
||||
isAutoSavingGitHub,
|
||||
isAutoSavingGitea,
|
||||
}: ConnectionsFormProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GitHubConfigForm
|
||||
config={githubConfig}
|
||||
setConfig={setGithubConfig}
|
||||
onAutoSave={onAutoSaveGitHub}
|
||||
isAutoSaving={isAutoSavingGitHub}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<GiteaConfigForm
|
||||
config={giteaConfig}
|
||||
setConfig={setGiteaConfig}
|
||||
onAutoSave={onAutoSaveGitea}
|
||||
isAutoSaving={isAutoSavingGitea}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { githubApi } from "@/lib/api";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "../ui/alert";
|
||||
import { Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { GitHubMirrorSettings } from "./GitHubMirrorSettings";
|
||||
import { Separator } from "../ui/separator";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
interface GitHubConfigFormProps {
|
||||
config: GitHubConfig;
|
||||
@@ -87,7 +87,7 @@ export function GitHubConfigForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full self-start">
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
GitHub Configuration
|
||||
@@ -102,7 +102,7 @@ export function GitHubConfigForm({
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6">
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-username"
|
||||
@@ -123,12 +123,49 @@ export function GitHubConfigForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
GitHub Token
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<label
|
||||
htmlFor="github-token"
|
||||
className="block text-sm font-medium"
|
||||
>
|
||||
GitHub Token
|
||||
</label>
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span className="inline-flex p-0.5 hover:bg-muted rounded-sm transition-colors cursor-help">
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="right" align="start" className="w-80">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">GitHub Token Requirements</h4>
|
||||
<div className="text-sm space-y-2">
|
||||
<p>
|
||||
You need to create a <span className="font-medium">Classic GitHub PAT Token</span> with the following scopes:
|
||||
</p>
|
||||
<ul className="ml-4 space-y-1 list-disc">
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">repo</code></li>
|
||||
<li><code className="text-xs bg-muted px-1 py-0.5 rounded">admin:org</code></li>
|
||||
</ul>
|
||||
<p className="text-muted-foreground">
|
||||
The organization access is required for mirroring organization repositories.
|
||||
</p>
|
||||
<p>
|
||||
Generate tokens at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
<Input
|
||||
id="github-token"
|
||||
name="token"
|
||||
@@ -136,7 +173,7 @@ export function GitHubConfigForm({
|
||||
value={config.token}
|
||||
onChange={handleChange}
|
||||
className="bg-background"
|
||||
placeholder="Your GitHub personal access token"
|
||||
placeholder="Your GitHub token (classic) with repo and admin:org scopes"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Required for private repositories, organizations, and starred
|
||||
@@ -163,45 +200,8 @@ export function GitHubConfigForm({
|
||||
if (onAdvancedOptionsAutoSave) onAdvancedOptionsAutoSave(newOptions);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex-col items-start">
|
||||
<Alert variant="note" className="w-full">
|
||||
<AlertTriangle className="h-4 w-4 text-blue-600 dark:text-blue-400 mr-2" />
|
||||
<AlertDescription className="text-sm">
|
||||
<div className="font-semibold mb-1">Note:</div>
|
||||
<div className="mb-1">
|
||||
You need to create a{" "}
|
||||
<span className="font-semibold">Classic GitHub PAT Token</span>{" "}
|
||||
with following scopes:
|
||||
</div>
|
||||
<ul className="ml-4 mb-1 list-disc">
|
||||
<li>
|
||||
<code>repo</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>admin:org</code>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mb-1">
|
||||
The organization access is required for mirroring organization
|
||||
repositories.
|
||||
</div>
|
||||
<div>
|
||||
You can generate tokens at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium hover:text-blue-900 dark:hover:text-blue-200"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,22 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Info,
|
||||
GitBranch,
|
||||
Star,
|
||||
Building2,
|
||||
Lock,
|
||||
Archive,
|
||||
GitPullRequest,
|
||||
@@ -22,7 +27,9 @@ import {
|
||||
MessageSquare,
|
||||
Target,
|
||||
BookOpen,
|
||||
GitFork
|
||||
GitFork,
|
||||
ChevronDown,
|
||||
Funnel
|
||||
} from "lucide-react";
|
||||
import type { GitHubConfig, MirrorOptions, AdvancedOptions } from "@/types/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -70,6 +77,18 @@ export function GitHubMirrorSettings({
|
||||
// When metadata is disabled, all components should be disabled
|
||||
const isMetadataEnabled = mirrorOptions.mirrorMetadata;
|
||||
|
||||
// Calculate what content is included for starred repos
|
||||
const starredRepoContent = {
|
||||
code: true, // Always included
|
||||
releases: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorReleases,
|
||||
issues: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.issues,
|
||||
pullRequests: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.pullRequests,
|
||||
wiki: !advancedOptions.skipStarredIssues && mirrorOptions.mirrorMetadata && mirrorOptions.metadataComponents.wiki,
|
||||
};
|
||||
|
||||
const starredContentCount = Object.entries(starredRepoContent).filter(([key, value]) => key !== 'code' && value).length;
|
||||
const totalStarredOptions = 4; // releases, issues, PRs, wiki
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Repository Selection Section */}
|
||||
@@ -100,28 +119,162 @@ export function GitHubMirrorSettings({
|
||||
Include private repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mirror your private repositories (requires appropriate token permissions)
|
||||
Mirror your private repositories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="starred-repos"
|
||||
checked={githubConfig.mirrorStarred}
|
||||
onCheckedChange={(checked) => handleGitHubChange('mirrorStarred', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="starred-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Mirror starred repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include repositories you've starred on GitHub
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="starred-repos"
|
||||
checked={githubConfig.mirrorStarred}
|
||||
onCheckedChange={(checked) => handleGitHubChange('mirrorStarred', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="starred-repos"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Mirror starred repositories
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include repositories you've starred on GitHub
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Starred repos content selection - inline to prevent layout shift */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200",
|
||||
githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!githubConfig.mirrorStarred}
|
||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{advancedOptions.skipStarredIssues ? (
|
||||
"Code only"
|
||||
) : starredContentCount === 0 ? (
|
||||
"Code only"
|
||||
) : starredContentCount === totalStarredOptions ? (
|
||||
"Full content"
|
||||
) : (
|
||||
`${starredContentCount + 1} of ${totalStarredOptions + 1} selected`
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Starred repos content</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
Choose what content to mirror from starred repositories.
|
||||
Selecting "Lightweight mode" will only mirror code for better performance.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="starred-lightweight"
|
||||
checked={advancedOptions.skipStarredIssues}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="starred-lightweight"
|
||||
className="text-sm font-normal cursor-pointer flex-1"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">Lightweight mode</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Only mirror code, skip all metadata
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{!advancedOptions.skipStarredIssues && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Content included for starred repos:
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs pl-2">
|
||||
<GitBranch className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Source code</span>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px] px-2 h-4">Always</Badge>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.releases ? "" : "opacity-50"
|
||||
)}>
|
||||
<Tag className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Releases & Tags</span>
|
||||
{starredRepoContent.releases && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.issues ? "" : "opacity-50"
|
||||
)}>
|
||||
<MessageSquare className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Issues</span>
|
||||
{starredRepoContent.issues && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.pullRequests ? "" : "opacity-50"
|
||||
)}>
|
||||
<GitPullRequest className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Pull Requests</span>
|
||||
{starredRepoContent.pullRequests && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 text-xs pl-2",
|
||||
starredRepoContent.wiki ? "" : "opacity-50"
|
||||
)}>
|
||||
<BookOpen className="h-3 w-3 text-muted-foreground" />
|
||||
<span>Wiki</span>
|
||||
{starredRepoContent.wiki && <Badge variant="outline" className="ml-auto text-[10px] px-2 h-4">Included</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
To include more content, enable them in the Content & Data section below
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +325,7 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="mirror-metadata"
|
||||
@@ -193,92 +346,142 @@ export function GitHubMirrorSettings({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata sub-options */}
|
||||
{mirrorOptions.mirrorMetadata && (
|
||||
<div className="ml-7 space-y-2 p-3 bg-muted/30 dark:bg-muted/10 rounded-md">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata-issues"
|
||||
checked={mirrorOptions.metadataComponents.issues}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('issues', !!checked)}
|
||||
disabled={!isMetadataEnabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-issues"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
Issues
|
||||
</Label>
|
||||
</div>
|
||||
{/* Metadata multi-select - inline to prevent layout shift */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-end transition-opacity duration-200",
|
||||
mirrorOptions.mirrorMetadata ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!mirrorOptions.mirrorMetadata}
|
||||
className="h-8 text-xs font-normal min-w-[140px] justify-between"
|
||||
>
|
||||
<span>
|
||||
{(() => {
|
||||
const selectedCount = Object.values(mirrorOptions.metadataComponents).filter(Boolean).length;
|
||||
const totalCount = Object.keys(mirrorOptions.metadataComponents).length;
|
||||
if (selectedCount === 0) return "No items selected";
|
||||
if (selectedCount === totalCount) return "All items selected";
|
||||
return `${selectedCount} of ${totalCount} selected`;
|
||||
})()}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-3 w-3 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium">Metadata to mirror</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto px-2 py-1 text-xs font-normal text-primary hover:text-primary/80"
|
||||
onClick={() => {
|
||||
const allSelected = Object.values(mirrorOptions.metadataComponents).every(Boolean);
|
||||
const newValue = !allSelected;
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata-prs"
|
||||
checked={mirrorOptions.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('pullRequests', !!checked)}
|
||||
disabled={!isMetadataEnabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-prs"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
Pull Requests
|
||||
</Label>
|
||||
</div>
|
||||
// Update all metadata components at once
|
||||
onMirrorOptionsChange({
|
||||
...mirrorOptions,
|
||||
metadataComponents: {
|
||||
issues: newValue,
|
||||
pullRequests: newValue,
|
||||
labels: newValue,
|
||||
milestones: newValue,
|
||||
wiki: newValue,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{Object.values(mirrorOptions.metadataComponents).every(Boolean) ? 'Deselect all' : 'Select all'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata-labels"
|
||||
checked={mirrorOptions.metadataComponents.labels}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('labels', !!checked)}
|
||||
disabled={!isMetadataEnabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-labels"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Tag className="h-3 w-3" />
|
||||
Labels
|
||||
</Label>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata-milestones"
|
||||
checked={mirrorOptions.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('milestones', !!checked)}
|
||||
disabled={!isMetadataEnabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-milestones"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<Target className="h-3 w-3" />
|
||||
Milestones
|
||||
</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-issues-popup"
|
||||
checked={mirrorOptions.metadataComponents.issues}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('issues', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-issues-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Issues
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="metadata-wiki"
|
||||
checked={mirrorOptions.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('wiki', !!checked)}
|
||||
disabled={!isMetadataEnabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-wiki"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Wiki
|
||||
</Label>
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-prs-popup"
|
||||
checked={mirrorOptions.metadataComponents.pullRequests}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('pullRequests', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-prs-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Pull Requests
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-labels-popup"
|
||||
checked={mirrorOptions.metadataComponents.labels}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('labels', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-labels-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Labels
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-milestones-popup"
|
||||
checked={mirrorOptions.metadataComponents.milestones}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('milestones', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-milestones-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Target className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Milestones
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 py-1 px-1 rounded hover:bg-accent">
|
||||
<Checkbox
|
||||
id="metadata-wiki-popup"
|
||||
checked={mirrorOptions.metadataComponents.wiki}
|
||||
onCheckedChange={(checked) => handleMetadataComponentChange('wiki', !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="metadata-wiki-popup"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2 flex-1"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Wiki
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,7 +492,7 @@ export function GitHubMirrorSettings({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<Funnel className="h-4 w-4" />
|
||||
Filtering & Behavior
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
@@ -317,42 +520,6 @@ export function GitHubMirrorSettings({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{githubConfig.mirrorStarred && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="skip-starred-metadata"
|
||||
checked={advancedOptions.skipStarredIssues}
|
||||
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)}
|
||||
/>
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label
|
||||
htmlFor="skip-starred-metadata"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
Lightweight starred repository mirroring
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="text-xs">
|
||||
When enabled, starred repositories will only mirror code,
|
||||
skipping issues, PRs, and other metadata to reduce storage
|
||||
and improve performance.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only mirror code from starred repos, skip issues and metadata
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,24 +3,14 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Checkbox } from "../ui/checkbox";
|
||||
import { giteaApi } from "@/lib/api";
|
||||
import type { GiteaConfig, GiteaOrgVisibility, MirrorStrategy } from "@/types/config";
|
||||
import type { GiteaConfig, MirrorStrategy } from "@/types/config";
|
||||
import { toast } from "sonner";
|
||||
import { Info } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { OrganizationStrategy } from "./OrganizationStrategy";
|
||||
import { OrganizationConfiguration } from "./OrganizationConfiguration";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
interface GiteaConfigFormProps {
|
||||
@@ -133,7 +123,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full self-start">
|
||||
<Card className="w-full h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
Gitea Configuration
|
||||
@@ -148,7 +138,7 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
</Button>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-y-6">
|
||||
<CardContent className="flex flex-col gap-y-6 flex-1">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="gitea-username"
|
||||
@@ -210,13 +200,25 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
<Separator />
|
||||
|
||||
<OrganizationStrategy
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
onStrategyChange={setMirrorStrategy}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<OrganizationConfiguration
|
||||
strategy={mirrorStrategy}
|
||||
destinationOrg={config.organization}
|
||||
starredReposOrg={config.starredReposOrg}
|
||||
personalReposOrg={config.personalReposOrg}
|
||||
visibility={config.visibility}
|
||||
onDestinationOrgChange={(org) => {
|
||||
const newConfig = { ...config, organization: org };
|
||||
setConfig(newConfig);
|
||||
@@ -227,54 +229,18 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={config.username}
|
||||
onPersonalReposOrgChange={(org) => {
|
||||
const newConfig = { ...config, personalReposOrg: org };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
onVisibilityChange={(visibility) => {
|
||||
const newConfig = { ...config, visibility };
|
||||
setConfig(newConfig);
|
||||
if (onAutoSave) onAutoSave(newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="visibility"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
>
|
||||
Organization Visibility
|
||||
</label>
|
||||
<Select
|
||||
name="visibility"
|
||||
value={config.visibility}
|
||||
onValueChange={(value) =>
|
||||
handleChange({
|
||||
target: { name: "visibility", value },
|
||||
} as React.ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border border-input dark:bg-background dark:hover:bg-background">
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-background text-foreground border border-input shadow-sm">
|
||||
{(["public", "private", "limited"] as GiteaOrgVisibility[]).map(
|
||||
(option) => (
|
||||
<SelectItem
|
||||
key={option}
|
||||
value={option}
|
||||
className="cursor-pointer text-sm px-3 py-2 hover:bg-accent focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Visibility for newly created organizations
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="">
|
||||
{/* Footer content can be added here if needed */}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
193
src/components/config/OrganizationConfiguration.tsx
Normal file
193
src/components/config/OrganizationConfiguration.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Star, Globe, Lock, Shield, Info, MonitorCog } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MirrorStrategy, GiteaOrgVisibility } from "@/types/config";
|
||||
|
||||
interface OrganizationConfigurationProps {
|
||||
strategy: MirrorStrategy;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
personalReposOrg?: string;
|
||||
visibility: GiteaOrgVisibility;
|
||||
onDestinationOrgChange: (org: string) => void;
|
||||
onStarredReposOrgChange: (org: string) => void;
|
||||
onPersonalReposOrgChange: (org: string) => void;
|
||||
onVisibilityChange: (visibility: GiteaOrgVisibility) => void;
|
||||
}
|
||||
|
||||
const visibilityOptions = [
|
||||
{ value: "public" as GiteaOrgVisibility, label: "Public", icon: Globe, description: "Visible to everyone" },
|
||||
{ value: "private" as GiteaOrgVisibility, label: "Private", icon: Lock, description: "Visible to members only" },
|
||||
{ value: "limited" as GiteaOrgVisibility, label: "Limited", icon: Shield, description: "Visible to logged-in users" },
|
||||
];
|
||||
|
||||
export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps> = ({
|
||||
strategy,
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
personalReposOrg,
|
||||
visibility,
|
||||
onDestinationOrgChange,
|
||||
onStarredReposOrgChange,
|
||||
onPersonalReposOrgChange,
|
||||
onVisibilityChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<MonitorCog className="h-4 w-4" />
|
||||
Organization Configuration
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* First row - Organization inputs with consistent layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Left column - always shows starred repos org */}
|
||||
<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>
|
||||
|
||||
{/* Right column - shows destination org for single-org, personal repos org for preserve, empty div for others */}
|
||||
{strategy === "single-org" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="destinationOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
Destination Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>All repositories will be mirrored to this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="destinationOrg"
|
||||
value={destinationOrg || ""}
|
||||
onChange={(e) => onDestinationOrgChange(e.target.value)}
|
||||
placeholder="github-mirrors"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Organization for consolidated repositories
|
||||
</p>
|
||||
</div>
|
||||
) : strategy === "preserve" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="personalReposOrg" className="text-sm font-normal flex items-center gap-2">
|
||||
Personal Repos Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Override where your personal repositories are mirrored (leave empty to use your username)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="personalReposOrg"
|
||||
value={personalReposOrg || ""}
|
||||
onChange={(e) => onPersonalReposOrgChange(e.target.value)}
|
||||
placeholder="my-personal-mirrors"
|
||||
className=""
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Override destination for your personal repos
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden md:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Second row - Organization Visibility (always shown) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-normal flex items-center gap-2">
|
||||
Organization Visibility
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Default visibility for newly created organizations</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{visibilityOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = visibility === option.value;
|
||||
return (
|
||||
<TooltipProvider key={option.value}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onVisibilityChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-3 py-2 rounded-md text-sm transition-all",
|
||||
"border group",
|
||||
isSelected
|
||||
? "bg-accent border-accent-foreground/20"
|
||||
: "bg-background hover:bg-accent/50 border-input"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
<Info className="h-3 w-3 text-muted-foreground opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">{option.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Info, GitBranch, FolderTree, Package, Star, Building2, User, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Info, GitBranch, FolderTree, Star, Building2, User, Building } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
|
||||
export type MirrorStrategy = "preserve" | "single-org" | "flat-user";
|
||||
|
||||
@@ -25,226 +16,201 @@ interface OrganizationStrategyProps {
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
onStrategyChange: (strategy: MirrorStrategy) => void;
|
||||
onDestinationOrgChange: (org: string) => void;
|
||||
onStarredReposOrgChange: (org: string) => void;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}
|
||||
|
||||
const strategyConfig = {
|
||||
preserve: {
|
||||
title: "Mirror GitHub Structure",
|
||||
title: "Preserve Structure",
|
||||
icon: FolderTree,
|
||||
description: "Keep the same organization structure as GitHub",
|
||||
description: "Keep the exact same organization structure as GitHub",
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-900",
|
||||
details: [
|
||||
"Personal repos → Your Gitea username",
|
||||
"Org repos → Same org name in Gitea",
|
||||
"Team structure preserved"
|
||||
]
|
||||
repoColors: {
|
||||
bg: "bg-blue-50 dark:bg-blue-950/30",
|
||||
icon: "text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
},
|
||||
"single-org": {
|
||||
title: "Consolidate to One Org",
|
||||
title: "Single Organization",
|
||||
icon: Building2,
|
||||
description: "Mirror all repositories into a single organization",
|
||||
description: "Consolidate all repositories into one Gitea organization",
|
||||
color: "text-purple-600 dark:text-purple-400",
|
||||
bgColor: "bg-purple-50 dark:bg-purple-950/20",
|
||||
borderColor: "border-purple-200 dark:border-purple-900",
|
||||
details: [
|
||||
"All repos in one place",
|
||||
"Simplified management",
|
||||
"Custom organization name"
|
||||
]
|
||||
repoColors: {
|
||||
bg: "bg-purple-50 dark:bg-purple-950/30",
|
||||
icon: "text-purple-600 dark:text-purple-400"
|
||||
}
|
||||
},
|
||||
"flat-user": {
|
||||
title: "Flat User Structure",
|
||||
title: "User Repositories",
|
||||
icon: User,
|
||||
description: "Mirror all repositories under your user account",
|
||||
description: "Place all repositories directly under your user account",
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-50 dark:bg-green-950/20",
|
||||
borderColor: "border-green-200 dark:border-green-900",
|
||||
details: [
|
||||
"All repos under your username",
|
||||
"No organizations needed",
|
||||
"Simple and personal"
|
||||
]
|
||||
repoColors: {
|
||||
bg: "bg-green-50 dark:bg-green-950/30",
|
||||
icon: "text-green-600 dark:text-green-400"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const StrategyVisualizer: React.FC<{
|
||||
const MappingPreview: React.FC<{
|
||||
strategy: MirrorStrategy;
|
||||
config: typeof strategyConfig.preserve;
|
||||
destinationOrg?: string;
|
||||
starredReposOrg?: string;
|
||||
githubUsername?: string;
|
||||
giteaUsername?: string;
|
||||
}> = ({ strategy, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
}> = ({ strategy, config, destinationOrg, starredReposOrg, githubUsername, giteaUsername }) => {
|
||||
const displayGithubUsername = githubUsername || "<username>";
|
||||
const displayGiteaUsername = giteaUsername || "<username>";
|
||||
const isGithubPlaceholder = !githubUsername;
|
||||
const isGiteaPlaceholder = !giteaUsername;
|
||||
const renderPreserveStructure = () => (
|
||||
<div className="flex items-center justify-between gap-8 p-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">GitHub</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<User className="h-4 w-4" />
|
||||
<span className={cn("text-sm", isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm">my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Star className="h-4 w-4" />
|
||||
<span className="text-sm">awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">Gitea</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/30 rounded">
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className={cn("text-sm", isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm">my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm">{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSingleOrg = () => (
|
||||
<div className="flex items-center justify-between gap-8 p-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">GitHub</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<User className="h-4 w-4" />
|
||||
<span className={cn("text-sm", isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm">my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Star className="h-4 w-4" />
|
||||
<span className="text-sm">awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">Gitea</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-purple-50 dark:bg-purple-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm">{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-purple-50 dark:bg-purple-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm">{destinationOrg || "github-mirrors"}/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-purple-50 dark:bg-purple-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm">{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFlatUser = () => (
|
||||
<div className="flex items-center justify-between gap-8 p-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">GitHub</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<User className="h-4 w-4" />
|
||||
<span className={cn("text-sm", isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm">my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<Star className="h-4 w-4" />
|
||||
<span className="text-sm">awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-muted-foreground mb-3">Gitea</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-950/30 rounded">
|
||||
<User className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className={cn("text-sm", isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-950/30 rounded">
|
||||
<User className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className={cn("text-sm", isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-2 bg-green-50 dark:bg-green-950/30 rounded">
|
||||
<Building2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm">{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Card className="overflow-hidden">
|
||||
<CollapsibleTrigger className="w-full">
|
||||
<div className="bg-muted/50 p-3 border-b hover:bg-muted/70 transition-colors cursor-pointer">
|
||||
<h4 className="text-sm font-medium flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Repository Mapping Preview
|
||||
</span>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</h4>
|
||||
if (strategy === "preserve") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{strategy === "preserve" && renderPreserveStructure()}
|
||||
{strategy === "single-org" && renderSingleOrg()}
|
||||
{strategy === "flat-user" && renderFlatUser()}
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<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)} />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<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)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "single-org") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<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)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/my-repo</span>
|
||||
</div>
|
||||
<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)} />
|
||||
<span>{destinationOrg || "github-mirrors"}/team-repo</span>
|
||||
</div>
|
||||
<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)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (strategy === "flat-user") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">GitHub</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<User className="h-3 w-3" />
|
||||
<span className={cn(isGithubPlaceholder && "text-muted-foreground italic")}>{displayGithubUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span>my-org/team-repo</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 p-1.5 bg-gray-50 dark:bg-gray-800 rounded text-xs">
|
||||
<Star className="h-3 w-3" />
|
||||
<span>awesome/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Gitea</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/my-repo</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2 p-1.5 rounded text-xs", config.repoColors.bg)}>
|
||||
<User className={cn("h-3 w-3", config.repoColors.icon)} />
|
||||
<span className={cn(isGiteaPlaceholder && "text-muted-foreground italic")}>{displayGiteaUsername}/team-repo</span>
|
||||
</div>
|
||||
<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)} />
|
||||
<span>{starredReposOrg || "starred"}/starred-repo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
@@ -252,23 +218,23 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
destinationOrg,
|
||||
starredReposOrg,
|
||||
onStrategyChange,
|
||||
onDestinationOrgChange,
|
||||
onStarredReposOrgChange,
|
||||
githubUsername,
|
||||
giteaUsername,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Organization Strategy</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<Building className="h-4 w-4" />
|
||||
Organization Strategy
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Choose how your repositories will be organized in Gitea
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RadioGroup value={strategy} onValueChange={onStrategyChange}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid grid-cols-1 2xl:grid-cols-2 gap-4">
|
||||
{(Object.entries(strategyConfig) as [MirrorStrategy, typeof strategyConfig.preserve][]).map(([key, config]) => {
|
||||
const isSelected = strategy === key;
|
||||
const Icon = config.icon;
|
||||
@@ -283,12 +249,11 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
!isSelected && "border-muted"
|
||||
)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroupItem
|
||||
value={key}
|
||||
id={key}
|
||||
className="mt-1"
|
||||
/>
|
||||
|
||||
<div className={cn(
|
||||
@@ -296,36 +261,43 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-5 w-5",
|
||||
"h-4 w-4",
|
||||
isSelected ? config.color : "text-muted-foreground dark:text-muted-foreground/70"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{config.title}</h4>
|
||||
{isSelected && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-sm">{config.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
{config.details.map((detail, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"h-1.5 w-1.5 rounded-full",
|
||||
isSelected ? config.bgColor : "bg-muted dark:bg-muted/50"
|
||||
)} />
|
||||
<span className="text-xs text-muted-foreground">{detail}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HoverCard openDelay={200}>
|
||||
<HoverCardTrigger asChild>
|
||||
<span
|
||||
className="inline-flex p-1.5 hover:bg-muted rounded-md transition-colors cursor-help"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Info className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="left" align="center" className="w-[500px]">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Repository Mapping Preview</h4>
|
||||
<MappingPreview
|
||||
strategy={key}
|
||||
config={config}
|
||||
destinationOrg={destinationOrg}
|
||||
starredReposOrg={starredReposOrg}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -335,76 +307,6 @@ export const OrganizationStrategy: React.FC<OrganizationStrategyProps> = ({
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{strategy === "single-org" && (
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4 border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="destinationOrg" className="flex items-center gap-2">
|
||||
Destination Organization
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>All repositories will be mirrored to this organization</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Label>
|
||||
<Input
|
||||
id="destinationOrg"
|
||||
value={destinationOrg || ""}
|
||||
onChange={(e) => onDestinationOrgChange(e.target.value)}
|
||||
placeholder="github-mirrors"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="p-4 border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="starredReposOrg" className="flex items-center gap-2">
|
||||
<Star className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||
Starred Repositories 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="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground dark:text-muted-foreground/70 mt-1">
|
||||
Keep starred repos organized separately from your own repositories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<StrategyVisualizer
|
||||
strategy={strategy}
|
||||
destinationOrg={destinationOrg}
|
||||
starredReposOrg={starredReposOrg}
|
||||
githubUsername={githubUsername}
|
||||
giteaUsername={giteaUsername}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
193
src/components/organizations/MirrorDestinationEditor.tsx
Normal file
193
src/components/organizations/MirrorDestinationEditor.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { ArrowRight, Edit3, RotateCcw, CheckCircle2, XCircle, Building2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MirrorDestinationEditorProps {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
currentDestination?: string;
|
||||
onUpdate: (newDestination: string | null) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MirrorDestinationEditor({
|
||||
organizationId,
|
||||
organizationName,
|
||||
currentDestination,
|
||||
onUpdate,
|
||||
isUpdating = false,
|
||||
className,
|
||||
}: MirrorDestinationEditorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState(currentDestination || "");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasOverride = currentDestination && currentDestination !== organizationName;
|
||||
const effectiveDestination = currentDestination || organizationName;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedValue = editValue.trim();
|
||||
const newDestination = trimmedValue === "" || trimmedValue === organizationName
|
||||
? null
|
||||
: trimmedValue;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onUpdate(newDestination);
|
||||
setIsOpen(false);
|
||||
toast.success(
|
||||
newDestination
|
||||
? `Destination updated to: ${newDestination}`
|
||||
: "Destination reset to default"
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error("Failed to update destination");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
setEditValue("");
|
||||
await handleSave();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(currentDestination || "");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="font-medium">{organizationName}</span>
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
hasOverride && "text-orange-600 dark:text-orange-400"
|
||||
)}>
|
||||
{effectiveDestination}
|
||||
</span>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="h-4 px-1 text-[10px] border-orange-600 text-orange-600 dark:border-orange-400 dark:text-orange-400">
|
||||
custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 opacity-60 hover:opacity-100"
|
||||
title="Edit mirror destination"
|
||||
disabled={isUpdating || isLoading}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="end">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-sm mb-1">Mirror Destination</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customize where this organization's repositories are mirrored to in Gitea.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Visual Preview */}
|
||||
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Preview</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{organizationName}</span>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Building2 className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium text-primary">
|
||||
{editValue.trim() || organizationName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="destination" className="text-xs">
|
||||
Destination Organization
|
||||
</Label>
|
||||
<Input
|
||||
id="destination"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
placeholder={organizationName}
|
||||
className="h-8"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default GitHub organization name
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="w-full h-8 text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-2" />
|
||||
Reset to Default ({organizationName})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || (editValue.trim() === (currentDestination || ""))}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -404,6 +404,7 @@ export function Organization() {
|
||||
loadingOrgIds={loadingOrgIds}
|
||||
onMirror={handleMirrorOrg}
|
||||
onAddOrganization={() => setIsDialogOpen(true)}
|
||||
onRefresh={() => fetchOrganizations(false)}
|
||||
/>
|
||||
|
||||
<AddOrganizationDialog
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, RefreshCw, Building2 } from "lucide-react";
|
||||
import { SiGithub } from "react-icons/si";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
|
||||
import { SiGithub, SiGitea } from "react-icons/si";
|
||||
import type { Organization } from "@/lib/db/schema";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import Fuse from "fuse.js";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { getStatusColor } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MirrorDestinationEditor } from "./MirrorDestinationEditor";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
|
||||
interface OrganizationListProps {
|
||||
organizations: Organization[];
|
||||
@@ -18,8 +20,25 @@ interface OrganizationListProps {
|
||||
onMirror: ({ orgId }: { orgId: string }) => Promise<void>;
|
||||
loadingOrgIds: Set<string>;
|
||||
onAddOrganization?: () => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Helper function to get status badge variant and icon
|
||||
const getStatusBadge = (status: string | null) => {
|
||||
switch (status) {
|
||||
case "imported":
|
||||
return { variant: "secondary" as const, label: "Not Mirrored", icon: null };
|
||||
case "mirroring":
|
||||
return { variant: "outline" as const, label: "Mirroring", icon: Clock };
|
||||
case "mirrored":
|
||||
return { variant: "default" as const, label: "Mirrored", icon: Check };
|
||||
case "failed":
|
||||
return { variant: "destructive" as const, label: "Failed", icon: AlertCircle };
|
||||
default:
|
||||
return { variant: "secondary" as const, label: "Unknown", icon: null };
|
||||
}
|
||||
};
|
||||
|
||||
export function OrganizationList({
|
||||
organizations,
|
||||
isLoading,
|
||||
@@ -28,7 +47,59 @@ export function OrganizationList({
|
||||
onMirror,
|
||||
loadingOrgIds,
|
||||
onAddOrganization,
|
||||
onRefresh,
|
||||
}: OrganizationListProps) {
|
||||
const { giteaConfig } = useGiteaConfig();
|
||||
|
||||
// Helper function to construct Gitea organization URL
|
||||
const getGiteaOrgUrl = (organization: Organization): string | null => {
|
||||
if (!giteaConfig?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only provide Gitea links for organizations that have been mirrored
|
||||
const validStatuses = ['mirroring', 'mirrored'];
|
||||
if (!validStatuses.includes(organization.status || '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use destinationOrg if available, otherwise use the organization name
|
||||
const orgName = organization.destinationOrg || organization.name;
|
||||
if (!orgName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure the base URL doesn't have a trailing slash
|
||||
const baseUrl = giteaConfig.url.endsWith('/')
|
||||
? giteaConfig.url.slice(0, -1)
|
||||
: giteaConfig.url;
|
||||
|
||||
return `${baseUrl}/${orgName}`;
|
||||
};
|
||||
|
||||
const handleUpdateDestination = async (orgId: string, newDestination: string | null) => {
|
||||
// Call API to update organization destination
|
||||
const response = await fetch(`/api/organizations/${orgId}`, {
|
||||
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 organization");
|
||||
}
|
||||
|
||||
// Refresh organizations data
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyFilter = Object.values(filter).some(
|
||||
(val) => val?.toString().trim() !== ""
|
||||
);
|
||||
@@ -93,29 +164,56 @@ export function OrganizationList({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredOrganizations.map((org, index) => {
|
||||
const isLoading = loadingOrgIds.has(org.id ?? "");
|
||||
const statusBadge = getStatusBadge(org.status);
|
||||
const StatusIcon = statusBadge.icon;
|
||||
|
||||
return (
|
||||
<Card key={index} className="overflow-hidden p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<Card
|
||||
key={index}
|
||||
className={cn(
|
||||
"overflow-hidden p-4 transition-all hover:shadow-md min-h-[160px]",
|
||||
isLoading && "opacity-75"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
<a
|
||||
href={`/repositories?organization=${encodeURIComponent(org.name || '')}`}
|
||||
className="font-medium hover:underline cursor-pointer"
|
||||
>
|
||||
{org.name}
|
||||
</a>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Destination override section */}
|
||||
<div className="mt-2">
|
||||
<MirrorDestinationEditor
|
||||
organizationId={org.id!}
|
||||
organizationName={org.name!}
|
||||
currentDestination={org.destinationOrg}
|
||||
onUpdate={(newDestination) => handleUpdateDestination(org.id!, newDestination)}
|
||||
isUpdating={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full capitalize ${
|
||||
org.membershipRole === "member"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-purple-100 text-purple-800"
|
||||
}`}
|
||||
>
|
||||
{org.membershipRole}
|
||||
{/* needs to be updated */}
|
||||
</span>
|
||||
<Badge variant={statusBadge.variant} className="ml-2">
|
||||
{StatusIcon && <StatusIcon className={cn(
|
||||
"h-3 w-3",
|
||||
org.status === "mirroring" && "animate-pulse"
|
||||
)} />}
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
@@ -125,78 +223,147 @@ export function OrganizationList({
|
||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
||||
</span>
|
||||
</div>
|
||||
{(org.publicRepositoryCount !== undefined ||
|
||||
org.privateRepositoryCount !== undefined ||
|
||||
org.forkRepositoryCount !== undefined) && (
|
||||
<div className="flex gap-4 mt-2 text-xs">
|
||||
{org.publicRepositoryCount !== undefined && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{org.publicRepositoryCount} public
|
||||
</span>
|
||||
)}
|
||||
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||
{org.privateRepositoryCount} private
|
||||
</span>
|
||||
)}
|
||||
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Always render this section to prevent layout shift */}
|
||||
<div className="flex gap-4 mt-2 text-xs min-h-[20px]">
|
||||
{isLoading || (org.status === "mirroring" && org.publicRepositoryCount === undefined) ? (
|
||||
<>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{org.publicRepositoryCount !== undefined ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{org.publicRepositoryCount} public
|
||||
</span>
|
||||
) : null}
|
||||
{org.privateRepositoryCount !== undefined && org.privateRepositoryCount > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-orange-500" />
|
||||
{org.privateRepositoryCount} private
|
||||
</span>
|
||||
) : null}
|
||||
{org.forkRepositoryCount !== undefined && org.forkRepositoryCount > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
{org.forkRepositoryCount} fork{org.forkRepositoryCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
) : null}
|
||||
{/* Show a placeholder if no counts are available to maintain height */}
|
||||
{org.publicRepositoryCount === undefined &&
|
||||
org.privateRepositoryCount === undefined &&
|
||||
org.forkRepositoryCount === undefined && (
|
||||
<span className="invisible">Loading counts...</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id={`include-${org.id}`}
|
||||
name={`include-${org.id}`}
|
||||
checked={org.status === "mirrored"}
|
||||
disabled={
|
||||
loadingOrgIds.has(org.id ?? "") ||
|
||||
org.status === "mirrored" ||
|
||||
org.status === "mirroring"
|
||||
}
|
||||
onCheckedChange={async (checked) => {
|
||||
if (checked && !org.isIncluded && org.id) {
|
||||
onMirror({ orgId: org.id });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`include-${org.id}`}
|
||||
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Enable mirroring
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{org.status === "imported" && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Mirror"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
|
||||
{org.status === "mirroring" && (
|
||||
<Button size="sm" disabled variant="outline">
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Mirroring...
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "mirrored" && (
|
||||
<Button size="sm" disabled variant="secondary">
|
||||
<Check className="h-3 w-3 mr-2" />
|
||||
Mirrored
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{org.status === "failed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => org.id && onMirror({ orgId: org.id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin mr-2" />
|
||||
Retrying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 mr-2" />
|
||||
Retry
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const giteaUrl = getGiteaOrgUrl(org);
|
||||
|
||||
{/* dont know if this looks good. maybe revised */}
|
||||
<div className="flex items-center gap-2 justify-end mt-4">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(org.status)}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{org.status}</span>
|
||||
// Determine tooltip based on status and configuration
|
||||
let tooltip: string;
|
||||
if (!giteaConfig?.url) {
|
||||
tooltip = "Gitea not configured";
|
||||
} else if (org.status === 'imported') {
|
||||
tooltip = "Organization not yet mirrored to Gitea";
|
||||
} else if (org.status === 'failed') {
|
||||
tooltip = "Organization mirroring failed";
|
||||
} else if (org.status === 'mirroring') {
|
||||
tooltip = "Organization is being mirrored to Gitea";
|
||||
} else if (giteaUrl) {
|
||||
tooltip = "View on Gitea";
|
||||
} else {
|
||||
tooltip = "Gitea organization not available";
|
||||
}
|
||||
|
||||
return giteaUrl ? (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={giteaUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={tooltip}
|
||||
>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="icon" disabled title={tooltip}>
|
||||
<SiGitea className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a
|
||||
href={`https://github.com/${org.name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<SiGithub className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
||||
import { Search, RefreshCw, FlipHorizontal, RotateCcw, X } from "lucide-react";
|
||||
import type { MirrorRepoRequest, MirrorRepoResponse } from "@/types/mirror";
|
||||
import { useSSE } from "@/hooks/useSEE";
|
||||
import { useFilterParams } from "@/hooks/useFilterParams";
|
||||
@@ -46,6 +46,7 @@ export default function Repository() {
|
||||
owner: "",
|
||||
});
|
||||
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
|
||||
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Read organization filter from URL when component mounts
|
||||
useEffect(() => {
|
||||
@@ -254,6 +255,143 @@ export default function Repository() {
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action handlers
|
||||
const handleBulkMirror = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "imported" || repo.status === "failed"
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No eligible repositories to mirror in selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<MirrorRepoResponse>("/job/mirror-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Mirroring started for ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkSync = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(
|
||||
repo => repo.status === "mirrored" || repo.status === "synced"
|
||||
);
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No eligible repositories to sync in selection");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<SyncRepoResponse>("/job/sync-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Syncing started for ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error starting sync jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkRetry = async () => {
|
||||
if (selectedRepoIds.size === 0) return;
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const eligibleRepos = selectedRepos.filter(repo => repo.status === "failed");
|
||||
|
||||
if (eligibleRepos.length === 0) {
|
||||
toast.info("No failed repositories in selection to retry");
|
||||
return;
|
||||
}
|
||||
|
||||
const repoIds = eligibleRepos.map(repo => repo.id as string);
|
||||
|
||||
setLoadingRepoIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
repoIds.forEach(id => newSet.add(id));
|
||||
return newSet;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiRequest<RetryRepoResponse>("/job/retry-repo", {
|
||||
method: "POST",
|
||||
data: { userId: user?.id, repositoryIds: repoIds }
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success(`Retrying ${repoIds.length} repositories`);
|
||||
setRepositories(prevRepos =>
|
||||
prevRepos.map(repo => {
|
||||
const updated = response.repositories.find(r => r.id === repo.id);
|
||||
return updated ? updated : repo;
|
||||
})
|
||||
);
|
||||
setSelectedRepoIds(new Set());
|
||||
} else {
|
||||
showErrorToast(response.error || "Error retrying jobs", toast);
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
} finally {
|
||||
setLoadingRepoIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncRepo = async ({ repoId }: { repoId: string }) => {
|
||||
try {
|
||||
if (!user || !user.id) {
|
||||
@@ -392,6 +530,35 @@ export default function Repository() {
|
||||
)
|
||||
).sort();
|
||||
|
||||
// Determine what actions are available for selected repositories
|
||||
const getAvailableActions = () => {
|
||||
if (selectedRepoIds.size === 0) return [];
|
||||
|
||||
const selectedRepos = repositories.filter(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const statuses = new Set(selectedRepos.map(repo => repo.status));
|
||||
|
||||
const actions = [];
|
||||
|
||||
// Check if any selected repos can be mirrored
|
||||
if (selectedRepos.some(repo => repo.status === "imported" || repo.status === "failed")) {
|
||||
actions.push('mirror');
|
||||
}
|
||||
|
||||
// Check if any selected repos can be synced
|
||||
if (selectedRepos.some(repo => repo.status === "mirrored" || repo.status === "synced")) {
|
||||
actions.push('sync');
|
||||
}
|
||||
|
||||
// Check if any selected repos are failed
|
||||
if (selectedRepos.some(repo => repo.status === "failed")) {
|
||||
actions.push('retry');
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
|
||||
const availableActions = getAvailableActions();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-8">
|
||||
{/* Combine search and actions into a single flex row */}
|
||||
@@ -459,14 +626,69 @@ export default function Repository() {
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
{/* Context-aware action buttons */}
|
||||
{selectedRepoIds.size === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleMirrorAllRepos}
|
||||
disabled={isInitialLoading || loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror All
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-muted/50 rounded-md">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedRepoIds.size} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setSelectedRepoIds(new Set())}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{availableActions.includes('mirror') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleBulkMirror}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4 mr-2" />
|
||||
Mirror ({selectedRepoIds.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('sync') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkSync}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync ({selectedRepoIds.size})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{availableActions.includes('retry') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBulkRetry}
|
||||
disabled={loadingRepoIds.size > 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isGitHubConfigured ? (
|
||||
@@ -497,6 +719,9 @@ export default function Repository() {
|
||||
onSync={handleSyncRepo}
|
||||
onRetry={handleRetryRepoAction}
|
||||
loadingRepoIds={loadingRepoIds}
|
||||
selectedRepoIds={selectedRepoIds}
|
||||
onSelectionChange={setSelectedRepoIds}
|
||||
onRefresh={() => fetchRepositories(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -9,6 +9,14 @@ import { formatDate, getStatusColor } from "@/lib/utils";
|
||||
import type { FilterParams } from "@/types/filter";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGiteaConfig } from "@/hooks/useGiteaConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { InlineDestinationEditor } from "./InlineDestinationEditor";
|
||||
|
||||
interface RepositoryTableProps {
|
||||
repositories: Repository[];
|
||||
@@ -20,6 +28,9 @@ interface RepositoryTableProps {
|
||||
onSync: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
onRetry: ({ repoId }: { repoId: string }) => Promise<void>;
|
||||
loadingRepoIds: Set<string>;
|
||||
selectedRepoIds: Set<string>;
|
||||
onSelectionChange: (selectedIds: Set<string>) => void;
|
||||
onRefresh?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function RepositoryTable({
|
||||
@@ -32,10 +43,36 @@ export default function RepositoryTable({
|
||||
onSync,
|
||||
onRetry,
|
||||
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) {
|
||||
@@ -105,9 +142,36 @@ export default function RepositoryTable({
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Selection handlers
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allIds = new Set(filteredRepositories.map(repo => repo.id).filter((id): id is string => !!id));
|
||||
onSelectionChange(allIds);
|
||||
} else {
|
||||
onSelectionChange(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRepo = (repoId: string, checked: boolean) => {
|
||||
const newSelection = new Set(selectedRepoIds);
|
||||
if (checked) {
|
||||
newSelection.add(repoId);
|
||||
} else {
|
||||
newSelection.delete(repoId);
|
||||
}
|
||||
onSelectionChange(newSelection);
|
||||
};
|
||||
|
||||
const isAllSelected = filteredRepositories.length > 0 &&
|
||||
filteredRepositories.every(repo => repo.id && selectedRepoIds.has(repo.id));
|
||||
const isPartiallySelected = selectedRepoIds.size > 0 && !isAllSelected;
|
||||
|
||||
return isLoading ? (
|
||||
<div className="border rounded-md">
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
Repository
|
||||
</div>
|
||||
@@ -132,6 +196,9 @@ export default function RepositoryTable({
|
||||
key={i}
|
||||
className="h-[65px] flex items-center justify-between border-b bg-transparent"
|
||||
>
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
<Skeleton className="h-full w-full" />
|
||||
</div>
|
||||
@@ -187,6 +254,14 @@ export default function RepositoryTable({
|
||||
<div className="flex flex-col border rounded-md">
|
||||
{/* table header */}
|
||||
<div className="h-[45px] flex items-center justify-between border-b bg-muted/50">
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
aria-label="Select all repositories"
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full p-3 text-sm font-medium flex-[2.5]">
|
||||
Repository
|
||||
</div>
|
||||
@@ -235,11 +310,25 @@ export default function RepositoryTable({
|
||||
data-index={virtualRow.index}
|
||||
className="h-[65px] flex items-center justify-between bg-transparent border-b hover:bg-muted/50" //the height is set according to the row content. right now the highest row is in the repo column which is arround 64.99px
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<div className="h-full p-3 flex items-center justify-center flex-[0.3]">
|
||||
<Checkbox
|
||||
checked={repo.id ? selectedRepoIds.has(repo.id) : false}
|
||||
onCheckedChange={(checked) => repo.id && handleSelectRepo(repo.id, !!checked)}
|
||||
aria-label={`Select ${repo.name}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
@@ -263,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 */}
|
||||
@@ -277,12 +371,26 @@ export default function RepositoryTable({
|
||||
|
||||
{/* Status */}
|
||||
<div className="h-full p-3 flex items-center gap-x-2 flex-[1]">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(
|
||||
repo.status
|
||||
)}`}
|
||||
/>
|
||||
<span className="text-sm capitalize">{repo.status}</span>
|
||||
{repo.status === "failed" && repo.errorMessage ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-x-2 cursor-help">
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize underline decoration-dotted">{repo.status}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p className="text-sm">{repo.errorMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<>
|
||||
<div className={`h-2 w-2 rounded-full ${getStatusColor(repo.status)}`} />
|
||||
<span className="text-sm capitalize">{repo.status}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
42
src/components/ui/hover-card.tsx
Normal file
42
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -19,7 +19,11 @@ function TooltipProvider({
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
@@ -40,7 +44,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
// Define a schema for the documentation collection
|
||||
const docsCollection = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
order: z.number().optional(),
|
||||
updatedDate: z.date().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Export the collections
|
||||
export const collections = {
|
||||
'docs': docsCollection,
|
||||
};
|
||||
// Export empty collections since docs have been moved
|
||||
export const collections = {};
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { mirrorApi } from '@/lib/api';
|
||||
import type { MirrorJob } from '@/lib/db/schema';
|
||||
|
||||
export function useMirror() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentJob, setCurrentJob] = useState<MirrorJob | null>(null);
|
||||
const [jobs, setJobs] = useState<MirrorJob[]>([]);
|
||||
|
||||
const startMirror = async (configId: string, repositoryIds?: string[]) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await mirrorApi.startMirror(configId, repositoryIds);
|
||||
setCurrentJob(job);
|
||||
return job;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start mirroring');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMirrorJobs = async (configId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const fetchedJobs = await mirrorApi.getMirrorJobs(configId);
|
||||
setJobs(fetchedJobs);
|
||||
return fetchedJobs;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch mirror jobs');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getMirrorJob = async (jobId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await mirrorApi.getMirrorJob(jobId);
|
||||
setCurrentJob(job);
|
||||
return job;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch mirror job');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelMirrorJob = async (jobId: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await mirrorApi.cancelMirrorJob(jobId);
|
||||
if (result.success && currentJob?.id === jobId) {
|
||||
setCurrentJob({ ...currentJob, status: 'failed' });
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to cancel mirror job');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
currentJob,
|
||||
jobs,
|
||||
startMirror,
|
||||
getMirrorJobs,
|
||||
getMirrorJob,
|
||||
cancelMirrorJob,
|
||||
};
|
||||
}
|
||||
@@ -28,11 +28,44 @@ try {
|
||||
|
||||
// Ensure all required tables exist
|
||||
ensureTablesExist(sqlite);
|
||||
|
||||
// Run migrations
|
||||
runMigrations(sqlite);
|
||||
} catch (error) {
|
||||
console.error("Error opening database:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
*/
|
||||
function runMigrations(db: Database) {
|
||||
try {
|
||||
// Migration 1: Add destination_org column to organizations table
|
||||
const orgTableInfo = db.query("PRAGMA table_info(organizations)").all() as Array<{name: string}>;
|
||||
const hasDestinationOrg = orgTableInfo.some(col => col.name === 'destination_org');
|
||||
|
||||
if (!hasDestinationOrg) {
|
||||
console.log("🔄 Running migration: Adding destination_org column to organizations table");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure all required tables exist in the database
|
||||
*/
|
||||
@@ -159,6 +192,7 @@ function createTable(db: Database, tableName: string) {
|
||||
last_mirrored INTEGER,
|
||||
error_message TEXT,
|
||||
repository_count INTEGER NOT NULL DEFAULT 0,
|
||||
destination_org TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
@@ -437,6 +471,9 @@ export const organizations = sqliteTable("organizations", {
|
||||
.notNull()
|
||||
.default(true),
|
||||
|
||||
// Override destination organization for this GitHub org's repos
|
||||
destinationOrg: text("destination_org"),
|
||||
|
||||
status: text("status").notNull().default("imported"),
|
||||
lastMirrored: integer("last_mirrored", { mode: "timestamp" }),
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
@@ -45,6 +45,7 @@ export const configSchema = z.object({
|
||||
starredReposOrg: z.string().default("github"),
|
||||
preserveOrgStructure: z.boolean().default(false),
|
||||
mirrorStrategy: z.enum(["preserve", "single-org", "flat-user"]).optional(),
|
||||
personalReposOrg: z.string().optional(), // Override destination for personal repos
|
||||
}),
|
||||
include: z.array(z.string()).default(["*"]),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
@@ -100,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()),
|
||||
@@ -158,6 +160,9 @@ export const organizationSchema = z.object({
|
||||
privateRepositoryCount: z.number().optional(),
|
||||
forkRepositoryCount: z.number().optional(),
|
||||
|
||||
// Override destination organization for this GitHub org's repos
|
||||
destinationOrg: z.string().optional(),
|
||||
|
||||
createdAt: z.date().default(() => new Date()),
|
||||
updatedAt: z.date().default(() => new Date()),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, test, expect, mock, beforeEach, afterEach } from "bun:test";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { repoStatusEnum } from "@/types/Repository";
|
||||
import { getOrCreateGiteaOrg } from "./gitea";
|
||||
import { getOrCreateGiteaOrg, getGiteaRepoOwner, getGiteaRepoOwnerAsync } from "./gitea";
|
||||
import type { Config, Repository, Organization } from "./db/schema";
|
||||
|
||||
// Mock the isRepoPresentInGitea function
|
||||
const mockIsRepoPresentInGitea = mock(() => Promise.resolve(false));
|
||||
@@ -291,3 +292,90 @@ describe("Gitea Repository Mirroring", () => {
|
||||
expect(mockGetOrCreateGiteaOrg).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGiteaRepoOwner - Organization Override Tests", () => {
|
||||
const baseConfig: Partial<Config> = {
|
||||
githubConfig: {
|
||||
username: "testuser",
|
||||
token: "token",
|
||||
preserveOrgStructure: false,
|
||||
skipForks: false,
|
||||
privateRepositories: false,
|
||||
mirrorIssues: false,
|
||||
mirrorWiki: false,
|
||||
mirrorStarred: false,
|
||||
useSpecificUser: false,
|
||||
includeOrgs: [],
|
||||
excludeOrgs: [],
|
||||
mirrorPublicOrgs: false,
|
||||
publicOrgs: [],
|
||||
skipStarredIssues: false
|
||||
},
|
||||
giteaConfig: {
|
||||
username: "giteauser",
|
||||
url: "https://gitea.example.com",
|
||||
token: "gitea-token",
|
||||
organization: "github-mirrors",
|
||||
visibility: "public",
|
||||
starredReposOrg: "starred",
|
||||
preserveOrgStructure: false,
|
||||
mirrorStrategy: "preserve"
|
||||
}
|
||||
};
|
||||
|
||||
const baseRepo: Repository = {
|
||||
id: "repo-id",
|
||||
userId: "user-id",
|
||||
configId: "config-id",
|
||||
name: "test-repo",
|
||||
fullName: "testuser/test-repo",
|
||||
url: "https://github.com/testuser/test-repo",
|
||||
cloneUrl: "https://github.com/testuser/test-repo.git",
|
||||
owner: "testuser",
|
||||
isPrivate: false,
|
||||
isForked: false,
|
||||
hasIssues: true,
|
||||
isStarred: false,
|
||||
isArchived: false,
|
||||
size: 1000,
|
||||
hasLFS: false,
|
||||
hasSubmodules: false,
|
||||
defaultBranch: "main",
|
||||
visibility: "public",
|
||||
status: "imported",
|
||||
mirroredLocation: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
test("starred repos go to starredReposOrg", () => {
|
||||
const repo = { ...baseRepo, isStarred: true };
|
||||
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
|
||||
expect(result).toBe("starred");
|
||||
});
|
||||
|
||||
test("preserve strategy: personal repos use personalReposOrg override", () => {
|
||||
const configWithOverride = {
|
||||
...baseConfig,
|
||||
giteaConfig: {
|
||||
...baseConfig.giteaConfig!,
|
||||
personalReposOrg: "my-personal-mirrors"
|
||||
}
|
||||
};
|
||||
const repo = { ...baseRepo, organization: undefined };
|
||||
const result = getGiteaRepoOwner({ config: configWithOverride, repository: repo });
|
||||
expect(result).toBe("my-personal-mirrors");
|
||||
});
|
||||
|
||||
test("preserve strategy: personal repos fallback to username when no override", () => {
|
||||
const repo = { ...baseRepo, organization: undefined };
|
||||
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
|
||||
expect(result).toBe("giteauser");
|
||||
});
|
||||
|
||||
test("preserve strategy: org repos go to same org name", () => {
|
||||
const repo = { ...baseRepo, organization: "myorg" };
|
||||
const result = getGiteaRepoOwner({ config: baseConfig, repository: repo });
|
||||
expect(result).toBe("myorg");
|
||||
});
|
||||
});
|
||||
|
||||
195
src/lib/gitea.ts
195
src/lib/gitea.ts
@@ -9,7 +9,87 @@ import type { Organization, Repository } from "./db/schema";
|
||||
import { httpPost, httpGet } from "./http-client";
|
||||
import { createMirrorJob } from "./helpers";
|
||||
import { db, organizations, repositories } from "./db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Helper function to get organization configuration including destination override
|
||||
*/
|
||||
export const getOrganizationConfig = async ({
|
||||
orgName,
|
||||
userId,
|
||||
}: {
|
||||
orgName: string;
|
||||
userId: string;
|
||||
}): Promise<Organization | null> => {
|
||||
try {
|
||||
const [orgConfig] = await db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.where(and(eq(organizations.name, orgName), eq(organizations.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
return orgConfig || null;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching organization config for ${orgName}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced async version of getGiteaRepoOwner that supports organization overrides
|
||||
*/
|
||||
export const getGiteaRepoOwnerAsync = async ({
|
||||
config,
|
||||
repository,
|
||||
}: {
|
||||
config: Partial<Config>;
|
||||
repository: Repository;
|
||||
}): Promise<string> => {
|
||||
if (!config.githubConfig || !config.giteaConfig) {
|
||||
throw new Error("GitHub or Gitea config is required.");
|
||||
}
|
||||
|
||||
if (!config.giteaConfig.username) {
|
||||
throw new Error("Gitea username is required.");
|
||||
}
|
||||
|
||||
if (!config.userId) {
|
||||
throw new Error("User ID is required for organization overrides.");
|
||||
}
|
||||
|
||||
// Check if repository is starred - starred repos always go to starredReposOrg (highest priority)
|
||||
if (repository.isStarred && 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
|
||||
if (repository.organization) {
|
||||
const orgConfig = await getOrganizationConfig({
|
||||
orgName: repository.organization,
|
||||
userId: config.userId,
|
||||
});
|
||||
|
||||
if (orgConfig?.destinationOrg) {
|
||||
console.log(`Using organization override: ${repository.organization} -> ${orgConfig.destinationOrg}`);
|
||||
return orgConfig.destinationOrg;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for personal repos override (when it's user's repo, not an organization)
|
||||
if (!repository.organization && config.giteaConfig.personalReposOrg) {
|
||||
console.log(`Using personal repos override: ${config.giteaConfig.personalReposOrg}`);
|
||||
return config.giteaConfig.personalReposOrg;
|
||||
}
|
||||
|
||||
// Fall back to existing strategy logic
|
||||
return getGiteaRepoOwner({ config, repository });
|
||||
};
|
||||
|
||||
export const getGiteaRepoOwner = ({
|
||||
config,
|
||||
@@ -37,11 +117,12 @@ export const getGiteaRepoOwner = ({
|
||||
|
||||
switch (mirrorStrategy) {
|
||||
case "preserve":
|
||||
// Keep GitHub structure - org repos go to same org, personal repos to user
|
||||
// Keep GitHub structure - org repos go to same org, personal repos to user (or override)
|
||||
if (repository.organization) {
|
||||
return repository.organization;
|
||||
}
|
||||
return config.giteaConfig.username;
|
||||
// Use personal repos override if configured, otherwise use username
|
||||
return config.giteaConfig.personalReposOrg || config.giteaConfig.username;
|
||||
|
||||
case "single-org":
|
||||
// All non-starred repos go to the destination organization
|
||||
@@ -160,15 +241,18 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
throw new Error("Gitea username is required.");
|
||||
}
|
||||
|
||||
// Get the correct owner based on the strategy (with organization overrides)
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
|
||||
|
||||
const isExisting = await isRepoPresentInGitea({
|
||||
config,
|
||||
owner: config.giteaConfig.username,
|
||||
owner: repoOwner,
|
||||
repoName: repository.name,
|
||||
});
|
||||
|
||||
if (isExisting) {
|
||||
console.log(
|
||||
`Repository ${repository.name} already exists in Gitea. Updating database status.`
|
||||
`Repository ${repository.name} already exists in Gitea under ${repoOwner}. Updating database status.`
|
||||
);
|
||||
|
||||
// Update database to reflect that the repository is already mirrored
|
||||
@@ -179,7 +263,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
|
||||
mirroredLocation: `${repoOwner}/${repository.name}`,
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
@@ -189,7 +273,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
message: `Repository ${repository.name} already exists in Gitea`,
|
||||
details: `Repository ${repository.name} was found to already exist in Gitea and database status was updated.`,
|
||||
details: `Repository ${repository.name} was found to already exist in Gitea under ${repoOwner} and database status was updated.`,
|
||||
status: "mirrored",
|
||||
});
|
||||
|
||||
@@ -238,6 +322,15 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
|
||||
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`;
|
||||
|
||||
// Handle organization creation if needed for single-org or preserve strategies
|
||||
if (repoOwner !== config.giteaConfig.username && !repository.isStarred) {
|
||||
// Need to create the organization if it doesn't exist
|
||||
await getOrCreateGiteaOrg({
|
||||
orgName: repoOwner,
|
||||
config,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await httpPost(
|
||||
apiUrl,
|
||||
{
|
||||
@@ -246,7 +339,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
mirror: true,
|
||||
wiki: config.githubConfig.mirrorWiki || false, // will mirror wiki if it exists
|
||||
private: repository.isPrivate,
|
||||
repo_owner: config.giteaConfig.username,
|
||||
repo_owner: repoOwner,
|
||||
description: "",
|
||||
service: "git",
|
||||
},
|
||||
@@ -263,7 +356,11 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
});
|
||||
|
||||
// clone issues
|
||||
if (config.githubConfig.mirrorIssues) {
|
||||
// Skip issues for starred repos if skipStarredIssues is enabled
|
||||
const shouldMirrorIssues = config.githubConfig.mirrorIssues &&
|
||||
!(repository.isStarred && config.githubConfig.skipStarredIssues);
|
||||
|
||||
if (shouldMirrorIssues) {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
@@ -282,7 +379,7 @@ export const mirrorGithubRepoToGitea = async ({
|
||||
updatedAt: new Date(),
|
||||
lastMirrored: new Date(),
|
||||
errorMessage: null,
|
||||
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`,
|
||||
mirroredLocation: `${repoOwner}/${repository.name}`,
|
||||
})
|
||||
.where(eq(repositories.id, repository.id!));
|
||||
|
||||
@@ -416,7 +513,7 @@ export async function getOrCreateGiteaOrg({
|
||||
username: orgName,
|
||||
full_name: `${orgName} Org`,
|
||||
description: `Mirrored organization from GitHub ${orgName}`,
|
||||
visibility: "public",
|
||||
visibility: config.giteaConfig?.visibility || "public",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -608,7 +705,11 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
||||
});
|
||||
|
||||
// Clone issues
|
||||
if (config.githubConfig?.mirrorIssues) {
|
||||
// Skip issues for starred repos if skipStarredIssues is enabled
|
||||
const shouldMirrorIssues = config.githubConfig?.mirrorIssues &&
|
||||
!(repository.isStarred && config.githubConfig?.skipStarredIssues);
|
||||
|
||||
if (shouldMirrorIssues) {
|
||||
await mirrorGitRepoIssuesToGitea({
|
||||
config,
|
||||
octokit,
|
||||
@@ -755,11 +856,37 @@ export async function mirrorGitHubOrgToGitea({
|
||||
status: repoStatusEnum.parse("mirroring"),
|
||||
});
|
||||
|
||||
const giteaOrgId = await getOrCreateGiteaOrg({
|
||||
orgId: organization.id,
|
||||
orgName: organization.name,
|
||||
config,
|
||||
});
|
||||
// Get the mirror strategy - use preserveOrgStructure for backward compatibility
|
||||
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
|
||||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
||||
|
||||
let giteaOrgId: number;
|
||||
let targetOrgName: string;
|
||||
|
||||
// Determine the target organization based on strategy
|
||||
if (mirrorStrategy === "single-org" && config.giteaConfig?.organization) {
|
||||
// For single-org strategy, use the configured destination organization
|
||||
targetOrgName = config.giteaConfig.organization;
|
||||
giteaOrgId = await getOrCreateGiteaOrg({
|
||||
orgId: organization.id,
|
||||
orgName: targetOrgName,
|
||||
config,
|
||||
});
|
||||
console.log(`Using single organization strategy: all repos will go to ${targetOrgName}`);
|
||||
} else if (mirrorStrategy === "preserve") {
|
||||
// For preserve strategy, create/use an org with the same name as GitHub
|
||||
targetOrgName = organization.name;
|
||||
giteaOrgId = await getOrCreateGiteaOrg({
|
||||
orgId: organization.id,
|
||||
orgName: targetOrgName,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
// For flat-user strategy, we shouldn't create organizations at all
|
||||
// Skip organization creation and let individual repos be handled by getGiteaRepoOwner
|
||||
console.log(`Using flat-user strategy: repos will be placed under user account`);
|
||||
targetOrgName = config.giteaConfig?.username || "";
|
||||
}
|
||||
|
||||
//query the db with the org name and get the repos
|
||||
const orgRepos = await db
|
||||
@@ -797,17 +924,27 @@ export async function mirrorGitHubOrgToGitea({
|
||||
|
||||
// Log the start of mirroring
|
||||
console.log(
|
||||
`Starting mirror for repository: ${repo.name} in organization ${organization.name}`
|
||||
`Starting mirror for repository: ${repo.name} from GitHub org ${organization.name}`
|
||||
);
|
||||
|
||||
// Mirror the repository
|
||||
await mirrorGitHubRepoToGiteaOrg({
|
||||
octokit,
|
||||
config,
|
||||
repository: repoData,
|
||||
giteaOrgId,
|
||||
orgName: organization.name,
|
||||
});
|
||||
// Mirror the repository based on strategy
|
||||
if (mirrorStrategy === "flat-user") {
|
||||
// For flat-user strategy, mirror directly to user account
|
||||
await mirrorGithubRepoToGitea({
|
||||
octokit,
|
||||
repository: repoData,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
// For preserve and single-org strategies, use organization
|
||||
await mirrorGitHubRepoToGiteaOrg({
|
||||
octokit,
|
||||
config,
|
||||
repository: repoData,
|
||||
giteaOrgId: giteaOrgId!,
|
||||
orgName: targetOrgName,
|
||||
});
|
||||
}
|
||||
|
||||
return repo;
|
||||
},
|
||||
@@ -931,8 +1068,8 @@ export const syncGiteaRepo = async ({
|
||||
status: repoStatusEnum.parse("syncing"),
|
||||
});
|
||||
|
||||
// Get the expected owner based on current config
|
||||
const repoOwner = getGiteaRepoOwner({ config, repository });
|
||||
// Get the expected owner based on current config (with organization overrides)
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({ config, repository });
|
||||
|
||||
// Check if repo exists at the expected location or alternate location
|
||||
const { present, actualOwner } = await checkRepoLocation({
|
||||
@@ -1233,7 +1370,7 @@ export async function mirrorGitHubReleasesToGitea({
|
||||
throw new Error("Gitea config is incomplete for mirroring releases.");
|
||||
}
|
||||
|
||||
const repoOwner = getGiteaRepoOwner({
|
||||
const repoOwner = await getGiteaRepoOwnerAsync({
|
||||
config,
|
||||
repository,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ interface DbGiteaConfig {
|
||||
starredReposOrg: string;
|
||||
preserveOrgStructure: boolean;
|
||||
mirrorStrategy?: "preserve" | "single-org" | "flat-user";
|
||||
personalReposOrg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +107,7 @@ export function mapDbToUiConfig(dbConfig: any): {
|
||||
starredReposOrg: dbConfig.giteaConfig?.starredReposOrg || "github",
|
||||
preserveOrgStructure: dbConfig.giteaConfig?.preserveOrgStructure || false,
|
||||
mirrorStrategy: dbConfig.giteaConfig?.mirrorStrategy,
|
||||
personalReposOrg: dbConfig.giteaConfig?.personalReposOrg,
|
||||
};
|
||||
|
||||
const mirrorOptions: MirrorOptions = {
|
||||
|
||||
37
src/pages/404.astro
Normal file
37
src/pages/404.astro
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
import ThemeScript from '@/components/theme/ThemeScript.astro';
|
||||
import { NotFound } from '@/components/NotFound';
|
||||
|
||||
const generator = Astro.generator;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={generator} />
|
||||
<title>Page Not Found - Gitea Mirror</title>
|
||||
<ThemeScript />
|
||||
</head>
|
||||
<body>
|
||||
<NotFound client:load />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
/* Floating animation for 404 text */
|
||||
:global(.animate-float) {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -58,7 +58,7 @@ export const GET: APIRoute = async () => {
|
||||
latestVersion: latestVersion,
|
||||
updateAvailable: latestVersion !== "unknown" &&
|
||||
currentVersion !== "unknown" &&
|
||||
latestVersion !== currentVersion,
|
||||
compareVersions(currentVersion, latestVersion) < 0,
|
||||
database: dbStatus,
|
||||
recovery: recoveryStatus,
|
||||
system: systemInfo,
|
||||
@@ -174,6 +174,28 @@ function formatBytes(bytes: number): string {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic versions
|
||||
* Returns:
|
||||
* -1 if v1 < v2
|
||||
* 0 if v1 = v2
|
||||
* 1 if v1 > v2
|
||||
*/
|
||||
function compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split('.').map(Number);
|
||||
const parts2 = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 < part2) return -1;
|
||||
if (part1 > part2) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for the latest version from GitHub releases
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ import { repositoryVisibilityEnum, repoStatusEnum } from "@/types/Repository";
|
||||
import {
|
||||
mirrorGithubRepoToGitea,
|
||||
mirrorGitHubOrgRepoToGiteaOrg,
|
||||
getGiteaRepoOwner,
|
||||
getGiteaRepoOwnerAsync,
|
||||
} from "@/lib/gitea";
|
||||
import { createGitHubClient } from "@/lib/github";
|
||||
import { processWithResilience } from "@/lib/utils/concurrency";
|
||||
@@ -97,16 +97,25 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
// Log the start of mirroring
|
||||
console.log(`Starting mirror for repository: ${repo.name}`);
|
||||
|
||||
// Determine where the repository should be mirrored
|
||||
const owner = getGiteaRepoOwner({
|
||||
// Determine where the repository should be mirrored (with organization overrides)
|
||||
const owner = await getGiteaRepoOwnerAsync({
|
||||
config,
|
||||
repository: repoData,
|
||||
});
|
||||
|
||||
console.log(`Repository ${repo.name} will be mirrored to owner: ${owner}`);
|
||||
|
||||
// Check if owner is different from the user (means it's going to an org)
|
||||
if (owner !== config.giteaConfig?.username) {
|
||||
// For single-org and starred repos strategies, or when mirroring to an org,
|
||||
// always use the org mirroring function to ensure proper organization handling
|
||||
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
|
||||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
||||
|
||||
const shouldUseOrgMirror =
|
||||
owner !== config.giteaConfig?.username || // Different owner means org
|
||||
mirrorStrategy === "single-org" || // Single-org strategy always uses org
|
||||
repoData.isStarred; // Starred repos always go to org
|
||||
|
||||
if (shouldUseOrgMirror) {
|
||||
await mirrorGitHubOrgRepoToGiteaOrg({
|
||||
config,
|
||||
octokit,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, configs, repositories } from "@/lib/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { getGiteaRepoOwner, isRepoPresentInGitea } from "@/lib/gitea";
|
||||
import { getGiteaRepoOwnerAsync, isRepoPresentInGitea } from "@/lib/gitea";
|
||||
import {
|
||||
mirrorGithubRepoToGitea,
|
||||
mirrorGitHubOrgRepoToGiteaOrg,
|
||||
@@ -109,8 +109,8 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
status: "imported",
|
||||
});
|
||||
|
||||
// Determine if the repository exists in Gitea
|
||||
let owner = getGiteaRepoOwner({
|
||||
// Determine if the repository exists in Gitea (with organization overrides)
|
||||
let owner = await getGiteaRepoOwnerAsync({
|
||||
config,
|
||||
repository: repoData,
|
||||
});
|
||||
@@ -137,8 +137,17 @@ export const POST: APIRoute = async ({ request }) => {
|
||||
|
||||
console.log(`Importing repo: ${repo.name} to owner: ${owner}`);
|
||||
|
||||
// Check if owner is different from the user (means it's going to an org)
|
||||
if (owner !== config.giteaConfig?.username) {
|
||||
// For single-org and starred repos strategies, or when mirroring to an org,
|
||||
// always use the org mirroring function to ensure proper organization handling
|
||||
const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
|
||||
(config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
|
||||
|
||||
const shouldUseOrgMirror =
|
||||
owner !== config.giteaConfig?.username || // Different owner means org
|
||||
mirrorStrategy === "single-org" || // Single-org strategy always uses org
|
||||
repoData.isStarred; // Starred repos always go to org
|
||||
|
||||
if (shouldUseOrgMirror) {
|
||||
await mirrorGitHubOrgRepoToGiteaOrg({
|
||||
config,
|
||||
octokit,
|
||||
|
||||
82
src/pages/api/organizations/[id].ts
Normal file
82
src/pages/api/organizations/[id].ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { db, organizations } 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 orgId = params.id;
|
||||
if (!orgId) {
|
||||
return new Response(JSON.stringify({ error: "Organization ID is required" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { destinationOrg } = body;
|
||||
|
||||
// Validate that the organization belongs to the user
|
||||
const [existingOrg] = await db
|
||||
.select()
|
||||
.from(organizations)
|
||||
.where(and(eq(organizations.id, orgId), eq(organizations.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!existingOrg) {
|
||||
return new Response(JSON.stringify({ error: "Organization not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Update the organization's destination override
|
||||
await db
|
||||
.update(organizations)
|
||||
.set({
|
||||
destinationOrg: destinationOrg || null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(organizations.id, orgId));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: "Organization destination updated successfully",
|
||||
destinationOrg: destinationOrg || null,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "Update organization destination", 500);
|
||||
}
|
||||
};
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export interface GiteaConfig {
|
||||
starredReposOrg: string;
|
||||
preserveOrgStructure: boolean;
|
||||
mirrorStrategy?: MirrorStrategy; // New field for the strategy
|
||||
personalReposOrg?: string; // Override destination for personal repos
|
||||
}
|
||||
|
||||
export interface ScheduleConfig {
|
||||
|
||||
Reference in New Issue
Block a user