Compare commits

...

7 Commits

8 changed files with 255 additions and 193 deletions

View File

@@ -7,11 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.15.0] - 2025-06-17 ## [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 ### Added
- Enhanced OrganizationConfiguration component with improved layout and metadata options - Enhanced OrganizationConfiguration component with improved layout and metadata options
- New GitHubMirrorSettings component with better organization and flexibility - New GitHubMirrorSettings component with better organization and flexibility
- Enhanced starred repositories content selection and improved layout
### Improved ### Improved
- Enhanced configuration interface layout and spacing across multiple components - Enhanced configuration interface layout and spacing across multiple components
@@ -19,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved responsive layout for larger screens in configuration forms - Improved responsive layout for larger screens in configuration forms
- Better icon usage and clarity in configuration components - Better icon usage and clarity in configuration components
- Enhanced tooltip descriptions and component organization - Enhanced tooltip descriptions and component organization
- Improved version comparison logic in health API
- Enhanced issue mirroring logic for starred repositories
### Fixed ### Fixed
- Fixed mirror to single organization functionality - Fixed mirror to single organization functionality
@@ -29,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Simplified component structures by removing unused imports and dependencies - Simplified component structures by removing unused imports and dependencies
- Enhanced layout flexibility in GitHubConfigForm and GiteaConfigForm components - Enhanced layout flexibility in GitHubConfigForm and GiteaConfigForm components
- Improved component organization and code clarity - Improved component organization and code clarity
- Removed ConnectionsForm and useMirror hook for better code organization
## [2.14.0] - 2025-06-17 ## [2.14.0] - 2025-06-17

View File

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

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

@@ -76,6 +76,18 @@ export function GitHubMirrorSettings({
// When metadata is disabled, all components should be disabled // When metadata is disabled, all components should be disabled
const isMetadataEnabled = mirrorOptions.mirrorMetadata; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -112,7 +124,7 @@ export function GitHubMirrorSettings({
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<Checkbox <Checkbox
id="starred-repos" id="starred-repos"
@@ -133,44 +145,136 @@ export function GitHubMirrorSettings({
</div> </div>
</div> </div>
{/* Lightweight starred repos option - inline to prevent layout shift */} {/* Starred repos content selection - inline to prevent layout shift */}
<div className={cn( <div className={cn(
"flex items-start space-x-3 transition-all duration-200", "flex items-center justify-end transition-opacity duration-200",
githubConfig.mirrorStarred githubConfig.mirrorStarred ? "opacity-100" : "opacity-0 pointer-events-none"
? "opacity-100 lg:pl-6 lg:border-l lg:border-border"
: "opacity-0 pointer-events-none"
)}> )}>
<Checkbox <Popover>
id="skip-starred-metadata" <PopoverTrigger asChild>
checked={advancedOptions.skipStarredIssues} <Button
onCheckedChange={(checked) => handleAdvancedChange('skipStarredIssues', !!checked)} variant="outline"
disabled={!githubConfig.mirrorStarred} size="sm"
/> disabled={!githubConfig.mirrorStarred}
<div className="space-y-0.5 flex-1"> className="h-8 text-xs font-normal min-w-[140px] justify-between"
<Label >
htmlFor="skip-starred-metadata" <span>
className="text-sm font-normal cursor-pointer flex items-center gap-2" {advancedOptions.skipStarredIssues ? (
> "Code only"
Lightweight mirroring ) : starredContentCount === 0 ? (
<TooltipProvider> "Code only"
<Tooltip> ) : starredContentCount === totalStarredOptions ? (
<TooltipTrigger> "Full content"
<Info className="h-3 w-3 text-muted-foreground" /> ) : (
</TooltipTrigger> `${starredContentCount + 1} of ${totalStarredOptions + 1} selected`
<TooltipContent side="right" className="max-w-xs"> )}
<p className="text-xs"> </span>
When enabled, starred repositories will only mirror code, <ChevronDown className="ml-2 h-3 w-3 opacity-50" />
skipping issues, PRs, and other metadata to reduce storage </Button>
and improve performance. </PopoverTrigger>
</p> <PopoverContent align="end" className="w-72">
</TooltipContent> <div className="space-y-3">
</Tooltip> <div className="flex items-center justify-between">
</TooltipProvider> <div className="text-sm font-medium">Starred repos content</div>
</Label> <TooltipProvider>
<p className="text-xs text-muted-foreground"> <Tooltip>
Only code, skip issues and metadata <TooltipTrigger>
</p> <Info className="h-3.5 w-3.5 text-muted-foreground" />
</div> </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> </div>
</div> </div>

View File

@@ -51,7 +51,7 @@ export const OrganizationConfiguration: React.FC<OrganizationConfigurationProps>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2"> <Label htmlFor="starredReposOrg" className="text-sm font-normal flex items-center gap-2">
<Star className="h-3.5 w-3.5" /> <Star className="h-3.5 w-3.5" />
Starred Repositories Organization Starred Repos Organization
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>

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."); throw new Error("Gitea username is required.");
} }
// Get the correct owner based on the strategy
const repoOwner = getGiteaRepoOwner({ config, repository });
const isExisting = await isRepoPresentInGitea({ const isExisting = await isRepoPresentInGitea({
config, config,
owner: config.giteaConfig.username, owner: repoOwner,
repoName: repository.name, repoName: repository.name,
}); });
if (isExisting) { if (isExisting) {
console.log( 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 // Update database to reflect that the repository is already mirrored
@@ -179,7 +182,7 @@ export const mirrorGithubRepoToGitea = async ({
updatedAt: new Date(), updatedAt: new Date(),
lastMirrored: new Date(), lastMirrored: new Date(),
errorMessage: null, errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`, mirroredLocation: `${repoOwner}/${repository.name}`,
}) })
.where(eq(repositories.id, repository.id!)); .where(eq(repositories.id, repository.id!));
@@ -189,7 +192,7 @@ export const mirrorGithubRepoToGitea = async ({
repositoryId: repository.id, repositoryId: repository.id,
repositoryName: repository.name, repositoryName: repository.name,
message: `Repository ${repository.name} already exists in Gitea`, 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", status: "mirrored",
}); });
@@ -238,6 +241,15 @@ export const mirrorGithubRepoToGitea = async ({
const apiUrl = `${config.giteaConfig.url}/api/v1/repos/migrate`; 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( const response = await httpPost(
apiUrl, apiUrl,
{ {
@@ -246,7 +258,7 @@ export const mirrorGithubRepoToGitea = async ({
mirror: true, mirror: true,
wiki: config.githubConfig.mirrorWiki || false, // will mirror wiki if it exists wiki: config.githubConfig.mirrorWiki || false, // will mirror wiki if it exists
private: repository.isPrivate, private: repository.isPrivate,
repo_owner: config.giteaConfig.username, repo_owner: repoOwner,
description: "", description: "",
service: "git", service: "git",
}, },
@@ -263,7 +275,11 @@ export const mirrorGithubRepoToGitea = async ({
}); });
// clone issues // 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({ await mirrorGitRepoIssuesToGitea({
config, config,
octokit, octokit,
@@ -282,7 +298,7 @@ export const mirrorGithubRepoToGitea = async ({
updatedAt: new Date(), updatedAt: new Date(),
lastMirrored: new Date(), lastMirrored: new Date(),
errorMessage: null, errorMessage: null,
mirroredLocation: `${config.giteaConfig.username}/${repository.name}`, mirroredLocation: `${repoOwner}/${repository.name}`,
}) })
.where(eq(repositories.id, repository.id!)); .where(eq(repositories.id, repository.id!));
@@ -608,7 +624,11 @@ export async function mirrorGitHubRepoToGiteaOrg({
}); });
// Clone issues // 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({ await mirrorGitRepoIssuesToGitea({
config, config,
octokit, octokit,
@@ -755,11 +775,37 @@ export async function mirrorGitHubOrgToGitea({
status: repoStatusEnum.parse("mirroring"), status: repoStatusEnum.parse("mirroring"),
}); });
const giteaOrgId = await getOrCreateGiteaOrg({ // Get the mirror strategy - use preserveOrgStructure for backward compatibility
orgId: organization.id, const mirrorStrategy = config.giteaConfig?.mirrorStrategy ||
orgName: organization.name, (config.githubConfig?.preserveOrgStructure ? "preserve" : "flat-user");
config,
}); 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 //query the db with the org name and get the repos
const orgRepos = await db const orgRepos = await db
@@ -797,17 +843,27 @@ export async function mirrorGitHubOrgToGitea({
// Log the start of mirroring // Log the start of mirroring
console.log( 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 // Mirror the repository based on strategy
await mirrorGitHubRepoToGiteaOrg({ if (mirrorStrategy === "flat-user") {
octokit, // For flat-user strategy, mirror directly to user account
config, await mirrorGithubRepoToGitea({
repository: repoData, octokit,
giteaOrgId, repository: repoData,
orgName: organization.name, config,
}); });
} else {
// For preserve and single-org strategies, use organization
await mirrorGitHubRepoToGiteaOrg({
octokit,
config,
repository: repoData,
giteaOrgId: giteaOrgId!,
orgName: targetOrgName,
});
}
return repo; return repo;
}, },

View File

@@ -58,7 +58,7 @@ export const GET: APIRoute = async () => {
latestVersion: latestVersion, latestVersion: latestVersion,
updateAvailable: latestVersion !== "unknown" && updateAvailable: latestVersion !== "unknown" &&
currentVersion !== "unknown" && currentVersion !== "unknown" &&
latestVersion !== currentVersion, compareVersions(currentVersion, latestVersion) < 0,
database: dbStatus, database: dbStatus,
recovery: recoveryStatus, recovery: recoveryStatus,
system: systemInfo, system: systemInfo,
@@ -174,6 +174,28 @@ function formatBytes(bytes: number): string {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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 * Check for the latest version from GitHub releases
*/ */