mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 12:36:44 +03:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
832b57538d | ||
|
|
415bff8e41 | ||
|
|
13c3ddea04 | ||
|
|
b917b30830 | ||
|
|
b34ed5595b | ||
|
|
cbc11155ef | ||
|
|
941f61830f | ||
|
|
5b60cffaae | ||
|
|
ede5b4dbe8 |
30
README.md
30
README.md
@@ -513,6 +513,36 @@ Try the following steps:
|
|||||||
>
|
>
|
||||||
> This setup provides a complete containerized deployment for the Gitea Mirror application.
|
> This setup provides a complete containerized deployment for the Gitea Mirror application.
|
||||||
|
|
||||||
|
#### Docker Volume Types and Permissions
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Named Volumes vs Bind Mounts**: If you encounter SQLite permission errors even when using Docker, check your volume configuration:
|
||||||
|
|
||||||
|
**✅ Named Volumes (Recommended):**
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- gitea-mirror-data:/app/data # Docker manages permissions automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Bind Mounts (Requires Manual Permission Setup):**
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- /host/path/to/data:/app/data # Host filesystem permissions apply
|
||||||
|
```
|
||||||
|
|
||||||
|
**If using bind mounts**, ensure the host directory is owned by UID 1001 (the `gitea-mirror` user):
|
||||||
|
```bash
|
||||||
|
# Set correct ownership for bind mount
|
||||||
|
sudo chown -R 1001:1001 /host/path/to/data
|
||||||
|
sudo chmod -R 755 /host/path/to/data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why named volumes work better:**
|
||||||
|
- Docker automatically handles permissions
|
||||||
|
- Better portability across different hosts
|
||||||
|
- No manual permission setup required
|
||||||
|
- Used by our official docker-compose.yml
|
||||||
|
|
||||||
|
|
||||||
#### Database Maintenance
|
#### Database Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gitea-mirror",
|
"name": "gitea-mirror",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "2.9.2",
|
"version": "2.11.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"bun": ">=1.2.9"
|
"bun": ">=1.2.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '../ui/dialog';
|
} from '../ui/dialog';
|
||||||
import { apiRequest, formatDate } from '@/lib/utils';
|
import { apiRequest, formatDate, showErrorToast } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import type { MirrorJob } from '@/lib/db/schema';
|
import type { MirrorJob } from '@/lib/db/schema';
|
||||||
import type { ActivityApiResponse } from '@/types/activities';
|
import type { ActivityApiResponse } from '@/types/activities';
|
||||||
@@ -155,7 +155,7 @@ export function ActivityLog() {
|
|||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||||
if (!isLiveRefresh) {
|
if (!isLiveRefresh) {
|
||||||
toast.error(res.message ?? 'Failed to fetch activities.');
|
showErrorToast(res.message ?? 'Failed to fetch activities.', toast);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -184,9 +184,7 @@ export function ActivityLog() {
|
|||||||
if (isMountedRef.current) {
|
if (isMountedRef.current) {
|
||||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||||
if (!isLiveRefresh) {
|
if (!isLiveRefresh) {
|
||||||
toast.error(
|
showErrorToast(err, toast);
|
||||||
err instanceof Error ? err.message : 'Failed to fetch activities.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -331,11 +329,11 @@ export function ActivityLog() {
|
|||||||
setActivities([]);
|
setActivities([]);
|
||||||
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
|
toast.success(`All activities cleaned up successfully. Deleted ${res.result.mirrorJobsDeleted} mirror jobs and ${res.result.eventsDeleted} events.`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.error || 'Failed to cleanup activities.');
|
showErrorToast(res.error || 'Failed to cleanup activities.', toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error cleaning up activities:', error);
|
console.error('Error cleaning up activities:', error);
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to cleanup activities.');
|
showErrorToast(error, toast);
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitialLoading(false);
|
setIsInitialLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { SiGitea } from 'react-icons/si';
|
import { SiGitea } from 'react-icons/si';
|
||||||
import { toast, Toaster } from 'sonner';
|
import { toast, Toaster } from 'sonner';
|
||||||
|
import { showErrorToast } from '@/lib/utils';
|
||||||
import { FlipHorizontal } from 'lucide-react';
|
import { FlipHorizontal } from 'lucide-react';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
@@ -45,10 +46,10 @@ export function LoginForm() {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Login failed. Please try again.');
|
showErrorToast(data.error || 'Login failed. Please try again.', toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('An error occurred while logging in. Please try again.');
|
showErrorToast(error, toast);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { GitMerge } from 'lucide-react';
|
import { GitMerge } from 'lucide-react';
|
||||||
import { toast, Toaster } from 'sonner';
|
import { toast, Toaster } from 'sonner';
|
||||||
|
import { showErrorToast } from '@/lib/utils';
|
||||||
|
|
||||||
export function SignupForm() {
|
export function SignupForm() {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -51,10 +52,10 @@ export function SignupForm() {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
} else {
|
} else {
|
||||||
toast.error(data.error || 'Failed to create account. Please try again.');
|
showErrorToast(data.error || 'Failed to create account. Please try again.', toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('An error occurred while creating your account. Please try again.');
|
showErrorToast(error, toast);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '@/types/config';
|
} from '@/types/config';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { apiRequest } from '@/lib/utils';
|
import { apiRequest, showErrorToast } from '@/lib/utils';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
@@ -56,14 +56,18 @@ export function ConfigTabs() {
|
|||||||
retentionDays: 604800, // 7 days in seconds
|
retentionDays: 604800, // 7 days in seconds
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { user, refreshUser } = useAuth();
|
const { user } = useAuth();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
const [isSyncing, setIsSyncing] = useState<boolean>(false);
|
||||||
const [isConfigSaved, setIsConfigSaved] = useState<boolean>(false);
|
|
||||||
const [isAutoSavingSchedule, setIsAutoSavingSchedule] = useState<boolean>(false);
|
const [isAutoSavingSchedule, setIsAutoSavingSchedule] = useState<boolean>(false);
|
||||||
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
const [isAutoSavingCleanup, setIsAutoSavingCleanup] = useState<boolean>(false);
|
||||||
|
const [isAutoSavingGitHub, setIsAutoSavingGitHub] = useState<boolean>(false);
|
||||||
|
const [isAutoSavingGitea, setIsAutoSavingGitea] = useState<boolean>(false);
|
||||||
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const autoSaveScheduleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const autoSaveCleanupTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const autoSaveGitHubTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const autoSaveGiteaTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
const isConfigFormValid = (): boolean => {
|
const isConfigFormValid = (): boolean => {
|
||||||
const { githubConfig, giteaConfig } = config;
|
const { githubConfig, giteaConfig } = config;
|
||||||
@@ -109,47 +113,9 @@ export function ConfigTabs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async () => {
|
|
||||||
if (!user?.id) return;
|
|
||||||
const reqPayload: SaveConfigApiRequest = {
|
|
||||||
userId: user.id,
|
|
||||||
githubConfig: config.githubConfig,
|
|
||||||
giteaConfig: config.giteaConfig,
|
|
||||||
scheduleConfig: config.scheduleConfig,
|
|
||||||
cleanupConfig: config.cleanupConfig,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(reqPayload),
|
|
||||||
});
|
|
||||||
const result: SaveConfigApiResponse = await response.json();
|
|
||||||
if (result.success) {
|
|
||||||
await refreshUser();
|
|
||||||
setIsConfigSaved(true);
|
|
||||||
// Invalidate config cache so other components get fresh data
|
|
||||||
invalidateConfigCache();
|
|
||||||
toast.success(
|
|
||||||
'Configuration saved successfully! Now import your GitHub data to begin.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.error(
|
|
||||||
`Failed to save configuration: ${result.message || 'Unknown error'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
`An error occurred while saving the configuration: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-save function specifically for schedule config changes
|
// Auto-save function specifically for schedule config changes
|
||||||
const autoSaveScheduleConfig = useCallback(async (scheduleConfig: ScheduleConfig) => {
|
const autoSaveScheduleConfig = useCallback(async (scheduleConfig: ScheduleConfig) => {
|
||||||
if (!user?.id || !isConfigSaved) return; // Only auto-save if config was previously saved
|
if (!user?.id) return;
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (autoSaveScheduleTimeoutRef.current) {
|
if (autoSaveScheduleTimeoutRef.current) {
|
||||||
@@ -198,27 +164,22 @@ export function ConfigTabs() {
|
|||||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
showErrorToast(
|
||||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||||
{ duration: 3000 }
|
toast
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
`Auto-save error: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
{ duration: 3000 }
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoSavingSchedule(false);
|
setIsAutoSavingSchedule(false);
|
||||||
}
|
}
|
||||||
}, 500); // 500ms debounce
|
}, 500); // 500ms debounce
|
||||||
}, [user?.id, isConfigSaved, config.githubConfig, config.giteaConfig, config.cleanupConfig]);
|
}, [user?.id, config.githubConfig, config.giteaConfig, config.cleanupConfig]);
|
||||||
|
|
||||||
// Auto-save function specifically for cleanup config changes
|
// Auto-save function specifically for cleanup config changes
|
||||||
const autoSaveCleanupConfig = useCallback(async (cleanupConfig: DatabaseCleanupConfig) => {
|
const autoSaveCleanupConfig = useCallback(async (cleanupConfig: DatabaseCleanupConfig) => {
|
||||||
if (!user?.id || !isConfigSaved) return; // Only auto-save if config was previously saved
|
if (!user?.id) return;
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (autoSaveCleanupTimeoutRef.current) {
|
if (autoSaveCleanupTimeoutRef.current) {
|
||||||
@@ -266,23 +227,112 @@ export function ConfigTabs() {
|
|||||||
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
console.warn('Failed to fetch updated config after auto-save:', fetchError);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
showErrorToast(
|
||||||
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||||
{ duration: 3000 }
|
toast
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
`Auto-save error: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
{ duration: 3000 }
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoSavingCleanup(false);
|
setIsAutoSavingCleanup(false);
|
||||||
}
|
}
|
||||||
}, 500); // 500ms debounce
|
}, 500); // 500ms debounce
|
||||||
}, [user?.id, isConfigSaved, config.githubConfig, config.giteaConfig, config.scheduleConfig]);
|
}, [user?.id, config.githubConfig, config.giteaConfig, config.scheduleConfig]);
|
||||||
|
|
||||||
|
// Auto-save function specifically for GitHub config changes
|
||||||
|
const autoSaveGitHubConfig = useCallback(async (githubConfig: GitHubConfig) => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (autoSaveGitHubTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the auto-save to prevent excessive API calls
|
||||||
|
autoSaveGitHubTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setIsAutoSavingGitHub(true);
|
||||||
|
|
||||||
|
const reqPayload: SaveConfigApiRequest = {
|
||||||
|
userId: user.id!,
|
||||||
|
githubConfig: githubConfig,
|
||||||
|
giteaConfig: config.giteaConfig,
|
||||||
|
scheduleConfig: config.scheduleConfig,
|
||||||
|
cleanupConfig: config.cleanupConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(reqPayload),
|
||||||
|
});
|
||||||
|
const result: SaveConfigApiResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Silent success - no toast for auto-save
|
||||||
|
// Invalidate config cache so other components get fresh data
|
||||||
|
invalidateConfigCache();
|
||||||
|
} else {
|
||||||
|
showErrorToast(
|
||||||
|
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||||
|
toast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, toast);
|
||||||
|
} finally {
|
||||||
|
setIsAutoSavingGitHub(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
}, [user?.id, config.giteaConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||||
|
|
||||||
|
// Auto-save function specifically for Gitea config changes
|
||||||
|
const autoSaveGiteaConfig = useCallback(async (giteaConfig: GiteaConfig) => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (autoSaveGiteaTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce the auto-save to prevent excessive API calls
|
||||||
|
autoSaveGiteaTimeoutRef.current = setTimeout(async () => {
|
||||||
|
setIsAutoSavingGitea(true);
|
||||||
|
|
||||||
|
const reqPayload: SaveConfigApiRequest = {
|
||||||
|
userId: user.id!,
|
||||||
|
githubConfig: config.githubConfig,
|
||||||
|
giteaConfig: giteaConfig,
|
||||||
|
scheduleConfig: config.scheduleConfig,
|
||||||
|
cleanupConfig: config.cleanupConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(reqPayload),
|
||||||
|
});
|
||||||
|
const result: SaveConfigApiResponse = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Silent success - no toast for auto-save
|
||||||
|
// Invalidate config cache so other components get fresh data
|
||||||
|
invalidateConfigCache();
|
||||||
|
} else {
|
||||||
|
showErrorToast(
|
||||||
|
`Auto-save failed: ${result.message || 'Unknown error'}`,
|
||||||
|
toast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showErrorToast(error, toast);
|
||||||
|
} finally {
|
||||||
|
setIsAutoSavingGitea(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
}, [user?.id, config.githubConfig, config.scheduleConfig, config.cleanupConfig]);
|
||||||
|
|
||||||
// Cleanup timeouts on unmount
|
// Cleanup timeouts on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -293,6 +343,12 @@ export function ConfigTabs() {
|
|||||||
if (autoSaveCleanupTimeoutRef.current) {
|
if (autoSaveCleanupTimeoutRef.current) {
|
||||||
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
clearTimeout(autoSaveCleanupTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
if (autoSaveGitHubTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveGitHubTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (autoSaveGiteaTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveGiteaTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -317,7 +373,7 @@ export function ConfigTabs() {
|
|||||||
cleanupConfig:
|
cleanupConfig:
|
||||||
response.cleanupConfig || config.cleanupConfig,
|
response.cleanupConfig || config.cleanupConfig,
|
||||||
});
|
});
|
||||||
if (response.id) setIsConfigSaved(true);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@@ -414,10 +470,10 @@ export function ConfigTabs() {
|
|||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleImportGitHubData}
|
onClick={handleImportGitHubData}
|
||||||
disabled={isSyncing || !isConfigSaved}
|
disabled={isSyncing || !isConfigFormValid()}
|
||||||
title={
|
title={
|
||||||
!isConfigSaved
|
!isConfigFormValid()
|
||||||
? 'Save configuration first'
|
? 'Please fill all required GitHub and Gitea fields'
|
||||||
: isSyncing
|
: isSyncing
|
||||||
? 'Import in progress'
|
? 'Import in progress'
|
||||||
: 'Import GitHub Data'
|
: 'Import GitHub Data'
|
||||||
@@ -435,17 +491,6 @@ export function ConfigTabs() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
onClick={handleSaveConfig}
|
|
||||||
disabled={!isConfigFormValid()}
|
|
||||||
title={
|
|
||||||
!isConfigFormValid()
|
|
||||||
? 'Please fill all required fields'
|
|
||||||
: 'Save Configuration'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Save Configuration
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -463,6 +508,8 @@ export function ConfigTabs() {
|
|||||||
: update,
|
: update,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onAutoSave={autoSaveGitHubConfig}
|
||||||
|
isAutoSaving={isAutoSavingGitHub}
|
||||||
/>
|
/>
|
||||||
<GiteaConfigForm
|
<GiteaConfigForm
|
||||||
config={config.giteaConfig}
|
config={config.giteaConfig}
|
||||||
@@ -475,6 +522,8 @@ export function ConfigTabs() {
|
|||||||
: update,
|
: update,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onAutoSave={autoSaveGiteaConfig}
|
||||||
|
isAutoSaving={isAutoSavingGitea}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-x-4">
|
<div className="flex gap-x-4">
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|||||||
interface GitHubConfigFormProps {
|
interface GitHubConfigFormProps {
|
||||||
config: GitHubConfig;
|
config: GitHubConfig;
|
||||||
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
|
setConfig: React.Dispatch<React.SetStateAction<GitHubConfig>>;
|
||||||
|
onAutoSave?: (githubConfig: GitHubConfig) => Promise<void>;
|
||||||
|
isAutoSaving?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
export function GitHubConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: GitHubConfigFormProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -43,10 +45,17 @@ export function GitHubConfigForm({ config, setConfig }: GitHubConfigFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setConfig({
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
[name]: type === "checkbox" ? checked : value,
|
[name]: type === "checkbox" ? checked : value,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
setConfig(newConfig);
|
||||||
|
|
||||||
|
// Auto-save for all field changes
|
||||||
|
if (onAutoSave) {
|
||||||
|
onAutoSave(newConfig);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
|
|||||||
@@ -21,19 +21,27 @@ import { toast } from "sonner";
|
|||||||
interface GiteaConfigFormProps {
|
interface GiteaConfigFormProps {
|
||||||
config: GiteaConfig;
|
config: GiteaConfig;
|
||||||
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
setConfig: React.Dispatch<React.SetStateAction<GiteaConfig>>;
|
||||||
|
onAutoSave?: (giteaConfig: GiteaConfig) => Promise<void>;
|
||||||
|
isAutoSaving?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GiteaConfigForm({ config, setConfig }: GiteaConfigFormProps) {
|
export function GiteaConfigForm({ config, setConfig, onAutoSave, isAutoSaving }: GiteaConfigFormProps) {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||||
) => {
|
) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setConfig({
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
[name]: value,
|
[name]: value,
|
||||||
});
|
};
|
||||||
|
setConfig(newConfig);
|
||||||
|
|
||||||
|
// Auto-save for all field changes
|
||||||
|
if (onAutoSave) {
|
||||||
|
onAutoSave(newConfig);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const testConnection = async () => {
|
const testConnection = async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GitFork, Clock, FlipHorizontal, Building2 } from "lucide-react";
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
import type { MirrorJob, Organization, Repository } from "@/lib/db/schema";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiRequest } from "@/lib/utils";
|
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||||
import type { DashboardApiResponse } from "@/types/dashboard";
|
import type { DashboardApiResponse } from "@/types/dashboard";
|
||||||
import { useSSE } from "@/hooks/useSEE";
|
import { useSSE } from "@/hooks/useSEE";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -103,15 +103,11 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error fetching dashboard data");
|
showErrorToast(response.error || "Error fetching dashboard data", toast);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Error fetching dashboard data"
|
|
||||||
);
|
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ export function RepositoryList({ repositories }: RepositoryListProps) {
|
|||||||
Private
|
Private
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{repo.isForked && (
|
||||||
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
Fork
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function VersionInfo() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-xs text-muted-foreground text-center pt-2 pb-3 border-t border-border mt-2">
|
<div className="text-xs text-muted-foreground text-center pt-2 pb-3 border-t border-border mt-2">
|
||||||
{versionInfo.updateAvailable ? (
|
{versionInfo.updateAvailable ? (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col gap-1">
|
||||||
<span>v{versionInfo.current}</span>
|
<span>v{versionInfo.current}</span>
|
||||||
<span className="text-primary">v{versionInfo.latest} available</span>
|
<span className="text-primary">v{versionInfo.latest} available</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Search, RefreshCw, FlipHorizontal, Plus } from "lucide-react";
|
import { Search, RefreshCw, FlipHorizontal } from "lucide-react";
|
||||||
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
import type { MirrorJob, Organization } from "@/lib/db/schema";
|
||||||
import { OrganizationList } from "./OrganizationsList";
|
import { OrganizationList } from "./OrganizationsList";
|
||||||
import AddOrganizationDialog from "./AddOrganizationDialog";
|
import AddOrganizationDialog from "./AddOrganizationDialog";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { apiRequest } from "@/lib/utils";
|
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
membershipRoleEnum,
|
membershipRoleEnum,
|
||||||
type AddOrganizationApiRequest,
|
type AddOrganizationApiRequest,
|
||||||
@@ -26,6 +26,7 @@ import { useFilterParams } from "@/hooks/useFilterParams";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
import { useConfigStatus } from "@/hooks/useConfigStatus";
|
||||||
import { useNavigation } from "@/components/layout/MainLayout";
|
import { useNavigation } from "@/components/layout/MainLayout";
|
||||||
|
import { useLiveRefresh } from "@/hooks/useLiveRefresh";
|
||||||
|
|
||||||
export function Organization() {
|
export function Organization() {
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -34,6 +35,7 @@ export function Organization() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { isGitHubConfigured } = useConfigStatus();
|
const { isGitHubConfigured } = useConfigStatus();
|
||||||
const { navigationKey } = useNavigation();
|
const { navigationKey } = useNavigation();
|
||||||
|
const { registerRefreshCallback } = useLiveRefresh();
|
||||||
const { filter, setFilter } = useFilterParams({
|
const { filter, setFilter } = useFilterParams({
|
||||||
searchTerm: "",
|
searchTerm: "",
|
||||||
membershipRole: "",
|
membershipRole: "",
|
||||||
@@ -62,19 +64,23 @@ export function Organization() {
|
|||||||
onMessage: handleNewMessage,
|
onMessage: handleNewMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchOrganizations = useCallback(async () => {
|
const fetchOrganizations = useCallback(async (isLiveRefresh = false) => {
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't fetch organizations if GitHub is not configured
|
// Don't fetch organizations if GitHub is not configured
|
||||||
if (!isGitHubConfigured) {
|
if (!isGitHubConfigured) {
|
||||||
setIsLoading(false);
|
if (!isLiveRefresh) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
if (!isLiveRefresh) {
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await apiRequest<OrganizationsApiResponse>(
|
const response = await apiRequest<OrganizationsApiResponse>(
|
||||||
`/github/organizations?userId=${user.id}`,
|
`/github/organizations?userId=${user.id}`,
|
||||||
@@ -87,27 +93,47 @@ export function Organization() {
|
|||||||
setOrganizations(response.organizations);
|
setOrganizations(response.organizations);
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error fetching organizations");
|
if (!isLiveRefresh) {
|
||||||
|
toast.error(response.error || "Error fetching organizations");
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
if (!isLiveRefresh) {
|
||||||
error instanceof Error ? error.message : "Error fetching organizations"
|
toast.error(
|
||||||
);
|
error instanceof Error ? error.message : "Error fetching organizations"
|
||||||
|
);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (!isLiveRefresh) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
}, [user?.id, isGitHubConfigured]); // Only depend on user.id, not entire user object
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Reset loading state when component becomes active
|
// Reset loading state when component becomes active
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
fetchOrganizations();
|
fetchOrganizations(false); // Manual refresh, not live
|
||||||
}, [fetchOrganizations, navigationKey]); // Include navigationKey to trigger on navigation
|
}, [fetchOrganizations, navigationKey]); // Include navigationKey to trigger on navigation
|
||||||
|
|
||||||
|
// Register with global live refresh system
|
||||||
|
useEffect(() => {
|
||||||
|
// Only register for live refresh if GitHub is configured
|
||||||
|
if (!isGitHubConfigured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unregister = registerRefreshCallback(() => {
|
||||||
|
fetchOrganizations(true); // Live refresh
|
||||||
|
});
|
||||||
|
|
||||||
|
return unregister;
|
||||||
|
}, [registerRefreshCallback, fetchOrganizations, isGitHubConfigured]);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
const success = await fetchOrganizations();
|
const success = await fetchOrganizations(false);
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success("Organizations refreshed successfully.");
|
toast.success("Organizations refreshed successfully.");
|
||||||
}
|
}
|
||||||
@@ -140,6 +166,12 @@ export function Organization() {
|
|||||||
return updated ? updated : org;
|
return updated ? updated : org;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh organization data to get updated repository breakdown
|
||||||
|
// Use a small delay to allow the backend to process the mirroring request
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchOrganizations(true);
|
||||||
|
}, 1000);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error starting mirror job");
|
toast.error(response.error || "Error starting mirror job");
|
||||||
}
|
}
|
||||||
@@ -193,12 +225,10 @@ export function Organization() {
|
|||||||
searchTerm: org,
|
searchTerm: org,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error adding organization");
|
showErrorToast(response.error || "Error adding organization", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error adding organization"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -250,24 +280,17 @@ export function Organization() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error starting mirror jobs");
|
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error starting mirror jobs"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Reset loading states - we'll let the SSE updates handle status changes
|
// Reset loading states - we'll let the SSE updates handle status changes
|
||||||
setLoadingOrgIds(new Set());
|
setLoadingOrgIds(new Set());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get unique organization names for combobox (since Organization has no owner field)
|
|
||||||
const ownerOptions = Array.from(
|
|
||||||
new Set(
|
|
||||||
organizations.map((org) => org.name).filter((v): v is string => !!v)
|
|
||||||
)
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-y-8">
|
<div className="flex flex-col gap-y-8">
|
||||||
|
|||||||
@@ -118,10 +118,38 @@ export function OrganizationList({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
{org.repositoryCount}{" "}
|
<div className="flex items-center justify-between">
|
||||||
{org.repositoryCount === 1 ? "repository" : "repositories"}
|
<span className="font-medium">
|
||||||
</p>
|
{org.repositoryCount}{" "}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -144,7 +172,7 @@ export function OrganizationList({
|
|||||||
htmlFor={`include-${org.id}`}
|
htmlFor={`include-${org.id}`}
|
||||||
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
className="ml-2 text-sm select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
>
|
>
|
||||||
Include in mirroring
|
Enable mirroring
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type RepositoryApiResponse,
|
type RepositoryApiResponse,
|
||||||
type RepoStatus,
|
type RepoStatus,
|
||||||
} from "@/types/Repository";
|
} from "@/types/Repository";
|
||||||
import { apiRequest } from "@/lib/utils";
|
import { apiRequest, showErrorToast } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -108,16 +108,14 @@ export default function Repository() {
|
|||||||
} else {
|
} else {
|
||||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||||
if (!isLiveRefresh) {
|
if (!isLiveRefresh) {
|
||||||
toast.error(response.error || "Error fetching repositories");
|
showErrorToast(response.error || "Error fetching repositories", toast);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Only show error toast for manual refreshes to avoid spam during live updates
|
// Only show error toast for manual refreshes to avoid spam during live updates
|
||||||
if (!isLiveRefresh) {
|
if (!isLiveRefresh) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error fetching repositories"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -184,12 +182,10 @@ export default function Repository() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error starting mirror job");
|
showErrorToast(response.error || "Error starting mirror job", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error starting mirror job"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRepoIds((prev) => {
|
setLoadingRepoIds((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -248,12 +244,10 @@ export default function Repository() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error starting mirror jobs");
|
showErrorToast(response.error || "Error starting mirror jobs", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error starting mirror jobs"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Reset loading states - we'll let the SSE updates handle status changes
|
// Reset loading states - we'll let the SSE updates handle status changes
|
||||||
setLoadingRepoIds(new Set());
|
setLoadingRepoIds(new Set());
|
||||||
@@ -287,12 +281,10 @@ export default function Repository() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error starting sync job");
|
showErrorToast(response.error || "Error starting sync job", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error starting sync job"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRepoIds((prev) => {
|
setLoadingRepoIds((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -329,12 +321,10 @@ export default function Repository() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error retrying job");
|
showErrorToast(response.error || "Error retrying job", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error retrying job"
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRepoIds((prev) => {
|
setLoadingRepoIds((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -381,12 +371,10 @@ export default function Repository() {
|
|||||||
searchTerm: repo,
|
searchTerm: repo,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
toast.error(response.error || "Error adding repository");
|
showErrorToast(response.error || "Error adding repository", toast);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
showErrorToast(error, toast);
|
||||||
error instanceof Error ? error.message : "Error adding repository"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export default function RepositoryTable({
|
|||||||
Private
|
Private
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{repo.isForked && (
|
||||||
|
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs">
|
||||||
|
Fork
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner */}
|
{/* Owner */}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ export const organizationSchema = z.object({
|
|||||||
errorMessage: z.string().optional(),
|
errorMessage: z.string().optional(),
|
||||||
|
|
||||||
repositoryCount: z.number().default(0),
|
repositoryCount: z.number().default(0),
|
||||||
|
publicRepositoryCount: z.number().optional(),
|
||||||
|
privateRepositoryCount: z.number().optional(),
|
||||||
|
forkRepositoryCount: z.number().optional(),
|
||||||
|
|
||||||
createdAt: z.date().default(() => new Date()),
|
createdAt: z.date().default(() => new Date()),
|
||||||
updatedAt: z.date().default(() => new Date()),
|
updatedAt: z.date().default(() => new Date()),
|
||||||
|
|||||||
@@ -204,4 +204,90 @@ describe("Gitea Repository Mirroring", () => {
|
|||||||
global.fetch = originalFetch;
|
global.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("mirrorGitHubOrgToGitea handles empty organizations correctly", async () => {
|
||||||
|
// Mock the createMirrorJob function
|
||||||
|
const mockCreateMirrorJob = mock(() => Promise.resolve("job-id"));
|
||||||
|
|
||||||
|
// Mock the getOrCreateGiteaOrg function
|
||||||
|
const mockGetOrCreateGiteaOrg = mock(() => Promise.resolve("gitea-org-id"));
|
||||||
|
|
||||||
|
// Create a test version of the function with mocked dependencies
|
||||||
|
const testMirrorGitHubOrgToGitea = async ({
|
||||||
|
organization,
|
||||||
|
config,
|
||||||
|
}: {
|
||||||
|
organization: any;
|
||||||
|
config: any;
|
||||||
|
}) => {
|
||||||
|
// Simulate the function logic for empty organization
|
||||||
|
console.log(`Mirroring organization ${organization.name}`);
|
||||||
|
|
||||||
|
// Mock: get or create Gitea org
|
||||||
|
await mockGetOrCreateGiteaOrg();
|
||||||
|
|
||||||
|
// Mock: query the db with the org name and get the repos
|
||||||
|
const orgRepos: any[] = []; // Empty array to simulate no repositories
|
||||||
|
|
||||||
|
if (orgRepos.length === 0) {
|
||||||
|
console.log(`No repositories found for organization ${organization.name} - marking as successfully mirrored`);
|
||||||
|
} else {
|
||||||
|
console.log(`Mirroring ${orgRepos.length} repositories for organization ${organization.name}`);
|
||||||
|
// Repository processing would happen here
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Organization ${organization.name} mirrored successfully`);
|
||||||
|
|
||||||
|
// Mock: Append log for "mirrored" status
|
||||||
|
await mockCreateMirrorJob({
|
||||||
|
userId: config.userId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
message: `Successfully mirrored organization: ${organization.name}`,
|
||||||
|
details: orgRepos.length === 0
|
||||||
|
? `Organization ${organization.name} was processed successfully (no repositories found).`
|
||||||
|
: `Organization ${organization.name} was mirrored to Gitea with ${orgRepos.length} repositories.`,
|
||||||
|
status: "mirrored",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create mock organization
|
||||||
|
const organization = {
|
||||||
|
id: "org-id",
|
||||||
|
name: "empty-org",
|
||||||
|
status: "imported"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create mock config
|
||||||
|
const config = {
|
||||||
|
id: "config-id",
|
||||||
|
userId: "user-id",
|
||||||
|
githubConfig: {
|
||||||
|
token: "github-token"
|
||||||
|
},
|
||||||
|
giteaConfig: {
|
||||||
|
url: "https://gitea.example.com",
|
||||||
|
token: "gitea-token"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call the test function
|
||||||
|
await testMirrorGitHubOrgToGitea({
|
||||||
|
organization,
|
||||||
|
config
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that the mirror job was created with the correct details for empty org
|
||||||
|
expect(mockCreateMirrorJob).toHaveBeenCalledWith({
|
||||||
|
userId: "user-id",
|
||||||
|
organizationId: "org-id",
|
||||||
|
organizationName: "empty-org",
|
||||||
|
message: "Successfully mirrored organization: empty-org",
|
||||||
|
details: "Organization empty-org was processed successfully (no repositories found).",
|
||||||
|
status: "mirrored",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that getOrCreateGiteaOrg was called
|
||||||
|
expect(mockGetOrCreateGiteaOrg).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -629,60 +629,59 @@ export async function mirrorGitHubOrgToGitea({
|
|||||||
.where(eq(repositories.organization, organization.name));
|
.where(eq(repositories.organization, organization.name));
|
||||||
|
|
||||||
if (orgRepos.length === 0) {
|
if (orgRepos.length === 0) {
|
||||||
console.log(`No repositories found for organization ${organization.name}`);
|
console.log(`No repositories found for organization ${organization.name} - marking as successfully mirrored`);
|
||||||
return;
|
} else {
|
||||||
}
|
console.log(`Mirroring ${orgRepos.length} repositories for organization ${organization.name}`);
|
||||||
|
|
||||||
console.log(`Mirroring ${orgRepos.length} repositories for organization ${organization.name}`);
|
// Import the processWithRetry function
|
||||||
|
const { processWithRetry } = await import("@/lib/utils/concurrency");
|
||||||
|
|
||||||
// Import the processWithRetry function
|
// Process repositories in parallel with concurrency control
|
||||||
const { processWithRetry } = await import("@/lib/utils/concurrency");
|
await processWithRetry(
|
||||||
|
orgRepos,
|
||||||
|
async (repo) => {
|
||||||
|
// Prepare repository data
|
||||||
|
const repoData = {
|
||||||
|
...repo,
|
||||||
|
status: repo.status as RepoStatus,
|
||||||
|
visibility: repo.visibility as RepositoryVisibility,
|
||||||
|
lastMirrored: repo.lastMirrored ?? undefined,
|
||||||
|
errorMessage: repo.errorMessage ?? undefined,
|
||||||
|
organization: repo.organization ?? undefined,
|
||||||
|
forkedFrom: repo.forkedFrom ?? undefined,
|
||||||
|
mirroredLocation: repo.mirroredLocation || "",
|
||||||
|
};
|
||||||
|
|
||||||
// Process repositories in parallel with concurrency control
|
// Log the start of mirroring
|
||||||
await processWithRetry(
|
console.log(`Starting mirror for repository: ${repo.name} in organization ${organization.name}`);
|
||||||
orgRepos,
|
|
||||||
async (repo) => {
|
|
||||||
// Prepare repository data
|
|
||||||
const repoData = {
|
|
||||||
...repo,
|
|
||||||
status: repo.status as RepoStatus,
|
|
||||||
visibility: repo.visibility as RepositoryVisibility,
|
|
||||||
lastMirrored: repo.lastMirrored ?? undefined,
|
|
||||||
errorMessage: repo.errorMessage ?? undefined,
|
|
||||||
organization: repo.organization ?? undefined,
|
|
||||||
forkedFrom: repo.forkedFrom ?? undefined,
|
|
||||||
mirroredLocation: repo.mirroredLocation || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log the start of mirroring
|
// Mirror the repository
|
||||||
console.log(`Starting mirror for repository: ${repo.name} in organization ${organization.name}`);
|
await mirrorGitHubRepoToGiteaOrg({
|
||||||
|
octokit,
|
||||||
|
config,
|
||||||
|
repository: repoData,
|
||||||
|
giteaOrgId,
|
||||||
|
orgName: organization.name,
|
||||||
|
});
|
||||||
|
|
||||||
// Mirror the repository
|
return repo;
|
||||||
await mirrorGitHubRepoToGiteaOrg({
|
|
||||||
octokit,
|
|
||||||
config,
|
|
||||||
repository: repoData,
|
|
||||||
giteaOrgId,
|
|
||||||
orgName: organization.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
return repo;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
concurrencyLimit: 3, // Process 3 repositories at a time
|
|
||||||
maxRetries: 2,
|
|
||||||
retryDelay: 2000,
|
|
||||||
onProgress: (completed, total, result) => {
|
|
||||||
const percentComplete = Math.round((completed / total) * 100);
|
|
||||||
if (result) {
|
|
||||||
console.log(`Mirrored repository "${result.name}" in organization ${organization.name} (${completed}/${total}, ${percentComplete}%)`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onRetry: (repo, error, attempt) => {
|
{
|
||||||
console.log(`Retrying repository ${repo.name} in organization ${organization.name} (attempt ${attempt}): ${error.message}`);
|
concurrencyLimit: 3, // Process 3 repositories at a time
|
||||||
|
maxRetries: 2,
|
||||||
|
retryDelay: 2000,
|
||||||
|
onProgress: (completed, total, result) => {
|
||||||
|
const percentComplete = Math.round((completed / total) * 100);
|
||||||
|
if (result) {
|
||||||
|
console.log(`Mirrored repository "${result.name}" in organization ${organization.name} (${completed}/${total}, ${percentComplete}%)`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRetry: (repo, error, attempt) => {
|
||||||
|
console.log(`Retrying repository ${repo.name} in organization ${organization.name} (attempt ${attempt}): ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
console.log(`Organization ${organization.name} mirrored successfully`);
|
console.log(`Organization ${organization.name} mirrored successfully`);
|
||||||
|
|
||||||
@@ -703,7 +702,9 @@ export async function mirrorGitHubOrgToGitea({
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
organizationName: organization.name,
|
organizationName: organization.name,
|
||||||
message: `Successfully mirrored organization: ${organization.name}`,
|
message: `Successfully mirrored organization: ${organization.name}`,
|
||||||
details: `Organization ${organization.name} was mirrored to Gitea.`,
|
details: orgRepos.length === 0
|
||||||
|
? `Organization ${organization.name} was processed successfully (no repositories found).`
|
||||||
|
: `Organization ${organization.name} was mirrored to Gitea with ${orgRepos.length} repositories.`,
|
||||||
status: repoStatusEnum.parse("mirrored"),
|
status: repoStatusEnum.parse("mirrored"),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { describe, test, expect } from "bun:test";
|
import { describe, test, expect } from "bun:test";
|
||||||
import { jsonResponse, formatDate, truncate, safeParse } from "./utils";
|
import { jsonResponse, formatDate, truncate, safeParse, parseErrorMessage, showErrorToast } from "./utils";
|
||||||
|
|
||||||
describe("jsonResponse", () => {
|
describe("jsonResponse", () => {
|
||||||
test("creates a Response with JSON content", () => {
|
test("creates a Response with JSON content", () => {
|
||||||
const data = { message: "Hello, world!" };
|
const data = { message: "Hello, world!" };
|
||||||
const response = jsonResponse({ data });
|
const response = jsonResponse({ data });
|
||||||
|
|
||||||
expect(response).toBeInstanceOf(Response);
|
expect(response).toBeInstanceOf(Response);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses the provided status code", () => {
|
test("uses the provided status code", () => {
|
||||||
const data = { error: "Not found" };
|
const data = { error: "Not found" };
|
||||||
const response = jsonResponse({ data, status: 404 });
|
const response = jsonResponse({ data, status: 404 });
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
expect(response.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("correctly serializes complex objects", async () => {
|
test("correctly serializes complex objects", async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const data = {
|
const data = {
|
||||||
message: "Complex object",
|
message: "Complex object",
|
||||||
date: now,
|
date: now,
|
||||||
nested: { foo: "bar" },
|
nested: { foo: "bar" },
|
||||||
array: [1, 2, 3]
|
array: [1, 2, 3]
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = jsonResponse({ data });
|
const response = jsonResponse({ data });
|
||||||
const responseBody = await response.json();
|
const responseBody = await response.json();
|
||||||
|
|
||||||
expect(responseBody).toEqual({
|
expect(responseBody).toEqual({
|
||||||
message: "Complex object",
|
message: "Complex object",
|
||||||
date: now.toISOString(),
|
date: now.toISOString(),
|
||||||
@@ -43,22 +43,22 @@ describe("formatDate", () => {
|
|||||||
test("formats a date object", () => {
|
test("formats a date object", () => {
|
||||||
const date = new Date("2023-01-15T12:30:45Z");
|
const date = new Date("2023-01-15T12:30:45Z");
|
||||||
const formatted = formatDate(date);
|
const formatted = formatDate(date);
|
||||||
|
|
||||||
// The exact format might depend on the locale, so we'll check for parts
|
// The exact format might depend on the locale, so we'll check for parts
|
||||||
expect(formatted).toContain("2023");
|
expect(formatted).toContain("2023");
|
||||||
expect(formatted).toContain("January");
|
expect(formatted).toContain("January");
|
||||||
expect(formatted).toContain("15");
|
expect(formatted).toContain("15");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("formats a date string", () => {
|
test("formats a date string", () => {
|
||||||
const dateStr = "2023-01-15T12:30:45Z";
|
const dateStr = "2023-01-15T12:30:45Z";
|
||||||
const formatted = formatDate(dateStr);
|
const formatted = formatDate(dateStr);
|
||||||
|
|
||||||
expect(formatted).toContain("2023");
|
expect(formatted).toContain("2023");
|
||||||
expect(formatted).toContain("January");
|
expect(formatted).toContain("January");
|
||||||
expect(formatted).toContain("15");
|
expect(formatted).toContain("15");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 'Never' for null or undefined", () => {
|
test("returns 'Never' for null or undefined", () => {
|
||||||
expect(formatDate(null)).toBe("Never");
|
expect(formatDate(null)).toBe("Never");
|
||||||
expect(formatDate(undefined)).toBe("Never");
|
expect(formatDate(undefined)).toBe("Never");
|
||||||
@@ -69,18 +69,18 @@ describe("truncate", () => {
|
|||||||
test("truncates a string that exceeds the length", () => {
|
test("truncates a string that exceeds the length", () => {
|
||||||
const str = "This is a long string that needs truncation";
|
const str = "This is a long string that needs truncation";
|
||||||
const truncated = truncate(str, 10);
|
const truncated = truncate(str, 10);
|
||||||
|
|
||||||
expect(truncated).toBe("This is a ...");
|
expect(truncated).toBe("This is a ...");
|
||||||
expect(truncated.length).toBe(13); // 10 chars + "..."
|
expect(truncated.length).toBe(13); // 10 chars + "..."
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not truncate a string that is shorter than the length", () => {
|
test("does not truncate a string that is shorter than the length", () => {
|
||||||
const str = "Short";
|
const str = "Short";
|
||||||
const truncated = truncate(str, 10);
|
const truncated = truncate(str, 10);
|
||||||
|
|
||||||
expect(truncated).toBe("Short");
|
expect(truncated).toBe("Short");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles empty strings", () => {
|
test("handles empty strings", () => {
|
||||||
expect(truncate("", 10)).toBe("");
|
expect(truncate("", 10)).toBe("");
|
||||||
});
|
});
|
||||||
@@ -90,21 +90,71 @@ describe("safeParse", () => {
|
|||||||
test("parses valid JSON strings", () => {
|
test("parses valid JSON strings", () => {
|
||||||
const jsonStr = '{"name":"John","age":30}';
|
const jsonStr = '{"name":"John","age":30}';
|
||||||
const parsed = safeParse(jsonStr);
|
const parsed = safeParse(jsonStr);
|
||||||
|
|
||||||
expect(parsed).toEqual({ name: "John", age: 30 });
|
expect(parsed).toEqual({ name: "John", age: 30 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns undefined for invalid JSON strings", () => {
|
test("returns undefined for invalid JSON strings", () => {
|
||||||
const invalidJson = '{"name":"John",age:30}'; // Missing quotes around age
|
const invalidJson = '{"name":"John",age:30}'; // Missing quotes around age
|
||||||
const parsed = safeParse(invalidJson);
|
const parsed = safeParse(invalidJson);
|
||||||
|
|
||||||
expect(parsed).toBeUndefined();
|
expect(parsed).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns the original value for non-string inputs", () => {
|
test("returns the original value for non-string inputs", () => {
|
||||||
const obj = { name: "John", age: 30 };
|
const obj = { name: "John", age: 30 };
|
||||||
const parsed = safeParse(obj);
|
const parsed = safeParse(obj);
|
||||||
|
|
||||||
expect(parsed).toBe(obj);
|
expect(parsed).toBe(obj);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("parseErrorMessage", () => {
|
||||||
|
test("parses JSON error with error and troubleshooting fields", () => {
|
||||||
|
const errorMessage = JSON.stringify({
|
||||||
|
error: "Unexpected end of JSON input",
|
||||||
|
errorType: "SyntaxError",
|
||||||
|
timestamp: "2025-05-28T09:08:02.37Z",
|
||||||
|
troubleshooting: "JSON parsing error detected. Check Gitea server status and logs. Ensure Gitea is returning valid JSON responses."
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseErrorMessage(errorMessage);
|
||||||
|
|
||||||
|
expect(result.title).toBe("Unexpected end of JSON input");
|
||||||
|
expect(result.description).toBe("JSON parsing error detected. Check Gitea server status and logs. Ensure Gitea is returning valid JSON responses.");
|
||||||
|
expect(result.isStructured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses JSON error with title and description fields", () => {
|
||||||
|
const errorMessage = JSON.stringify({
|
||||||
|
title: "Connection Failed",
|
||||||
|
description: "Unable to connect to the server. Please check your network connection."
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseErrorMessage(errorMessage);
|
||||||
|
|
||||||
|
expect(result.title).toBe("Connection Failed");
|
||||||
|
expect(result.description).toBe("Unable to connect to the server. Please check your network connection.");
|
||||||
|
expect(result.isStructured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles plain string error messages", () => {
|
||||||
|
const errorMessage = "Simple error message";
|
||||||
|
|
||||||
|
const result = parseErrorMessage(errorMessage);
|
||||||
|
|
||||||
|
expect(result.title).toBe("Simple error message");
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
expect(result.isStructured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles Error objects", () => {
|
||||||
|
const error = new Error("Something went wrong");
|
||||||
|
|
||||||
|
const result = parseErrorMessage(error);
|
||||||
|
|
||||||
|
expect(result.title).toBe("Something went wrong");
|
||||||
|
expect(result.description).toBeUndefined();
|
||||||
|
expect(result.isStructured).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
131
src/lib/utils.ts
131
src/lib/utils.ts
@@ -36,20 +36,141 @@ export function safeParse<T>(value: unknown): T | undefined {
|
|||||||
return value as T;
|
return value as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced error message parsing for toast notifications
|
||||||
|
export interface ParsedErrorMessage {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
isStructured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseErrorMessage(error: unknown): ParsedErrorMessage {
|
||||||
|
// Handle Error objects
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return parseErrorMessage(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string messages
|
||||||
|
if (typeof error === "string") {
|
||||||
|
// Try to parse as JSON first
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(error);
|
||||||
|
|
||||||
|
// Check for common structured error formats
|
||||||
|
if (typeof parsed === "object" && parsed !== null) {
|
||||||
|
// Format 1: { error: "message", errorType: "type", troubleshooting: "info" }
|
||||||
|
if (parsed.error) {
|
||||||
|
return {
|
||||||
|
title: parsed.error,
|
||||||
|
description: parsed.troubleshooting || parsed.errorType || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 2: { title: "title", description: "desc" }
|
||||||
|
if (parsed.title) {
|
||||||
|
return {
|
||||||
|
title: parsed.title,
|
||||||
|
description: parsed.description || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 3: { message: "msg", details: "details" }
|
||||||
|
if (parsed.message) {
|
||||||
|
return {
|
||||||
|
title: parsed.message,
|
||||||
|
description: parsed.details || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, treat as plain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain string message
|
||||||
|
return {
|
||||||
|
title: error,
|
||||||
|
description: undefined,
|
||||||
|
isStructured: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle objects directly
|
||||||
|
if (typeof error === "object" && error !== null) {
|
||||||
|
const errorObj = error as any;
|
||||||
|
|
||||||
|
if (errorObj.error) {
|
||||||
|
return {
|
||||||
|
title: errorObj.error,
|
||||||
|
description: errorObj.troubleshooting || errorObj.errorType || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorObj.title) {
|
||||||
|
return {
|
||||||
|
title: errorObj.title,
|
||||||
|
description: errorObj.description || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorObj.message) {
|
||||||
|
return {
|
||||||
|
title: errorObj.message,
|
||||||
|
description: errorObj.details || undefined,
|
||||||
|
isStructured: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown types
|
||||||
|
return {
|
||||||
|
title: String(error),
|
||||||
|
description: undefined,
|
||||||
|
isStructured: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced toast helper that parses structured error messages
|
||||||
|
export function showErrorToast(error: unknown, toast: any) {
|
||||||
|
const parsed = parseErrorMessage(error);
|
||||||
|
|
||||||
|
if (parsed.description) {
|
||||||
|
// Use sonner's rich toast format with title and description
|
||||||
|
toast.error(parsed.title, {
|
||||||
|
description: parsed.description,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Simple error toast
|
||||||
|
toast.error(parsed.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function for API requests
|
// Helper function for API requests
|
||||||
|
|
||||||
export async function apiRequest<T>(
|
export async function apiRequest<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
options: (RequestInit & { data?: any }) = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const response = await httpRequest<T>(`${API_BASE}${endpoint}`, {
|
// Handle the custom 'data' property by converting it to 'body'
|
||||||
|
const { data, ...requestOptions } = options;
|
||||||
|
const finalOptions: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers || {}),
|
...(requestOptions.headers || {}),
|
||||||
},
|
},
|
||||||
...options,
|
...requestOptions,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// If data is provided, stringify it and set as body
|
||||||
|
if (data !== undefined) {
|
||||||
|
finalOptions.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await httpRequest<T>(`${API_BASE}${endpoint}`, finalOptions);
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { organizations } from "@/lib/db";
|
import { organizations, repositories, configs } from "@/lib/db";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql, and, count } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
membershipRoleEnum,
|
membershipRoleEnum,
|
||||||
type OrganizationsApiResponse,
|
type OrganizationsApiResponse,
|
||||||
@@ -25,24 +25,114 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch the user's active configuration to respect filtering settings
|
||||||
|
const [config] = await db
|
||||||
|
.select()
|
||||||
|
.from(configs)
|
||||||
|
.where(and(eq(configs.userId, userId), eq(configs.isActive, true)));
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return jsonResponse({
|
||||||
|
data: {
|
||||||
|
success: false,
|
||||||
|
error: "No active configuration found for this user",
|
||||||
|
},
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubConfig = config.githubConfig as {
|
||||||
|
mirrorStarred: boolean;
|
||||||
|
skipForks: boolean;
|
||||||
|
privateRepositories: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
const rawOrgs = await db
|
const rawOrgs = await db
|
||||||
.select()
|
.select()
|
||||||
.from(organizations)
|
.from(organizations)
|
||||||
.where(eq(organizations.userId, userId))
|
.where(eq(organizations.userId, userId))
|
||||||
.orderBy(sql`name COLLATE NOCASE`);
|
.orderBy(sql`name COLLATE NOCASE`);
|
||||||
|
|
||||||
const orgsWithIds: Organization[] = rawOrgs.map((org) => ({
|
// Calculate repository breakdowns for each organization
|
||||||
...org,
|
const orgsWithBreakdown = await Promise.all(
|
||||||
status: repoStatusEnum.parse(org.status),
|
rawOrgs.map(async (org) => {
|
||||||
membershipRole: membershipRoleEnum.parse(org.membershipRole),
|
// Build base conditions for this organization (without private/fork filters)
|
||||||
lastMirrored: org.lastMirrored ?? undefined,
|
const baseConditions = [
|
||||||
errorMessage: org.errorMessage ?? undefined,
|
eq(repositories.userId, userId),
|
||||||
}));
|
eq(repositories.organization, org.name)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!githubConfig.mirrorStarred) {
|
||||||
|
baseConditions.push(eq(repositories.isStarred, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count with all user config filters applied
|
||||||
|
const totalConditions = [...baseConditions];
|
||||||
|
if (githubConfig.skipForks) {
|
||||||
|
totalConditions.push(eq(repositories.isForked, false));
|
||||||
|
}
|
||||||
|
if (!githubConfig.privateRepositories) {
|
||||||
|
totalConditions.push(eq(repositories.isPrivate, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(and(...totalConditions));
|
||||||
|
|
||||||
|
// Get public count
|
||||||
|
const publicConditions = [...baseConditions, eq(repositories.isPrivate, false)];
|
||||||
|
if (githubConfig.skipForks) {
|
||||||
|
publicConditions.push(eq(repositories.isForked, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [publicCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(and(...publicConditions));
|
||||||
|
|
||||||
|
// Get private count (only if private repos are enabled in config)
|
||||||
|
const [privateCount] = githubConfig.privateRepositories ? await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
...baseConditions,
|
||||||
|
eq(repositories.isPrivate, true),
|
||||||
|
...(githubConfig.skipForks ? [eq(repositories.isForked, false)] : [])
|
||||||
|
)
|
||||||
|
) : [{ count: 0 }];
|
||||||
|
|
||||||
|
// Get fork count (only if forks are enabled in config)
|
||||||
|
const [forkCount] = !githubConfig.skipForks ? await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(repositories)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
...baseConditions,
|
||||||
|
eq(repositories.isForked, true),
|
||||||
|
...(!githubConfig.privateRepositories ? [eq(repositories.isPrivate, false)] : [])
|
||||||
|
)
|
||||||
|
) : [{ count: 0 }];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...org,
|
||||||
|
status: repoStatusEnum.parse(org.status),
|
||||||
|
membershipRole: membershipRoleEnum.parse(org.membershipRole),
|
||||||
|
lastMirrored: org.lastMirrored ?? undefined,
|
||||||
|
errorMessage: org.errorMessage ?? undefined,
|
||||||
|
repositoryCount: totalCount.count,
|
||||||
|
publicRepositoryCount: publicCount.count,
|
||||||
|
privateRepositoryCount: privateCount.count,
|
||||||
|
forkRepositoryCount: forkCount.count,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const resPayload: OrganizationsApiResponse = {
|
const resPayload: OrganizationsApiResponse = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Organizations fetched successfully",
|
message: "Organizations fetched successfully",
|
||||||
organizations: orgsWithIds,
|
organizations: orgsWithBreakdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse({ data: resPayload, status: 200 });
|
return jsonResponse({ data: resPayload, status: 200 });
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export interface GitOrg {
|
|||||||
isIncluded: boolean;
|
isIncluded: boolean;
|
||||||
status: RepoStatus;
|
status: RepoStatus;
|
||||||
repositoryCount: number;
|
repositoryCount: number;
|
||||||
|
publicRepositoryCount?: number;
|
||||||
|
privateRepositoryCount?: number;
|
||||||
|
forkRepositoryCount?: number;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user