Compare commits

..

26 Commits

Author SHA1 Message Date
Arunavo Ray
7b58df375e chore: bump version to v2.16.2 2025-06-17 17:23:44 +05:30
Arunavo Ray
1d27bd31d8 feat: add bulk actions for repository management with selection support 2025-06-17 17:22:38 +05:30
Arunavo Ray
13d4257c4f refactor: enhance organization card display with status badges and improved layout 2025-06-17 16:52:15 +05:30
Arunavo Ray
818ba77693 chore: bump version to v2.16.1 2025-06-17 15:50:42 +05:30
Arunavo Ray
056970e577 refactor: improve repository owner handling and mirror strategy in Gitea integration 2025-06-17 15:44:04 +05:30
Arunavo Ray
65ea73e238 refactor: update label for starred repositories organization for consistency 2025-06-17 15:32:43 +05:30
Arunavo Ray
398f00aceb chore: bump version to v2.16.0 2025-06-17 15:16:10 +05:30
Arunavo Ray
50972713a3 refactor: improve version comparison logic in health API 2025-06-17 15:11:05 +05:30
Arunavo Ray
fbf3033455 refactor: remove ConnectionsForm and useMirror hook; update issue mirroring logic for starred repos 2025-06-17 15:06:54 +05:30
Arunavo Ray
cc4d8dabbc refactor: enhance starred repos content selection and improve layout 2025-06-17 14:54:23 +05:30
Arunavo Ray
8bba3d3521 chore: bump version to v2.15.0 2025-06-17 14:37:47 +05:30
Arunavo Ray
be63555e5c refactor: update titles and descriptions in strategyConfig for clarity 2025-06-17 14:34:17 +05:30
Arunavo Ray
32a906369f refactor: remove unused Building2 import in OrganizationConfiguration component 2025-06-17 14:28:49 +05:30
Arunavo Ray
064474fd13 refactor: remove unused tooltip imports in OrganizationStrategy component 2025-06-17 14:27:38 +05:30
Arunavo Ray
2ac933b599 refactor: streamline layout and improve tooltip descriptions in OrganizationConfiguration component 2025-06-17 14:26:52 +05:30
Arunavo Ray
403fe08bae refactor: update icons in GitHubMirrorSettings and OrganizationConfiguration components for improved clarity 2025-06-17 14:16:15 +05:30
Arunavo Ray
23c7ff7349 refactor: enhance layout and flexibility in GitHubConfigForm and GiteaConfigForm components 2025-06-17 14:05:10 +05:30
Arunavo Ray
3169af44cb refactor: improve layout and spacing in GitHubMirrorSettings component 2025-06-17 13:49:53 +05:30
Arunavo Ray
c1d93dbbc6 feat: enhance GitHubMirrorSettings with improved layout and metadata options 2025-06-17 13:43:17 +05:30
Arunavo Ray
047719cde9 feat: add OrganizationConfiguration component and integrate it into GiteaConfigForm 2025-06-17 13:20:03 +05:30
Arunavo Ray
13d4b03541 refactor: simplify OrganizationStrategy component by removing unused imports and details 2025-06-17 13:12:12 +05:30
Arunavo Ray
f07ae220b0 refactor: clean up imports and improve layout in OrganizationStrategy component 2025-06-17 12:59:29 +05:30
Arunavo Ray
01647445f2 Improved layout in larger screens 2025-06-17 12:48:38 +05:30
Arunavo Ray
13cbf86309 Updated Layout 2025-06-17 12:42:32 +05:30
Arunavo Ray
792096d209 Updates to Organisation Strategy Layouts 2025-06-17 11:39:41 +05:30
Arunavo Ray
e94de5c9ca fix: mirror to one org issue 2025-06-17 11:35:09 +05:30
19 changed files with 1380 additions and 827 deletions

View File

@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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

View File

@@ -1,7 +1,7 @@
{
"name": "gitea-mirror",
"type": "module",
"version": "2.14.0",
"version": "2.16.2",
"engines": {
"bun": ">=1.2.9"
},

View File

@@ -598,7 +598,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 =>

View File

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

View File

@@ -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 {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
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,52 @@ 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>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-0.5 hover:bg-muted rounded-sm transition-colors"
>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent 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>
</PopoverContent>
</Popover>
</div>
<Input
id="github-token"
name="token"
@@ -136,7 +176,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 +203,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>
);
}

View File

@@ -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";
@@ -69,6 +76,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">
@@ -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;
// 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>
<Separator className="my-2" />
<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-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>
<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-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>
<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-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="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-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-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>

View File

@@ -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,24 @@ 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}
visibility={config.visibility}
onDestinationOrgChange={(org) => {
const newConfig = { ...config, organization: org };
setConfig(newConfig);
@@ -227,54 +228,13 @@ export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving, g
setConfig(newConfig);
if (onAutoSave) onAutoSave(newConfig);
}}
githubUsername={githubUsername}
giteaUsername={config.username}
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>
);
}

View File

@@ -0,0 +1,163 @@
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;
visibility: GiteaOrgVisibility;
onDestinationOrgChange: (org: string) => void;
onStarredReposOrgChange: (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,
visibility,
onDestinationOrgChange,
onStarredReposOrgChange,
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, 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>
) : (
<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>
);
};

View File

@@ -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";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
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<{
strategy: MirrorStrategy;
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,44 @@ 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>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="p-1.5 hover:bg-muted rounded-md transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-4 w-4 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent 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>
</PopoverContent>
</Popover>
</div>
</div>
</Card>
@@ -335,76 +308,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>
);
};

View File

@@ -1,14 +1,14 @@
import { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, RefreshCw, Building2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Plus, RefreshCw, Building2, Check, AlertCircle, Clock } from "lucide-react";
import { SiGithub } 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";
interface OrganizationListProps {
organizations: Organization[];
@@ -20,6 +20,22 @@ interface OrganizationListProps {
onAddOrganization?: () => 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,
@@ -93,29 +109,45 @@ 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>
</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,58 +157,96 @@ 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>
{isLoading && (
<RefreshCw className="opacity-50 h-4 w-4 animate-spin ml-4" />
<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>
)}
{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>
@@ -185,19 +255,12 @@ export function OrganizationList({
href={`https://github.com/${org.name}`}
target="_blank"
rel="noopener noreferrer"
title="View on GitHub"
>
<SiGithub className="h-4 w-4" />
</a>
</Button>
</div>
{/* 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>
</div>
</Card>
);
})}

View File

@@ -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,8 @@ export default function Repository() {
onSync={handleSyncRepo}
onRetry={handleRetryRepoAction}
loadingRepoIds={loadingRepoIds}
selectedRepoIds={selectedRepoIds}
onSelectionChange={setSelectedRepoIds}
/>
)}

View File

@@ -9,6 +9,13 @@ 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";
interface RepositoryTableProps {
repositories: Repository[];
@@ -20,6 +27,8 @@ 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;
}
export default function RepositoryTable({
@@ -32,6 +41,8 @@ export default function RepositoryTable({
onSync,
onRetry,
loadingRepoIds,
selectedRepoIds,
onSelectionChange,
}: RepositoryTableProps) {
const tableParentRef = useRef<HTMLDivElement>(null);
const { giteaConfig } = useGiteaConfig();
@@ -105,9 +116,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 +170,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 +228,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,6 +284,15 @@ 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" />
@@ -277,12 +335,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 */}

View File

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

View File

@@ -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 = {};

View File

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

View File

@@ -160,15 +160,18 @@ export const mirrorGithubRepoToGitea = async ({
throw new Error("Gitea username is required.");
}
// Get the correct owner based on the strategy
const repoOwner = getGiteaRepoOwner({ 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 +182,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 +192,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 +241,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 +258,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 +275,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 +298,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 +432,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 +624,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 +775,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 +843,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;
},

View File

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

View File

@@ -105,8 +105,17 @@ export const POST: APIRoute = async ({ request }) => {
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,

View File

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